Certified Kubernetes Security Specialist (CKS) #13 Minimal images: distroless, scratch (Supply Chain)

#12 Pod-to-Pod mTLS: Cilium까지로 Minimize Microservice Vulnerabilities도메인을 마쳤습니다. 이번 글부터 비중 20%의 Supply Chain Security 도메인에 들어갑니다. 공급망 보안은 클러스터에 올라가는 이미지가 어디서 왔고 무엇을 담고 있는지를 통제하는 일이며, 그 첫걸음은 이미지 자체를 최소화하는 것입니다. 담은 것이 적을수록 공격할 곳도 적기 때문입니다.

이번 글에서는 큰 이미지가 왜 위험한지, distroless와 scratch가 무엇을 덜어내는지, alpine과 비교해 어떻게 고르는지, 그리고 멀티스테이지 빌드로 빌드 도구를 런타임에서 떼어내는 표준 패턴을 Dockerfile 예제로 잡겠습니다.

왜 이미지 최소화가 공급망 보안의 시작인가 #

공급망 보안은 “내 클러스터에서 도는 코드가 정확히 무엇인가"를 통제하는 일입니다. 그 코드의 출발점이 컨테이너 이미지이며, 이미지 안에는 내 애플리케이션뿐 아니라 베이스 이미지가 끌고 온 운영체제 라이브러리, 패키지 매니저, 셸, 각종 유틸리티가 함께 들어 있습니다. 이 부수적인 구성 요소가 바로 공격 표면입니다.

이미지를 최소화한다는 것은 애플리케이션을 돌리는 데 꼭 필요한 것만 남기고 나머지를 전부 덜어내는 것입니다. 덜어낸 만큼 다음 세 가지가 줄어듭니다.

  • CVE 수. 패키지가 적으면 알려진 취약점도 적습니다. ubuntu 기반 이미지를 Trivy로 스캔하면 애플리케이션과 무관한 OS 패키지에서 수십 건의 CVE가 나오는 일이 흔합니다.
  • 공격 후 행동 반경. 셸과 패키지 매니저가 없으면 공격자가 컨테이너에 침입해도 추가 도구를 내려받거나 대화형 셸을 띄우기 어렵습니다.
  • 이미지 크기. 작은 이미지는 배포가 빠르고 전송 중 변조 가능성도 줄어듭니다.

이미지 최소화가 #14 Image scan이나 #15 이미지 서명보다 먼저 오는 이유가 여기에 있습니다. 스캔할 표면과 서명할 대상 자체를 작게 만드는 것이 공급망 보안의 토대이기 때문입니다.

큰 이미지가 넓히는 공격 표면 #

full OS를 베이스로 쓰는 이미지에는 애플리케이션이 절대 쓰지 않는 구성 요소가 가득합니다. 대표적으로 다음이 위험합니다.

구성 요소공격자가 악용하는 방식
셸(/bin/sh, /bin/bash)침입 후 대화형 명령 실행, 스크립트 다운로드,실행
패키지 매니저(apt, apk, yum)컨테이너 안에서 추가 공격 도구 설치
curl,wget외부에서 페이로드 내려받기, 데이터 유출
컴파일러,빌드 도구(gcc, make)컨테이너 안에서 익스플로잇 컴파일
불필요한 OS 라이브러리알려진 CVE 누적, 취약점 연쇄(chain)

핵심은 이 도구들이 애플리케이션 실행에는 필요 없다는 점입니다. Go로 컴파일한 정적 바이너리는 셸도 패키지 매니저도 없이 혼자 돕니다. 그런데 베이스 이미지가 이 모든 것을 끌고 오면, 평소에는 쓰이지 않다가 침입 시점에만 공격자에게 도구로 활용됩니다. 그래서 “쓰지 않는 것은 아예 담지 않는다"가 최소화의 원칙입니다.

distroless, scratch, alpine 비교 #

