Skip to content
ioob.dev
Go back

Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들

· 8분 읽기
Docker 시리즈 (10/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

보안의 큰 그림

무엇으로부터 무엇을 지키는지를 먼저 보자.

flowchart TB
    subgraph BUILD["빌드 시점"]
      B1["최소 베이스 이미지"]
      B2["비루트 USER"]
      B3["빌드 시크릿"]
      B4["레이어 정리"]
    end
    subgraph DIST["유통 시점"]
      D1["이미지 스캔 (Trivy)"]
      D2["서명 (cosign)"]
      D3["SBOM"]
    end
    subgraph RUN["런타임"]
      R1["read-only FS"]
      R2["capabilities drop"]
      R3["리소스 제한"]
      R4["네트워크/마운트 제한"]
      R5["Secret 주입"]
    end
    BUILD --> DIST --> RUN

공격은 한 지점을 뚫고 측면 이동(lateral movement)을 통해 퍼진다. 빌드-유통-런타임 세 구간에 각각 장벽을 두면 한 구간이 뚫려도 다음 구간에서 막힌다.

1. 최소 베이스와 업데이트 관리

베이스가 크면 취약점도 같이 커진다. 8편의 멀티스테이지 + distroless/slim 조합은 보안 관점에서도 이득이다.

# 피해야 할 패턴 — 의존성과 도구가 한가득
FROM ubuntu:22.04

# 권장 — 필요한 것만
FROM gcr.io/distroless/static-debian12:nonroot

또 하나 중요한 건 “베이스 이미지를 주기적으로 업데이트하는 것”이다. 작년에 빌드한 이미지의 alpine:3.18은 이미 오래된 패키지들이 쌓여 있다. CI 스케줄로 정기 재빌드를 돌리거나, 이미지 태그를 고정하지 말고 마이너까지만(alpine:3.20) 고정해서 패치를 흡수한다.

2. 비루트 실행 — USER 지시어

기본적으로 컨테이너는 루트(UID 0)로 실행된다. 안 그래도 된다면 안 그러는 게 맞다. 탈출 취약점이 터졌을 때 호스트 권한까지 가지는 경로를 끊는다.

FROM alpine:3.20

RUN addgroup -S app && adduser -S -G app -u 10001 app

WORKDIR /app
COPY --chown=app:app . .

USER app

ENTRYPOINT ["/app/server"]

몇 가지 포인트가 있다.

Kubernetes에서는 Pod spec에 한 번 더 못 박을 수 있다.

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001

이미지에 USER가 있어도 Pod spec에 runAsNonRoot: true를 추가하면 이미지가 실수로 루트로 바뀌었을 때 kubelet이 아예 기동을 거부한다.

3. 이미지 스캔 — Trivy

빌드한 이미지에 어떤 CVE가 들어있는지 스스로 확인하지 않으면 모른다. Trivy는 가장 널리 쓰이는 오픈소스 컨테이너 스캐너다.

# 설치 (macOS)
brew install trivy

# 로컬 이미지 스캔
trivy image myapp:1.4.2

# 심각도 필터, 수정 가능한 것만
trivy image --severity HIGH,CRITICAL --ignore-unfixed myapp:1.4.2

# 취약점 있으면 CI에서 실패시키기
trivy image --exit-code 1 --severity CRITICAL myapp:1.4.2

CI에 붙여 CRITICAL이 있으면 빌드를 깨도록 게이팅하는 게 기본 설정이다. Harbor나 GitHub Actions는 Trivy가 기본 통합되어 있어서 push 시 자동 스캔도 가능하다.

Snyk, Grype, Clair 등 대안도 있다. 핵심은 “스캔을 안 하는 것”만 피하면 된다.

4. SBOM — 공급망 가시성

SBOM(Software Bill of Materials)은 이미지 안에 들어있는 라이브러리/패키지의 목록이다. 취약점 공시 때 “우리가 이 라이브러리를 쓰고 있는가?”를 즉답하려면 SBOM이 있어야 한다.

docker buildx build --sbom=true -t myapp:1.4.2 .

# SBOM 조회
docker buildx imagetools inspect myapp:1.4.2 --format '{{json .SBOM}}'

# syft로 별도 생성 (SPDX, CycloneDX 포맷)
syft myapp:1.4.2 -o spdx-json > sbom.json

최근에는 레지스트리에 SBOM을 이미지와 함께 붙여두는 패턴이 표준화되고 있다(OCI referrers API). 규제 산업이라면 감사 요건으로 곧 요구받게 될 확률이 높다.

5. 시크릿 관리 — 빌드와 런타임의 함정

가장 자주 보는 실수는 Dockerfile에 비밀번호나 API 키를 박아두는 것이다.

# 절대 하지 말 것
ARG DB_PASSWORD=mysecret
ENV DB_PASSWORD=$DB_PASSWORD
RUN curl -u admin:mysecret https://internal/...

ARGENV는 이미지 레이어에 그대로 박힌다. docker history로 누구나 볼 수 있다. 빌드 시점과 런타임 시점의 시크릿 취급이 전혀 다르다는 걸 알아야 한다.

빌드 시점 — --mount=type=secret

BuildKit은 빌드 중에만 파일을 마운트하고 최종 이미지에는 남기지 않는 기능을 제공한다.

# syntax=docker/dockerfile:1.7
FROM alpine:3.20
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm install
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t myapp:1.4.2 .

.npmrc가 빌드 중엔 /root/.npmrc로 마운트되지만, 그 레이어는 이미지에 남지 않는다. 11편에서 BuildKit 관련해서 더 자세히 다룬다.

런타임 시점 — 환경 변수 vs. 시크릿 저장소

로컬이나 Compose 개발 환경에서는 환경 변수로 충분하다.

docker run -e DB_PASSWORD="$DB_PASSWORD" myapp:1.4.2

프로덕션에서는 이것도 위험하다. docker inspect로 노출되고, 로그에 우연히 찍힐 수 있다. Kubernetes라면 Secret 리소스(+ External Secrets Operator로 Vault/SSM 연동), Swarm이라면 docker secret create를 쓴다.

# Docker Swarm 시크릿
echo "mysecret" | docker secret create db_password -
docker service create \
  --secret db_password \
  --name myapp myapp:1.4.2

# 컨테이너 안에서는 /run/secrets/db_password 파일로 읽음

원칙은 “환경 변수가 아닌 파일로 주입, 읽기 전용 마운트, 최소 권한 계정으로 접근”이다.

6. 읽기 전용 루트 파일시스템

대부분의 앱은 런타임에 파일시스템을 변경할 필요가 없다. 쓰기가 필요한 디렉토리(/tmp, 로그 디렉토리 등)만 별도로 마운트하면 된다.

docker run --read-only \
  --tmpfs /tmp:rw,size=64m \
  -v app-logs:/var/log/app \
  myapp:1.4.2

--read-only는 루트 FS를 읽기 전용으로 만든다. 공격자가 컨테이너 안에서 악성 바이너리를 떨어뜨리거나 /etc/passwd를 고치려는 시도가 원천 차단된다. Kubernetes에서는 readOnlyRootFilesystem: true로 같은 효과를 낸다.

securityContext:
  readOnlyRootFilesystem: true
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

실무에서 안 먹히는 앱도 있다. 일부 프레임워크는 /tmp/var/cache 같은 경로에 쓰기를 요구한다. 그런 경로만 tmpfsemptyDir로 열어주면 된다.

7. Linux capabilities — 꼭 필요한 것만 남긴다

루트 권한이라고 해서 전부 필요한 건 아니다. 컨테이너는 기본적으로 14개 정도의 capabilities를 가진 채 시작한다. 대부분의 앱은 이 중 절반 이상이 필요 없다.

# 전부 drop하고 필요한 것만 add
docker run \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  myapp:1.4.2

NET_BIND_SERVICE는 1024 미만 포트 바인딩에 필요하다. 앱이 80/443 같은 low port를 안 쓰면 이것도 뺀다. 웹 서버를 8080 같은 high port로 바인딩하고 앞에 프록시를 두면 --cap-drop=ALL만 해도 된다.

Kubernetes에서는 이렇게 표현된다.

securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]

