최소 베이스 이미지, 멀티스테이지 빌드, 루트 없는 컨테이너 등 Dockerfile 보안 모범 사례와 불변 이미지 전략을 실습합니다.
컨테이너 이미지는 읽기 전용 레이어의 스택으로 구성됩니다. Dockerfile의 각 명령어(FROM, RUN, COPY 등)가 하나의 레이어를 생성하며, 이 레이어들은 **Union Filesystem(유니온 파일시스템)**을 통해 하나의 파일시스템처럼 보입니다.
보안 관점에서 중요한 점은, 한번 레이어에 포함된 데이터는 삭제해도 이전 레이어에 남아있다는 것입니다.
FROM ubuntu:22.04
COPY secret.key /tmp/secret.key
RUN ./setup.sh --key-file /tmp/secret.key
RUN rm /tmp/secret.key # 이전 레이어에 여전히 존재위 예시에서 rm 명령으로 파일을 삭제해도, 두 번째 레이어에 secret.key가 그대로 남아있습니다. docker history나 레이어 추출 도구로 누구든 확인할 수 있습니다.
Dockerfile에 시크릿을 절대 포함하지 마세요. 빌드 시 시크릿이 필요하다면 Docker BuildKit의 --mount=type=secret 기능을 사용하세요. 이 방법은 시크릿을 레이어에 남기지 않습니다.
베이스 이미지의 크기가 클수록 포함된 패키지가 많고, 그만큼 알려진 취약점이 포함될 가능성이 높아집니다. 최소 베이스 이미지를 선택하는 것이 이미지 보안의 첫걸음입니다.
| 이미지 | 크기 | 셸 포함 | 패키지 매니저 | 적합한 용도 |
|---|---|---|---|---|
ubuntu:22.04 | ~77MB | O | apt | 개발/디버깅 |
alpine:3.19 | ~7MB | O | apk | 경량 범용 |
gcr.io/distroless/static | ~2MB | X | X | Go 바이너리 |
gcr.io/distroless/cc | ~20MB | X | X | C/C++ 앱 |
cgr.dev/chainguard/static | ~2MB | X | X | 정적 바이너리 |
Google이 관리하는 Distroless 이미지는 애플리케이션 실행에 필요한 최소한의 런타임만 포함합니다. 셸, 패키지 매니저, 기타 유틸리티가 모두 제거되어 있어 공격자가 컨테이너에 침투하더라도 할 수 있는 행동이 극히 제한됩니다.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]Chainguard는 보안에 특화된 컨테이너 이미지를 제공합니다. 모든 이미지에 SBOM이 포함되어 있고, 서명이 되어 있으며, 알려진 CVE가 0인 상태를 목표로 합니다.
# Chainguard의 Node.js 이미지 스캔
trivy image cgr.dev/chainguard/node:latest
# 일반 Node.js 이미지 스캔 (비교용)
trivy image node:20일반 node:20 이미지에서 수백 개의 취약점이 발견되는 반면, Chainguard 이미지에서는 취약점이 거의 없는 것을 확인할 수 있습니다.
프로덕션 이미지는 Distroless나 Chainguard를 기본으로 사용하고, 디버깅이 필요할 때만 debug 태그가 붙은 변형 이미지를 임시로 사용하세요. Distroless는 gcr.io/distroless/static-debian12:debug 태그로 셸이 포함된 버전을 제공합니다.
**Multi-stage Build(멀티스테이지 빌드)**는 빌드 도구와 런타임 환경을 분리하여 최종 이미지에 불필요한 도구가 포함되지 않도록 하는 기법입니다.
# === 빌드 스테이지 ===
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# === 런타임 스테이지 ===
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]빌드 스테이지에서는 Go 컴파일러와 소스 코드가 포함되지만, 최종 이미지에는 컴파일된 바이너리만 들어갑니다. 이 차이는 이미지 크기뿐만 아니라 보안 측면에서도 큰 의미가 있습니다.
# === 의존성 설치 스테이지 ===
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
# === 빌드 스테이지 ===
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# === 런타임 스테이지 ===
FROM cgr.dev/chainguard/node:latest
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]의존성 설치, 빌드, 런타임을 세 단계로 분리함으로써 최종 이미지에 devDependencies와 소스 코드가 포함되지 않습니다.
컨테이너 내부에서 root로 프로세스를 실행하면, 컨테이너 탈출 시 호스트에서도 root 권한을 얻을 수 있습니다. **Rootless Container(루트 없는 컨테이너)**는 이 위험을 줄이는 가장 기본적인 방어책입니다.
FROM node:20-alpine
# 전용 사용자 생성
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -D appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
# 루트가 아닌 사용자로 전환
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]Dockerfile에서 USER를 설정하더라도, 쿠버네티스에서 **SecurityContext(보안 컨텍스트)**를 통해 추가적인 제약을 걸어야 합니다.
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}위 설정에서 주목할 항목들을 살펴보겠습니다.
runAsNonRoot: true: root 사용자로 실행되는 컨테이너를 차단합니다.allowPrivilegeEscalation: false: 프로세스가 부모보다 높은 권한을 획득하는 것을 방지합니다.readOnlyRootFilesystem: true: 파일시스템을 읽기 전용으로 만들어 악성코드 작성을 차단합니다.capabilities.drop: ALL: 모든 리눅스 **Capability(커널 권한)**를 제거합니다.readOnlyRootFilesystem을 활성화하면 임시 파일을 쓸 수 없으므로, 필요한 경로에 emptyDir 볼륨을 마운트해야 합니다. 위 예시에서는 /tmp에 임시 볼륨을 연결했습니다.
실전에서 적용할 수 있는 Dockerfile 보안 체크리스트입니다.
# 나쁜 예: latest 태그는 언제든 바뀔 수 있음
FROM node:latest
# 좋은 예: 버전과 다이제스트를 명시
FROM node:20.12.0-alpine@sha256:abc123...latest 태그는 언제든 다른 이미지를 가리킬 수 있으므로, 정확한 버전과 다이제스트를 함께 명시해야 빌드 재현성과 보안을 동시에 확보할 수 있습니다.
# 나쁜 예: 추천 패키지까지 모두 설치
RUN apt-get update && apt-get install -y curl wget git vim
# 좋은 예: 필요한 패키지만 최소 설치 후 캐시 정리
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# 시크릿을 레이어에 남기지 않고 사용
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \
npm install --production && \
npm config delete //registry.npmjs.org/:_authToken빌드 시 다음과 같이 실행합니다.
DOCKER_BUILDKIT=1 docker build \
--secret id=npm_token,src=./.npm-token \
-t myapp:latest .Immutable Image(불변 이미지) 전략은 한 번 빌드된 이미지를 수정하지 않고, 변경이 필요하면 항상 새로운 이미지를 빌드하는 접근법입니다.
이 전략의 핵심 원칙은 다음과 같습니다.
readOnlyRootFilesystem으로 컨테이너 내 파일 변경을 차단합니다.# Git 커밋 해시를 태그로 사용
docker build -t myapp:$(git rev-parse --short HEAD) .
# 시맨틱 버전 + 빌드 번호
docker build -t myapp:1.2.3-build.456 .이번 장에서는 컨테이너 이미지 보안의 기초를 다루었습니다. 핵심 내용을 정리하면 다음과 같습니다.
USER 지시어와 SecurityContext로 루트 실행을 방지합니다.다음 장에서는 빌드된 이미지에 포함된 취약점을 자동으로 탐지하는 이미지 스캐닝을 다룹니다. Trivy, Grype, Snyk을 비교하고, CI/CD 파이프라인에 스캐닝 게이트를 통합하는 방법을 실습합니다.
이 글이 도움이 되셨나요?
Trivy, Grype, Snyk 컨테이너 스캐너를 비교하고, CI/CD 파이프라인에 취약점 스캐닝 게이트를 통합하여 안전한 이미지만 배포하는 방법을 다룹니다.
컨테이너 환경에서 마주하는 보안 위협과 공격 벡터를 분석하고, 방어 심층 전략과 OWASP 쿠버네티스 보안 체크리스트를 기반으로 한 보안 로드맵을 소개합니다.
SBOM(소프트웨어 자재 명세서)의 개념과 필요성, SPDX와 CycloneDX 형식을 비교하고, Syft와 Trivy로 SBOM을 생성하여 공급망 가시성을 확보하는 방법을 실습합니다.