도커 고급 강좌 #3 이미지 보안 — non-root, distroless, Trivy 스캔

8 분 소요

지금까지 굴려온 이미지를 한 번 멈추고 보안 시각으로 점검합니다. 컨테이너 보안은 깊이 들어가면 한 책 분량이지만, 현장에서 효과가 큰 도구 몇 개만 손에 익히면 위험의 큰 덩어리는 가시 범위에 들어옵니다.

도커 고급 강좌 시리즈에서 이번 글의 위치:

컨테이너 보안 한 그림 #

위협을 두 갈래로 나눠 보면 도구도 자연스럽게 갈라집니다.

두 갈래의 보안
   런타임 격리                     이미지 위생
   ──────────                       ─────────
   • USER (비특권 사용자)           • distroless / minimal base
   • read-only 루트                 • 최소 의존성
   • capabilities drop              • Trivy / Grype 취약점 스캔
   • seccomp / AppArmor             • hadolint Dockerfile 린트
   • no-new-privileges              • SBOM ([#4])
                                   • 서명 ([#4])

이번 글은 즉시 손이 닿는 도구 위주로 갑니다 — 왼쪽의 USER / read-only / capabilities, 오른쪽의 distroless / Trivy / hadolint. 깊은 정책(seccomp, AppArmor)은 운영 환경에 따라 그 위에 얹힙니다.

1) USER — 비특권 사용자로 떨어트리기 #

기본 이미지는 거의 모두 root로 시작합니다. 컨테이너 내부의 root가 호스트 root와 같지는 않지만(user namespace 격리), 컨테이너 안에서 root 권한이 있으면 컨테이너 격리를 깨뜨릴 수 있는 익스플로잇 표면이 분명히 더 큽니다.

기본 패턴:

USER 떨어트리기
FROM python:3.14-slim

WORKDIR /app
RUN groupadd --system app && useradd --system --gid app --home /app app

COPY --chown=app:app requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=app:app app.py .

USER app
CMD ["python", "app.py"]
  • groupadd --system app && useradd --system --gid app ... — system 사용자(UID < 1000)로 만든 비대화 사용자
  • COPY --chown=app:app — 복사할 때부터 권한을 그 사용자로
  • USER app — 이후 모든 명령(특히 CMD)이 이 사용자로 실행됨

베이스 이미지가 비특권 사용자를 미리 마련해 둔 경우 #

자주 쓰는 베이스 이미지들은 이미 비특권 사용자를 만들어 둡니다.

이미지미리 있는 사용자
node:*node (UID 1000)
nginx:*nginx
postgres:*postgres (가끔 자체 entrypoint가 처리)
gcr.io/distroless/*nonroot (UID 65532)
node — 한 줄로 끝
FROM node:20-slim
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

“왜 동작 안 함” 의 흔한 지점 #

USER를 떨어뜨리고 빌드/실행했을 때 자주 만나는 에러는 거의 권한 문제입니다.

  • 포트 80/443 바인딩 실패 — root가 아닌 사용자는 1024 미만 포트를 못 연다. 컨테이너 안 포트는 8000/8080 같은 비특권 포트를 쓰고, 호스트로 매핑할 때 -p 80:8000으로 푸시.
  • /var/log/xxx 쓰기 실패 — root가 만든 디렉터리에 권한이 없음. chown 또는 stdout 로깅 (중급 #6)으로 해결.
  • pip install 시점에 root 였는데 런타임이 비특권 — 글로벌 site-packages는 그대로 읽힘. 문제 없음.

2) Read-only 루트 파일시스템 #

컨테이너 안에서 코드가 디스크에 쓰는 일이 있나요? 잘 보면 거의 없는 경우가 많습니다. 그렇다면 루트 파일시스템 자체를 읽기 전용으로 마운트해 — 침입자가 무언가를 떨어트릴 여지를 줄일 수 있습니다.

docker run
docker run --read-only myapp
compose.yaml
services:
  web:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp        # /tmp는 쓰기 가능한 메모리 마운트로
    volumes:
      - app-data:/app/data    # 영속 데이터는 named volume으로

read_only: true만 주면 앱이 임시 파일을 쓰는 경우(예: 라이브러리가 /tmp에 쓰는 캐시)에서 깨질 수 있습니다. 그래서 보통 **tmpfs:/tmp**를 메모리 마운트로 풀어주는 패턴을 함께 씁니다.

장점:

  • 컨테이너에 침투해도 디스크에 도구를 받아 떨어뜨릴 수 없음
  • 앱이 자기 코드를 변조하지 못함
  • 어떤 데이터가 영속이고 어떤 게 일회성인지 명시적이 됨

운영 컨테이너에서는 자주 보는 옵션입니다. 처음 적용 시 깨지는 지점(라이브러리의 캐시 디렉터리 등)을 한 번 점검하는 노력이 필요합니다.

3) Capabilities drop #

리눅스 capability는 root 권한을 잘게 쪼갠 단위입니다. 컨테이너는 기본으로 14 개의 capability를 들고 시작하는데, 대부분의 앱은 0 개로도 동작 합니다.

모든 capability 떨어뜨리기
docker run --cap-drop=ALL myapp
compose.yaml
services:
  web:
    image: myapp
    cap_drop:
      - ALL
    cap_add:                # 정말 필요한 것만 다시 더함
      - NET_BIND_SERVICE    # 1024 미만 포트 바인딩 (USER가 비특권일 때 필요)

--cap-drop=ALL 한 줄로 공격 표면이 분명히 줄어듭니다. 앱이 깨지면 그때 필요한 capability를 하나씩 추가하는 흐름이 안전합니다.

자주 다시 더해주는 capability:

  • NET_BIND_SERVICE — 비특권 사용자가 1024 미만 포트 바인딩
  • CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID — entrypoint가 권한 작업을 할 때 (보통 비대화 entrypoint는 안 필요)
  • KILL — 다른 프로세스에 신호 보내기 (PID 1이 자식 관리할 때 가끔)

--security-opt no-new-privileges #

setuid 비트로 권한을 올리는 시도를 컨테이너 내부에서 차단합니다.

권한 상승 차단
docker run --security-opt no-new-privileges myapp
compose.yaml
services:
  web:
    security_opt:
      - no-new-privileges:true

컨테이너 안의 setuid 바이너리(예: sudo, passwd)가 권한을 올리지 못하게 합니다. 컨테이너 안에 그런 바이너리가 있는 경우가 거의 없지만(distroless 면 아예 없음), 한 줄 추가에 비해 안전 마진이 큽니다.

4) Distroless 다시 — 보안 시각 #

중급 #1에서 슬리밍 도구로 짚었던 distroless를 보안 시각에서 다시 봅니다.

distroless의 보안 효과:

  • 셸 없음 — 침입자가 들어와도 bash/sh가 없어 인터랙티브 쉘 획득 어려움
  • 패키지 매니저 없음apt/yum으로 도구를 받아 깔 수 없음
  • 코어유틸 없음cat, ls, wget, curl도 없음
  • nonroot 사용자 미리 준비USER nonroot:nonroot 한 줄로 끝
Go + distroless
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]

태그에 :nonroot가 붙으면 USER가 nonroot로 미리 설정된 변형입니다. USER nonroot:nonroot를 명시 적어주는 편이 의도가 분명해 좋습니다.

distroless의 트레이드오프는 중급 #6에서 다룬 디버깅 어려움입니다. 그래서 운영 컨테이너는 distroless, 같은 아키의 디버그 컨테이너는 일반 베이스로 두 종을 만드는 패턴이 자주 보입니다.

5) Chainguard Images — 또 다른 minimal 기반 #

Chainguard Images는 distroless의 강력한 대안으로, minimal 기반에 자주 갱신되는 이미지를 제공합니다.

이미지
cgr.dev/chainguard/staticdistroless static과 비슷
cgr.dev/chainguard/pythonminimal Python
cgr.dev/chainguard/nodeminimal Node
cgr.dev/chainguard/nginxminimal Nginx

특징은 CVE 갱신 속도가 빠르고 SBOM / 서명이 기본으로 따라 붙는다는 점입니다. 이번 글의 주제와 다음 글(#4)의 SBOM,서명을 둘 다 만족시키는 베이스 후보입니다. 도커 공식 이미지로도 충분한 경우가 많지만, 보안 게이트가 엄격한 환경에선 자주 보입니다.

6) Trivy — 알려진 취약점 스캔 #

이미지에 들어 있는 패키지의 알려진 취약점(CVE)을 찾는 도구. 거의 표준 입니다.

설치 (Homebrew)
brew install aquasecurity/trivy/trivy

# 직접 도커로
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image myapp:1.0
기본 스캔
trivy image myapp:1.0

출력 예:

trivy 출력
myapp:1.0 (debian 12.5)
======================
Total: 8 (LOW: 1, MEDIUM: 4, HIGH: 2, CRITICAL: 1)

┌──────────────┬───────────────┬──────────┬──────────────────┬───────────────┬─────────────────────────────┐
│   Library    │ Vulnerability │ Severity │ Installed Version│ Fixed Version │            Title            │
├──────────────┼───────────────┼──────────┼──────────────────┼───────────────┼─────────────────────────────┤
│ libssl3      │ CVE-2024-...  │ HIGH     │ 3.0.11-1         │ 3.0.13-1      │ ...                         │
│ ...          │               │          │                  │               │                             │
└──────────────┴───────────────┴──────────┴──────────────────┴───────────────┴─────────────────────────────┘

CI 게이트 패턴 #

HIGH 이상 발견 시 빌드 실패
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:1.0
  • --exit-code 1 — 발견 시 종료 코드 1 (CI가 실패로 인식)
  • --severity — 어느 심각도부터 카운트할지
  • --ignore-unfixed — 패치가 아직 안 나온 건 빼고 (수정할 수 없는 것에 발이 묶이지 않도록)

GitHub Actions와 잘 어울립니다.

.github/workflows/scan.yml (요약)
- uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/me/myapp:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    severity: HIGH,CRITICAL
    ignore-unfixed: true
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: trivy-results.sarif

SARIF 형식으로 출력해 GitHub Security 탭에 결과를 올릴 수 있습니다.

다른 스캐너 #

  • Grype — Anchore의 도구. 비슷한 기능, 비슷한 사용법
  • Snyk — 매니지드 스캔 SaaS, 풍부한 통합
  • Docker Scout — Docker Desktop에 내장

대부분 같은 CVE DB를 본다 — 도구 선택은 환경/취향. 한 가지를 CI에 끼워넣는 게 안 끼워넣는 것보다 훨씬 큰 차이입니다.

7) Hadolint — Dockerfile 린트 #

이미지의 보안 문제는 Dockerfile의 작성 패턴에서 시작되는 일이 많습니다. Hadolint는 이걸 정적으로 잡아주는 린터.

설치
brew install hadolint
실행
hadolint Dockerfile

출력 예:

hadolint 결과
Dockerfile:5 DL3008 warning: Pin versions in apt-get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:7 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:9 DL3018 warning: Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
Dockerfile:12 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
Dockerfile:20 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

자주 잡히는 룰:

의미
DL3008apt 패키지 버전 핀
DL3018apk 패키지 버전 핀
DL3009rm -rf /var/lib/apt/lists/* 빠짐
DL3015--no-install-recommends 권장
DL3025CMD/ENTRYPOINT exec form
DL3002USER를 비특권으로 떨어뜨리지 않음
DL3007베이스 이미지에 latest 사용

CI에 hadolint Dockerfile 한 줄을 넣으면 베이스 위생이 분명히 좋아집니다.

보안 체크리스트 — 한곳에 #

운영 컨테이너 하나를 점검할 때 돌릴 만한 체크리스트.

이미지 / Dockerfile
□ FROM에 명시적 버전 태그 (latest 금지)
□ 멀티스테이지로 빌드 도구 분리 ([#1])
□ USER 비특권으로 떨어뜨리기
□ COPY --chown으로 권한 명시
□ RUN 안에서 apt 캐시 정리
□ CMD/ENTRYPOINT는 exec form
□ 빌드 시 --mount=type=secret으로 비밀 처리 ([중급 #5])
□ hadolint 통과
□ Trivy HIGH/CRITICAL 통과
런타임 / compose.yaml
□ read_only: true + tmpfs (가능한 경우에)
□ cap_drop: ALL + cap_add 최소
□ security_opt: no-new-privileges:true
□ 자원 한계: mem_limit, cpus ([#5])
□ healthcheck 정의 ([중급 #4])
□ restart: unless-stopped ([중급 #4], [#6])
□ 비밀은 secrets: 또는 외부 매니저 ([중급 #5])
□ DB 같은 내부 서비스에 -p는 127.0.0.1 바인딩

정리 #

이번 글에서 잡은 그림:

  • 컨테이너 보안은 런타임 격리 + 이미지 위생 두 갈래
  • **USER**로 비특권 사용자 떨어뜨리기 — 가장 큰 효과 / 적은 비용
  • read_only: true + tmpfs, cap_drop: ALL, **no-new-privileges**가 운영 컨테이너의 안전 기본값
  • distroless / Chainguard 베이스로 공격 표면을 좁힘 — 디버깅 트레이드오프는 한 단락만
  • Trivy / Grype / Docker Scout로 알려진 CVE를 CI 게이트로
  • Hadolint로 Dockerfile 자체를 린트 — 자주 잡히는 룰 한 줌
  • 컨테이너 한 개의 점검 체크리스트 — 빌드 / 런타임 두 묶음

다음 글(#4 SBOM과 서명(cosign))에서는 한 발 더 나아가 — 이 이미지에 무엇이 들어 있는가를 기계가 읽을 수 있는 형태(SBOM)로 만들고, 그 이미지가 누가 만든 것인지를 cosign 서명으로 검증하는 단계로 갑니다. 공급망(supply chain) 보안의 입구입니다.

X