이미지를 최소화하는 베이스 선택지는 크게 세 가지입니다. 각각 무엇을 담고 무엇을 덜어내는지 표로 잡겠습니다.

항목scratchdistrolessalpine
베이스 내용완전히 빈 이미지libc,CA 인증서,tzdata 등 런타임 최소 구성만musl libc + BusyBox 기반 최소 리눅스
없음없음(:debug 태그 제외)있음(/bin/sh)
패키지 매니저없음없음있음(apk)
크기가장 작음(수 MB이하 가능)작음(수십 MB)작음(약 5MB대)
non-root 기본직접 설정:nonroot 태그 제공직접 설정
적합한 워크로드정적 바이너리(Go, Rust)libc 필요한 컴파일,인터프리터 언어셸,패키지가 필요한 경우
디버깅 난이도높음(셸 없음)높음(:debug 또는 ephemeral container)낮음(셸 있음)

선택의 기준은 간단합니다. 애플리케이션이 정적 바이너리라면 scratch가 가장 작습니다. libc나 인증서, 타임존 같은 런타임 의존이 있는 경우라면 그것만 담은 distroless가 안전한 기본값입니다. alpine은 셸과 패키지 매니저가 있어 다루기 쉽지만, 그만큼 공격 표면이 distroless보다 넓고 musl libc 특유의 호환성 문제가 생길 수 있어 보안 관점에서는 distroless를 우선합니다.

distroless 이미지는 구글이 gcr.io/distroless/ 경로로 언어별 변형을 제공합니다. static, base, cc, java, nodejs, python3 등이 있으며, 각각 :nonroot:debug 태그를 함께 제공합니다.

멀티스테이지 빌드로 빌드 도구 제거 #

이미지를 최소화하려면 빌드에 필요한 도구와 런타임에 필요한 것을 분리해야 합니다. 컴파일러, 빌드 의존성, 소스 코드는 빌드 시점에만 필요하고 런타임에는 결과물 바이너리만 있으면 됩니다. 이 분리를 한 Dockerfile 안에서 해 주는 것이 멀티스테이지 빌드입니다.

원리는 다음과 같습니다.

  1. build stage. 컴파일러가 든 무거운 베이스(예: golang)에서 소스를 빌드해 바이너리를 만듭니다.
  2. runtime stage. distroless나 scratch 같은 최소 베이스에서, build stage가 만든 바이너리만 COPY --from으로 가져옵니다.

최종 이미지에는 마지막 stage의 내용만 남습니다. 컴파일러도 소스 코드도 중간 산출물도 전부 빠지고, 실행 파일과 최소 런타임만 남습니다.

Go 애플리케이션: build stage → distroless #

Go는 정적 바이너리를 만들 수 있어 최소화에 가장 잘 맞는 언어입니다.

# build stage
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 정적 링크 바이너리 생성(CGO 비활성)
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server

# runtime stage: distroless static, non-root
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /app/server /app/server
USER nonroot:nonroot
ENTRYPOINT ["/app/server"]

여기서 핵심은 두 가지입니다. 첫째, CGO_ENABLED=0으로 정적 링크 바이너리를 만들어 libc 의존조차 없앴습니다. 그래서 가장 작은 distroless/static을 쓸 수 있습니다. 둘째, 최종 이미지에는 golang 베이스가 끌고 온 컴파일러와 도구가 하나도 들어가지 않습니다. COPY --from=build로 바이너리 한 개만 가져왔기 때문입니다.

정적 바이너리라면 scratch까지 내려갈 수도 있습니다.

FROM scratch
COPY --from=build /app/server /server
# HTTPS 호출이 필요하면 CA 인증서를 직접 복사
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]

scratch는 정말로 비어 있으므로 CA 인증서나 타임존 파일이 필요하면 build stage에서 직접 복사해야 합니다. 이 번거로움 때문에 실무에서는 인증서를 미리 담은 distroless/static을 더 자주 씁니다.

Node.js 애플리케이션: build stage → distroless #

