LabHub
배우기 러닝패스 코스

Building Images

The Order in Which You Shrink an Image

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

멀티스테이지의 핵심은 문법이 아니라 최종 스테이지로 무엇만 복사할 것인가입니다. 빌더 스테이지의 레이어는 최종 이미지의 매니페스트에 아예 들어가지 않습니다.

Concept map: 2.1MB 를 더했습니다. · 만든 것은 만든 그 RUN 안에서 지운다. · 생성과 정리가 짝을 이루는 명령들 · 11분 38초

왜 이게 필요했나

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 위에 정적 바이너리 하나만 올린 이미지가 정말 동작하는지(그리고 셸이 없다는 것이) 확인합니다.