イメージを小さくする順序
한국어 원문으로 표시합니다.
한 줄 요약
멀티스테이지의 핵심은 문법이 아니라 최종 스테이지로 무엇만 복사할 것인가입니다. 빌더 스테이지의 레이어는 최종 이미지의 매니페스트에 아예 들어가지 않습니다.
왜 이게 필요했나
1.9GB 짜리 노드 이미지가 있었습니다. 노드가 늘어날 때마다 파드가 Ready
가 되기까지 90초, 롤백 한 번에 또 90초. 추측 대신 docker history 를
먼저 봅니다.
RUN npm install 901MB
COPY . . 286MB
RUN apt-get update && ... 412MB
ADD file:07cf5b0f8bd1d5d5… 74.8MB
소스 코드가 286MB 일 리는 없으니 .git 이나 로컬 node_modules 가
통째로 들어갔다는 뜻입니다. 도구로 한 번 더 재면 낭비가 408MB,
효율이 71.8% 로 나옵니다. 408MB 가 전송되고 저장되지만 컨테이너에서는
보이지도 않습니다.
어떻게 동작하나
여기서 가장 반직관적인 사실. 아래처럼 정리해도 한 바이트도 줄지 않습니다.
0B RUN rm -rf /var/lib/apt/lists/*
2.1MB RUN apt-get purge -y build-essential && apt-get autoremove -y
18.4MB RUN make -C /src all
396MB RUN apt-get update && apt-get install -y build-essential
purge 레이어는 크기를 줄이기는커녕 2.1MB 를 더했습니다. 삭제 표식과
갱신된 패키지 DB 가 새 레이어에 기록됐기 때문입니다. 396MB 는 그대로입니다.
규칙은 하나입니다. 만든 것은 만든 그 RUN 안에서 지운다. 다만 "그러니 모든 RUN 을 합쳐라"로 이어지면 안 됩니다. 그것은 캐시 재사용을 파괴합니다. 합쳐야 하는 것은 생성과 정리가 짝을 이루는 명령들뿐입니다.
멀티스테이지의 안티패턴도 분명합니다.
FROM node:22 AS builder
COPY . . ; RUN npm install && npm run build
FROM node:22-slim
COPY --from=builder /app /app # devDeps, 소스, 테스트, 빌드 캐시 전부
베이스만 바꾼 셈이라 절감폭이 거의 없습니다. 산출물만 골라 복사해야 합니다.
현장에서 만나는 모습
베이스를 고를 때 크기만 보면 손해를 봅니다.
| 베이스 | 크기 | libc | 주의점 |
|---|---|---|---|
debian:bookworm-slim |
약 75MB | glibc | 무난한 기본값 |
python:3.12-slim |
약 130MB | glibc | 컴파일러 없음 |
alpine:3.20 |
약 8MB | musl | 휠 재빌드·DNS·malloc |
distroless/static |
약 2MB | 없음 | cgo 쓰면 실패 |
scratch |
0B | 없음 | CA·tzdata 직접 복사 |
알파인이 손해가 되는 대표적인 경우가 파이썬 휠입니다. 같은 pandas 설치가
slim 에서 9.4초인데 alpine 에서는 소스 컴파일로 넘어가 11분 38초가
걸립니다. CI 시간이 70배입니다. 그리고 scratch 를 쓸 때 잊기 쉬운
세 가지가 CA 인증서, 타임존 데이터, /etc/passwd 입니다. TLS 검증이
x509: certificate signed by unknown authority 로 실패하면 대개 첫 번째
때문입니다.
마지막으로 방향을 하나 틀어야 합니다. 크기보다 레이어 재사용률입니다. 400MB 이미지에서 실제로 받는 것이 12MB 일 수 있고, 200MB 짜리 "작은" 이미지라도 의존성 레이어가 매 커밋 무효화되면 매번 200MB 를 전송합니다. 성과는 이미지 크기가 아니라 콜드 스타트 풀 시간으로 검증합니다.
지운 파일은 왜 이미지에서 안 사라지나
멀티스테이지를 배우기 전에 흔히 쓰는 방법이 "설치하고 나서 지우기" 다. 그런데 이렇게 쓰면 이미지는 한 바이트도 줄지 않는다.
RUN apt-get install -y build-essential # 레이어 A: 400MB 늘어남
RUN apt-get purge -y build-essential # 레이어 B: 삭제 표시만 기록
각 RUN 은 레이어 하나를 만들고, 레이어는 쌓기만 할 뿐 앞의 것을 지우지
못한다. B 는 "그 파일들은 없는 것으로 보이게 하라" 는 표시를 담을 뿐이고,
A 의 400MB 는 그대로 배포되고 그대로 내려받아진다. 게다가 그 파일들은 여전히
꺼낼 수 있다 — 빌드 도중에 잠깐 넣었다 지운 비밀이 이미지에 남는 경로가
이것이다.
한 레이어 안에서 끝내면 줄어든다. 설치와 삭제를 하나의 RUN 으로 묶으면
레이어에 기록되는 것은 그 명령이 끝난 뒤의 상태뿐이다.
RUN apt-get update && apt-get install -y --no-install-recommends build-essential && make install && apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
그래도 멀티스테이지가 낫다. 위 방식은 캐시를 통째로 잃는다는 대가를
치른다. 소스가 한 글자만 바뀌어도 apt-get install 부터 다시 돈다.
멀티스테이지는 빌드 단계의 캐시를 그대로 두면서, 최종 이미지에는
COPY --from 으로 가져오기로 정한 것만 넣는다. 결과물이 아니라 경계가
분명해지는 것이 핵심이다.
캐시는 변하는 순서로 정렬한다. 의존성 목록을 먼저 복사해 설치하고, 소스는 그 뒤에 복사한다. 반대로 하면 소스 한 줄 고칠 때마다 의존성을 새로 받는다.
COPY go.mod go.sum ./
RUN go mod download # go.mod 가 그대로면 캐시가 산다
COPY . .
RUN go build -o /app ./cmd/server
바닥 이미지는 실행에 필요한 것으로 고른다. 정적으로 링크한 Go 바이너리는
scratch 로도 돈다. TLS 를 쓴다면 ca-certificates 가, 사용자 조회를 한다면
/etc/passwd 가 필요하다. 알파인은 작지만 musl libc 라서 glibc 를 전제로
빌드한 바이너리가 조용히 다르게 동작할 수 있다. 파이썬처럼 확장 모듈을
컴파일하는 언어에서는 알파인이 오히려 이미지도 크고 빌드도 느려지는 일이 흔하다.
다음 실습에서 할 것
빌더 스테이지에서 산출물을 만들고 최종 스테이지에는 그것만 복사해 크기가
얼마나 줄어드는지 재고, scratch 위에 정적 바이너리 하나만 올린 이미지가
정말 동작하는지(그리고 셸이 없다는 것이) 확인합니다.