인터프리터 언어도 멀티스테이지로 의존성 설치 단계를 런타임에서 분리합니다.

# build stage: 의존성 설치
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# runtime stage: distroless nodejs, non-root
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
USER nonroot
CMD ["server.js"]

distroless/nodejs 베이스에는 node 런타임만 들어 있고 npm도 셸도 없습니다. build stage에서 npm ci로 설치한 node_modules와 애플리케이션 코드만 가져오므로, 최종 이미지에 패키지 매니저가 남지 않습니다.

non-root 사용자로 돌리기 #

이미지를 최소화하면서 반드시 함께 챙길 것이 non-root 실행입니다. 컨테이너가 root로 돌면 침입 시 컨테이너 안에서 할 수 있는 일이 많아지고, 노드 권한 상승으로 이어질 여지도 커집니다.

distroless는 :nonroot 태그를 쓰면 UID 65532인 non-root 사용자로 기본 실행됩니다. scratch나 일반 베이스에서는 직접 사용자를 만들어 지정합니다.

FROM alpine:3.20
RUN addgroup -S app && adduser -S app -G app
USER app
ENTRYPOINT ["/app/server"]

이미지 차원의 USER 지정과 함께, 워크로드 차원에서는 이미지의 USER를 신뢰하지 않고 Pod의 securityContext로 한 번 더 못박는 것이 좋습니다.

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  allowPrivilegeEscalation: false

runAsNonRoot: true는 이미지가 root로 실행되도록 설정돼 있으면 Pod 자체를 기동 거부하므로, 최소 이미지와 짝을 이루는 방어선입니다. securityContext 자체는 #9 Pod Security AdmissionCKAD #4에서 다룬 내용과 이어집니다.

셸 없는 이미지를 디버깅하는 법 #

distroless와 scratch의 가장 큰 실무 불편은 셸이 없어 kubectl exec로 들어갈 수 없다는 점입니다. 그러나 셸을 없앤 것이 보안의 핵심이므로, 디버깅을 위해 셸을 다시 넣는 것은 본말이 뒤집힌 선택입니다. 두 가지 정석이 있습니다.

1) ephemeral container #

운영 중인 컨테이너를 건드리지 않고, 디버깅 도구가 든 임시 컨테이너를 같은 Pod에 끼워 넣는 방법입니다. 대상 컨테이너의 프로세스 네임스페이스를 공유하므로 셸 없는 컨테이너의 파일 시스템과 프로세스를 들여다볼 수 있습니다.

kubectl debug -it mypod \
  --image=busybox:1.36 \
  --target=app \
  --share-processes

이 방식은 운영 이미지에 셸을 추가하지 않으면서도 디버깅을 가능하게 하므로, CKS와 CKA에서 모두 권장하는 정석입니다. ephemeral container의 상세한 동작은 CKA 트랙의 트러블슈팅 편에서 다룬 내용과 같습니다.

2) distroless :debug 태그 #

구글 distroless는 BusyBox 셸이 포함된 :debug 태그를 별도로 제공합니다. 개발,디버깅 단계에서만 이 태그로 빌드해 셸을 쓰고, 운영 배포에는 셸 없는 일반 태그를 쓰는 식으로 분리합니다.

# 디버깅 빌드에서만 사용
FROM gcr.io/distroless/static:debug

운영 이미지에는 절대 :debug를 남기지 않는 것이 원칙입니다. 셸을 남기는 순간 최소화의 이점이 사라지기 때문입니다.

시험 단골: Dockerfile 고치기 #

CKS의 Supply Chain Security도메인에서 자주 나오는 작업은 주어진 Dockerfile을 더 안전하게 고치는 것입니다. 전형적인 출제는 다음과 같습니다.

다음 Dockerfile은 단일 stage로 빌드되며 큰 베이스 이미지를 쓰고 root로 실행됩니다. 멀티스테이지 빌드로 바꾸고 distroless 베이스를 적용하고 non-root로 실행되도록 수정하라.