allowPrivilegeEscalation: false도 중요하다. setuid 바이너리를 통한 권한 상승을 막는다.

8. 권한 상승 방어의 전체 구성

지금까지의 내용을 한 Compose 파일에 모으면 보안 하드닝된 서비스 블록이 이런 모습이다.

services:
  web:
    image: myapp:1.4.2
    read_only: true
    tmpfs:
      - /tmp:size=64m
    user: "10001:10001"
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - "no-new-privileges:true"
    environment:
      DB_HOST: db
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

한 줄 한 줄이 공격 경로를 하나씩 닫는다. 모두 기본값을 뒤집은 것뿐인데, 기본값을 뒤집지 않으면 위험이 누적된다.

9. 네트워크/마운트 제한

--privileged는 사실상 호스트 권한이다. Docker 소켓(/var/run/docker.sock)을 컨테이너에 마운트하면 컨테이너 안에서 호스트의 다른 컨테이너를 마음대로 제어할 수 있다. 정당한 이유가 있는 경우(CI runner, 호스트 레벨 관찰) 외에는 쓰지 않는다.

# 피해야 할 패턴
docker run --privileged ...
docker run -v /var/run/docker.sock:/var/run/docker.sock ...
docker run --net=host ...

꼭 필요하다면 해당 컨테이너는 신뢰된 이미지로만 구성하고, 네트워크 노출도 내부로만 둔다.

10. 이미지 서명과 검증 — cosign

이미지가 정말 우리 CI에서 나온 건지, 중간에 바꿔치기되지 않았는지 검증하고 싶으면 서명이 필요하다. cosign은 Sigstore 생태계의 표준 도구다.

# 키 생성
cosign generate-key-pair

# 서명
cosign sign --key cosign.key harbor.example.com/team/myapp:1.4.2

# 검증
cosign verify --key cosign.pub harbor.example.com/team/myapp:1.4.2

Kubernetes 쪽에서 admission controller(Kyverno, Gatekeeper, sigstore-policy-controller)로 “서명 안 된 이미지는 거부” 정책을 걸면 공급망 공격의 큰 벡터를 차단한다.

실무 체크리스트

새로 만드는 이미지 / 서비스마다 최소한 다음은 확인한다.

전부 실천하려면 귀찮아 보이지만, 한번 프로젝트 템플릿에 녹여두면 새 서비스마다 반복 설정할 필요가 없다.

여기까지의 상태

컨테이너를 빌드-유통-런타임 전 구간에서 하드닝하는 구체적 방법을 훑었다. 이어서 빌드 자체의 속도와 유연성을 올려주는 도구를 다룬다.


다음 편에서는 BuildKit과 고급 빌드 기능을 정리한다. 캐시 마운트, 멀티 아키텍처 빌드(buildx), 빌드 시크릿, 병렬 빌드까지 — 빌드 시간을 단축하고 CI/CD 파이프라인을 정리하는 핵심 도구들이다.

11편: BuildKit과 고급 빌드


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 9편 — 레지스트리, 이미지는 어디에 두는가
Next Post
Docker 11편 — BuildKit과 고급 빌드