- full (debian · ubuntu) — 수백 MB. 도구가 다 있다. 디버깅이 쉽다
- slim — 불필요한 패키지를 뺀 것. 대부분 여기서 시작한다
- alpine — 약 5~8MB. musl libc · busybox
- distroless — 셸도 패키지 매니저도 없다. 런타임만
- scratch — 아무것도 없다. 정적 바이너리 전용
작은 것이 왜 중요한가
-
배포가 빨라진다 — 노드마다 pull 해야 한다
- 오토스케일링에서 파드가 뜨는 시간에 직접 들어간다
-
취약점이 준다 — 안 쓰는 패키지도 CVE 를 갖는다
-
공격 표면이 준다 — 셸·컴파일러가 있으면 침투 후 할 수 있는 일이 많다
-
크기 줄이기와 보안 강화는 대부분 같은 작업이다
alpine 은 만능이 아니다
-
장점 매우 작다
-
주의
- musl libc 를 쓴다 — glibc 로 빌드한 바이너리가 안 돈다
- 증상: "not found" 인데 파일은 있다 (동적 링커를 못 찾는 것)
- DNS 해석 동작이 glibc 와 미묘하게 다르다
- 언어 런타임에 따라 성능 차이가 보고된 사례가 있다
- (musl 의 메모리 할당자 특성)
- musl libc 를 쓴다 — glibc 로 빌드한 바이너리가 안 돈다
-
'작다' 만 보고 고르지 않는다. 런타임과의 궁합을 확인한다
-
자바처럼 JRE 를 얹는 경우는 slim 계열이 무난하다
distroless·scratch 의 트레이드오프
-
얻는 것 — 셸·패키지 매니저가 없다 → 침투해도 할 수 있는 것이 거의 없다
- 이미지가 작다. 스캔 결과가 깨끗하다
-
잃는 것 — docker exec 로 들어갈 수 없다
- curl·ps 같은 진단 도구가 없다
대응 — 네임스페이스를 공유하는 임시 컨테이너로 디버깅한다
- docker run --rm -it --pid=container:web --network=container:web \
- nicolaka/netshoot sh
- kubectl debug -it pod/web --image=nicolaka/netshoot --target=web
디버깅 경로를 먼저 갖춘 뒤에 옮기는 것이 순서다
크기를 줄이는 순서
- ① 멀티스테이지 — 가장 효과가 크다. 빌드 도구를 통째로 뺀다
- ② 베이스 교체 — 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 인덱스를 안 지우면 이미지에 그대로 남는다 다음 레이어에서 지우면 소용없다 (삭제는 표식일 뿐)
업데이트 전략
베이스 이미지는 '한 번 고르고 끝' 이 아니다 새 CVE 가 나오면 베이스를 올려 다시 빌드해야 한다
- FROM 태그를 마이너 버전까지만 고정한다 (node:22-alpine)
- 패치는 자동으로 따라온다
- 주기적으로 --pull 로 다시 빌드한다 (주 1회 정도)
- 재빌드 후 스캔 결과를 비교한다
태그를 다이제스트로 완전히 고정하면 재현성은 최고지만 보안 패치를 놓친다 → 자동 갱신 도구(Renovate 류)와 함께 쓴다
조직 표준 베이스
팀마다 다른 베이스를 쓰면
- 노드마다 레이어가 중복 저장된다
- 취약점 대응을 곳곳에서 따로 한다
- 보안 설정(비루트 사용자 · CA 인증서 · 타임존)이 제각각이다
내부 베이스 이미지를 하나 만들어 두면 이 셋이 한 번에 정리된다
- FROM company/java-base:21 그 위에 애플리케이션만 얹는다
면접 함정
- ❌ "alpine이 항상 정답" → musl 궁합과 런타임 특성을 확인해야 한다.
- ❌ "distroless가 무조건 안전하고 좋다" → 디버깅 경로가 없으면 장애 대응이 막힌다.