수정 전 Dockerfile은 보통 이런 모양입니다.

FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /server ./cmd/server
ENTRYPOINT ["/server"]

이 경우 채점이 보는 포인트는 명확합니다.

  • 멀티스테이지로 분리했는가. FROM ... AS build와 두 번째 FROM이 있는가.
  • 최소 베이스로 교체했는가. 최종 stage가 distroless나 scratch인가.
  • COPY --from으로 바이너리만 가져왔는가. 소스나 컴파일러가 최종 이미지에 남지 않았는가.
  • non-root로 실행되는가. :nonroot 태그나 USER 지정이 있는가.

수정 후는 앞서 본 Go 예제와 같은 형태가 됩니다. 시험장에서는 빌드가 실제로 성공하고 컨테이너가 정상 기동하는지까지 확인해야 부분 점수가 아닌 만점을 받습니다. CGO_ENABLED=0을 빠뜨려 정적 링크가 안 된 채 scratch에 올리면 바이너리가 libc를 찾다가 기동에 실패하므로, 베이스와 빌드 옵션의 짝을 맞추는 것이 함정입니다.

시험 포인트 #

  • 이미지 최소화 = 공격 표면 축소. 셸,패키지 매니저,불필요한 OS 라이브러리를 덜어내면 CVE 수와 침입 후 행동 반경이 함께 줄어듭니다.
  • scratch는 빈 베이스, distroless는 런타임 최소 구성만. 정적 바이너리는 scratch, libc,인증서가 필요하면 distroless가 기본값입니다. alpine은 셸,apk가 있어 편하지만 공격 표면이 더 넓습니다.
  • 멀티스테이지 빌드가 핵심 기술. build stage에서 컴파일하고 runtime stage에 COPY --from으로 바이너리만 가져오면 컴파일러와 소스가 최종 이미지에서 빠집니다.
  • non-root 짝맞추기. distroless :nonroot 태그나 USER 지정에 더해, Pod의 securityContextrunAsNonRoot: true를 못박습니다.
  • 셸 없는 이미지 디버깅. 운영 이미지에 셸을 넣지 말고 kubectl debug의 ephemeral container 또는 distroless :debug 태그를 씁니다.
  • 시험 단골은 Dockerfile 리팩터링. 멀티스테이지화 + 최소 베이스 교체 + non-root 적용을 한 번에 요구하며, 빌드 성공과 기동 확인까지 마쳐야 만점입니다.

정리 #

공급망 보안은 클러스터에 올라가는 이미지를 통제하는 일이고, 그 출발점은 이미지를 최소화해 공격 표면 자체를 줄이는 것입니다. 큰 이미지가 끌고 오는 셸과 패키지 매니저는 애플리케이션 실행에는 쓸모가 없으면서 침입 시점에만 공격자에게 도구가 됩니다. distroless와 scratch는 이 부수 요소를 덜어낸 최소 베이스이며, 멀티스테이지 빌드로 빌드 도구를 런타임에서 떼어내면 컴파일러와 소스 없이 결과물만 담은 작은 이미지를 만들 수 있습니다. 여기에 non-root 실행과 ephemeral container 디버깅을 짝지으면, 작으면서도 운영 가능한 이미지가 됩니다.

다음: Image scan #

이미지를 작게 만들었다면, 다음은 그 안에 남은 것이 안전한지 확인할 차례입니다. 아무리 최소화해도 베이스 라이브러리나 애플리케이션 의존성에 알려진 취약점이 들어 있을 수 있기 때문입니다.

#14 Image scan: Trivy, Kubesec, KubeLinter에서는 이미지의 CVE를 Trivy로 스캔하는 법, 스캔 결과를 심각도로 거르고 게이트로 거는 법, 그리고 매니페스트의 보안 설정을 Kubesec과 KubeLinter로 정적 점검하는 패턴까지 직접 돌려 보며 정리하겠습니다.

X