이미지 크기와 보안 — 같은 문제의 두 얼굴
이미지를 줄이는 일과 안전하게 만드는 일은 대부분 같은 작업이다. 안 쓰는 것을 빼면 크기도 줄고 공격 표면도 준다.
1. 크기가 왜 중요한가
- 배포가 느려진다 — 노드마다 pull 해야 한다
- 레지스트리 저장·전송 비용
- 취약점이 늘어난다 — 안 쓰는 패키지도 CVE 를 갖는다
- 공격 표면이 넓어진다 — 셸·컴파일러·패키지 매니저가 있으면
- 침투 후 할 수 있는 일이 많아진다
2. 베이스 이미지 선택
- full (debian · ubuntu) — 수백 MB. 도구가 다 있다. 디버깅이 쉽다
- slim — 불필요한 패키지를 뺀 것. 대부분 여기서 시작한다
- alpine — 약 5~8MB. musl libc · busybox
- distroless — 셸도 패키지 매니저도 없다. 런타임만
- scratch — 아무것도 없다. 정적 바이너리 전용
alpine 은 만능이 아니다
-
장점 매우 작다
-
주의
- musl libc 를 쓴다 — glibc 로 빌드한 바이너리가 안 돈다
- 증상: "not found" 인데 파일은 있다 (동적 링커를 못 찾는 것)
- DNS 해석 동작이 glibc 와 미묘하게 다르다
- 언어 런타임에 따라 성능 차이가 보고된 사례가 있다
- (musl 의 메모리 할당자 특성)
- musl libc 를 쓴다 — glibc 로 빌드한 바이너리가 안 돈다
-
'작다' 만 보고 고르지 않는다. 런타임과의 궁합을 확인한다
-
자바처럼 JRE 를 얹는 경우는 slim 계열이 무난하다
distroless·scratch 의 트레이드오프
-
얻는 것 — 셸·패키지 매니저가 없다 → 침투해도 할 수 있는 것이 거의 없다
-
잃는 것 — docker exec 로 들어갈 수 없다
- curl·ps 같은 진단 도구가 없다
- 디버깅은 임시 컨테이너(kubectl debug · docker debug)로 한다
-
운영 성숙도가 필요하다 — 디버깅 경로를 먼저 갖춘 뒤에 옮긴다
3. 크기를 줄이는 순서
- ① 멀티스테이지 — 가장 효과가 크다. 빌드 도구를 통째로 뺀다
- ② 베이스 교체 — full → slim → alpine/distroless
- ③ 레이어 정리 — 설치와 정리를 같은 RUN 에
- ④ .dockerignore — 빌드 컨텍스트에서 불필요한 것 제외
- ⑤ 언어별 기법 — 자바 jlink · Go 정적 빌드 · Node 프로덕션 의존만
# ③ 설치와 정리는 같은 레이어에서
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends 만으로도 수십~수백 MB 가 준다 apt 인덱스(/var/lib/apt/lists)를 안 지우면 그대로 이미지에 남는다 다음 레이어에서 지우면 소용없다 (02편 — 삭제는 표식일 뿐)
# ⑤ 자바 — 필요한 모듈만 담은 런타임을 만든다
FROM eclipse-temurin:21-jdk AS jre-build
RUN jlink --add-modules java.base,java.logging,java.sql \
--strip-debug --no-man-pages --no-header-files --compress=2 \
--output /javaruntime
FROM debian:12-slim
COPY --from=jre-build /javaruntime /opt/java
ENV PATH="/opt/java/bin:${PATH}"
4. 루트로 돌리지 않는다
RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app
USER 10001:10001
기본은 root 다 — 명시하지 않으면 컨테이너 안 프로세스가 root 로 돈다
왜 위험한가
- 컨테이너 탈출 취약점이 나오면 호스트에서도 root 다
- 볼륨 마운트한 호스트 파일을 마음대로 고칠 수 있다
- 컨테이너 안에서 패키지를 설치하거나 시스템을 바꿀 수 있다
이름이 아니라 숫자 UID 로 지정하는 편이 낫다 쿠버네티스의 runAsNonRoot 검증이 숫자 UID 를 요구한다 이름만 있으면 판정할 수 없다
1024 미만 포트는 root 만 bind 할 수 있다
- 컨테이너 안에서 8080 을 쓰고, 밖에서 -p 80:8080 으로 매핑한다
- 애플리케이션이 80 을 직접 열 이유가 없다
읽기 전용 루트
docker run --read-only --tmpfs /tmp myapp
루트 파일시스템을 읽기 전용으로 만든다
- 침투해도 바이너리를 심거나 설정을 바꿀 수 없다
- 쓰기가 필요한 곳만 tmpfs·볼륨으로 연다
애플리케이션이 어디에 쓰는지 파악해야 하므로 도입에 품이 든다 그만큼 효과는 확실하다
5. 권한을 더 좁힌다
docker run \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges \
--read-only \
myapp
-
capability — root 권한을 쪼갠 것. 기본으로 몇 개가 켜져 있다
- 전부 끄고 필요한 것만 켠다
-
no-new-privileges — setuid 바이너리로 권한이 올라가는 것을 막는다
-
seccomp — 허용할 시스템 콜 목록. 도커는 기본 프로파일을 적용한다
- --privileged 를 쓰면 이 모든 방어가 통째로 꺼진다
--privileged 는 사실상 호스트 권한이다
--privileged 는 '조금 더 많은 권한' 이 아니다
- 모든 capability
- seccomp·AppArmor 해제
- 호스트 장치 접근
컨테이너 탈출이 어렵지 않아진다 필요한 이유를 설명할 수 없다면 쓰지 않는다 필요하면 그 능력 하나만 --cap-add 로 준다
도커 소켓 마운트는 곧 호스트 장악이다
-v /var/run/docker.sock:/var/run/docker.sock
이 한 줄이면 그 컨테이너는 호스트에 아무 컨테이너나 띄울 수 있다
- --privileged 로 호스트 루트를 마운트한 컨테이너를 만들면 끝이다
CI 러너·모니터링 도구에서 흔히 보이는 설정이다 대안 — 소켓 프록시로 필요한 API 만 노출 · rootless 모드 · DinD 대신 Kaniko/Buildah
6. 비밀을 이미지에 넣지 않는다
이미지에 남는 경로
-
COPY 로 넣은 설정 파일 (.env · 키 파일)
-
ARG 로 넘긴 값 → docker history 에 명령이 남는다
-
ENV → docker inspect 로 그대로 보인다
-
앞 레이어에 넣고 뒤에서 지운 파일 → 레이어에 남아 있다
-
빌드 시점 비밀 — RUN --mount=type=secret (03편)
-
런타임 비밀 — 환경변수 주입 · 시크릿 매니저 · 파일 마운트
.dockerignore 에 .env · *.pem · .git 을 넣는 것이 최소 방어다
# 이미지에 무엇이 들었는지 확인
docker history --no-trunc myapp:1.0
docker save myapp:1.0 | tar -tv | head -50
7. 취약점 스캔
docker scout cves myapp:1.0
trivy image myapp:1.0
grype myapp:1.0
스캐너는 이미지 안 패키지 목록을 뽑아 CVE 데이터베이스와 대조한다
결과를 다루는 법
- 전부 0 으로 만들려 하지 않는다 — 현실적으로 불가능하다
- Critical/High 부터, 그중 '실제로 쓰는 경로' 를 먼저 본다
- 대부분은 베이스 이미지를 최신으로 올리면 함께 해결된다
- 안 쓰는 패키지를 빼는 것이 가장 확실한 해결이다 (그래서 크기와 같은 문제다)
CI 에 넣되 처음부터 빌드를 막지 않는다
- 기준선을 정하고 '새로 늘어난 것' 만 실패시키는 편이 정착이 쉽다
SBOM
이미지에 무엇이 들었는지의 목록이다
docker buildx build --sbom=true --provenance=true --push .
Log4Shell 때 드러난 문제가 "우리가 무엇을 쓰는지 목록이 없다" 였다 SBOM 이 있으면 새 CVE 가 나왔을 때 영향 범위를 검색으로 찾는다 공급망 보안의 출발점이다
8. 기준선 — CIS Benchmark
CIS Docker Benchmark · CIS Kubernetes Benchmark 비상업적 용도로 PDF 를 무료로 받을 수 있다 (간단한 정보 입력 필요) 호스트·데몬·이미지·런타임 항목별 점검 기준을 제공한다
자동 점검
Docker Bench for Security 호스트·데몬 설정
kube-bench 쿠버네티스 클러스터
"무엇을 점검해야 하는지" 를 처음부터 만들 필요가 없다 기준선으로 시작해 조직에 맞게 예외를 정리하는 순서가 효율적이다
한눈에 정리
- 크기와 보안 — 같은 문제다. 안 쓰는 것을 빼면 둘 다 좋아진다
- 베이스 — slim 이 무난. alpine 은 musl 궁합 확인. distroless 는 디버깅 경로 먼저
- 줄이는 순서 — 멀티스테이지 → 베이스 교체 → 레이어 정리 → dockerignore → 언어별
- USER — 기본이 root 다. 숫자 UID 로 지정한다 (runAsNonRoot 검증)
- 읽기 전용 — --read-only + tmpfs. 침투 후 할 수 있는 일을 없앤다
- 권한 — cap-drop=ALL 후 필요한 것만. no-new-privileges
- --privileged — 방어가 통째로 꺼진다. 이유를 설명 못 하면 쓰지 않는다
- docker.sock — 마운트하면 곧 호스트 장악이다. 프록시·rootless·Kaniko
- 비밀 — history·inspect·레이어에 남는다. 빌드는 시크릿 마운트로
- 스캔 — Critical/High 부터. 대부분 베이스 업데이트로 해결
- SBOM — 새 CVE 가 나왔을 때 영향 범위를 찾는 근거
- CIS — 무료 기준선 + Docker Bench · kube-bench 로 자동 점검
출처 — Docker Docs: "Best practices for writing Dockerfiles"(USER·베이스 선택) · "Build secrets" · "Runtime privilege and Linux capabilities" · "Docker Scout" · CIS Docker/Kubernetes Benchmarks · CNCF "Kubernetes hardening with kube-bench"