- 들어가며 — 1.9GB짜리 Node 이미지는 어디서 왔는가
- 이미지가 커지는 지점은 추측하지 말고 특정한다
- 파일을 지워도 이미지가 줄지 않는 이유
- 멀티스테이지 빌드 — 무엇만 복사할 것인가
- 베이스 이미지 선택 — alpine이 언제 손해가 되는가
- .dockerignore는 이미지가 아니라 빌드 컨텍스트를 줄인다
- 크기보다 중요한 것 — 레이어 재사용률과 실제 풀 시간
- 마치며 — 지우는 것이 아니라 처음부터 넣지 않는 것
들어가며 — 1.9GB짜리 Node 이미지는 어디서 왔는가
배포는 됩니다. 문제는 이미지가 1.9GB라는 점입니다. 레지스트리 스토리지 비용이 매달 오르고, 노드가 스케일아웃될 때마다 파드가 Ready 상태가 되기까지 90초가 걸립니다. 롤백 한 번에 또 90초입니다.
docker images를 열어 보면 베이스로 쓴 node:22가 이미 1.1GB이고, 나머지 800MB가 어디서 왔는지는 아무도 설명하지 못합니다. 이 상태에서 검색하면 대부분 "alpine을 쓰세요", "RUN을 하나로 합치세요" 같은 답이 나옵니다. 둘 다 절반만 맞고, 절반은 상황에 따라 손해입니다.
이 글은 크기가 커지는 메커니즘부터 짚습니다. 유니온 파일시스템이 레이어를 어떻게 쌓는지 이해하면 왜 RUN rm -rf가 크기를 한 바이트도 줄이지 못하는지, 왜 멀티스테이지가 유일하게 확실한 해법인지, 그리고 왜 어떤 팀에게는 크기 자체가 잘못된 최적화 목표인지가 한 번에 정리됩니다.
이미지가 커지는 지점은 추측하지 말고 특정한다
최적화의 첫 단계는 Dockerfile을 고치는 것이 아니라 레이어를 열어 보는 것입니다. 도커는 각 명령이 만든 레이어의 크기를 그대로 기록해 둡니다.
docker history myapp:latest
IMAGE CREATED CREATED BY SIZE
a3f91c2e77b1 3 minutes ago CMD ["node" "dist/server.js"] 0B
<missing> 3 minutes ago RUN /bin/sh -c npm run build # buildkit 48.2MB
<missing> 3 minutes ago RUN /bin/sh -c npm install # buildkit 901MB
<missing> 4 minutes ago COPY . . # buildkit 286MB
<missing> 4 minutes ago RUN /bin/sh -c apt-get update && apt-get inst… 412MB
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) COPY file:4d192565a7220e13… 388B
<missing> 2 weeks ago /bin/sh -c set -ex && for key in 4ED778F539E… 5.3MB
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NODE_VERSION=22.14.0 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:07cf5b0f8bd1d5d5… 74.8MB
여기서 이미 세 가지가 보입니다. npm install이 901MB, 빌드 도구 설치가 412MB, COPY . .이 286MB입니다. 소스 코드가 286MB일 리는 없으니 .git이나 로컬 node_modules가 통째로 들어갔다는 뜻입니다.
docker history는 레이어별 총량만 보여줍니다. 레이어 안에서 무엇이 낭비되는지는 dive로 봅니다.
dive myapp:latest --ci --lowestEfficiency=0.95
Analyzing image
efficiency: 71.8443 %
wastedBytes: 407583744 bytes (408 MB)
userWastedPercent: 21.4127 %
Filename Total Space
/root/.npm/_cacache 214 MB
/var/lib/apt/lists 48 MB
/app/.git 61 MB
/usr/share/doc 23 MB
/tmp/build-deps 62 MB
wastedBytes는 상위 레이어에서 덮어쓰거나 삭제된, 그러나 하위 레이어에는 그대로 남아 있는 바이트입니다. 408MB가 전송되고 저장되지만 컨테이너에서는 보이지도 않습니다. 이 숫자가 왜 생기는지가 다음 절의 주제입니다.
파일을 지워도 이미지가 줄지 않는 이유
도커 이미지는 읽기 전용 레이어를 쌓아 올린 구조이고, 이 스택은 절대 뒤로 되감기지 않습니다. overlayfs는 상위 레이어에서 파일이 삭제되면 원본을 지우는 대신 화이트아웃 항목을 하나 추가합니다. 마운트된 결과에서는 파일이 사라진 것처럼 보이지만, 하위 레이어의 데이터는 물리적으로 그대로 있고 이미지에도 그대로 포함됩니다.
그래서 아래 Dockerfile은 아무것도 절약하지 못합니다.
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y build-essential
RUN make -C /src all
RUN apt-get purge -y build-essential && apt-get autoremove -y
RUN rm -rf /var/lib/apt/lists/*
docker build -t bad-cleanup . && docker history bad-cleanup --format '{{.Size}}\t{{.CreatedBy}}'
0B RUN /bin/sh -c rm -rf /var/lib/apt/lists/* # buildkit
2.1MB RUN /bin/sh -c apt-get purge -y build-essential && apt-get autoremove -y # buildkit
18.4MB RUN /bin/sh -c make -C /src all # buildkit
396MB RUN /bin/sh -c apt-get update && apt-get install -y build-essential # buildkit
74.8MB /bin/sh -c #(nop) ADD file:...
purge 레이어는 크기를 줄이는 대신 2.1MB를 더했습니다. 삭제 표식과 갱신된 패키지 DB가 새 레이어에 기록되었기 때문입니다. 396MB는 그대로 남습니다.
규칙은 하나입니다. 만든 것은 만든 그 RUN 안에서 지워야 합니다.
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& make -C /src all \
&& apt-get purge -y --auto-remove build-essential \
&& rm -rf /var/lib/apt/lists/*
이러면 레이어는 하나이고, 그 레이어의 최종 상태에만 결과물이 남습니다. 여기서 흔히 잘못 확장되는 조언이 "그러니 모든 RUN을 하나로 합쳐라"입니다. 그것은 캐시 재사용을 파괴합니다. 합쳐야 하는 것은 생성과 정리가 짝을 이루는 명령들뿐입니다. 의존성 설치와 소스 빌드는 오히려 분리해야 합니다.
패키지 매니저별로 정리 대상은 정해져 있습니다.
# Debian / Ubuntu
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Alpine
RUN apk add --no-cache ca-certificates curl
# Python
RUN pip install --no-cache-dir -r requirements.txt
# Node
RUN npm ci --omit=dev && npm cache clean --force
apk add --no-cache와 pip install --no-cache-dir는 캐시를 남기지 않는 옵션이지 사후 정리가 아닙니다. 순서가 아니라 옵션으로 해결되는 경우에는 옵션을 쓰는 편이 항상 안전합니다.
멀티스테이지 빌드 — 무엇만 복사할 것인가
같은 RUN 안에서 정리하는 규칙을 지켜도, 컴파일러와 헤더 파일은 결국 어딘가에 존재해야 합니다. 멀티스테이지는 그 존재 자체를 최종 이미지에서 제거합니다. 빌더 스테이지의 레이어는 최종 이미지의 매니페스트에 아예 들어가지 않습니다.
핵심은 문법이 아니라 마지막 스테이지로 무엇을 가져올지 정하는 판단입니다. 잘못 쓴 예부터 봅니다.
# 안티패턴: 멀티스테이지를 썼지만 통째로 복사
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM node:22-slim
WORKDIR /app
COPY /app /app
CMD ["node", "dist/server.js"]
COPY --from=builder /app /app는 devDependencies, 소스 코드, 테스트 픽스처, 빌드 캐시를 전부 가져옵니다. 베이스만 바꾼 셈이라 절감폭이 거의 없습니다. 제대로 쓰면 이렇습니다.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
COPY package.json ./
USER nonroot
CMD ["dist/server.js"]
스테이지가 넷인 이유가 있습니다. deps는 devDependencies까지 포함해 빌드에 쓰고, prod-deps는 프로덕션 의존성만 따로 설치합니다. 두 스테이지는 서로 독립이므로 BuildKit이 병렬로 실행합니다. 최종 이미지에는 런타임 의존성과 번들 결과물, 그리고 package.json만 들어갑니다.
컴파일 언어는 더 극적입니다.
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY /out/api /api
USER nonroot
ENTRYPOINT ["/api"]
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | head -3
api:distroless 14.8MB
api:naive-golang 1.24GB
node-app:optimized 198MB
CGO_ENABLED=0이 중요합니다. cgo가 켜져 있으면 바이너리가 glibc에 동적 링크되어 distroless/static이나 scratch에서 즉시 죽습니다. 정적 링크가 불가능한 경우에는 distroless/base를 쓰면 glibc와 CA 인증서가 들어 있습니다.
scratch를 쓸 때 잊기 쉬운 세 가지는 CA 인증서, 타임존 데이터, 그리고 /etc/passwd입니다. TLS 검증이 x509: certificate signed by unknown authority로 실패한다면 대부분 첫 번째입니다.
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /usr/share/zoneinfo /usr/share/zoneinfo
COPY /out/api /api
ENTRYPOINT ["/api"]
베이스 이미지 선택 — alpine이 언제 손해가 되는가
"alpine을 쓰면 작아진다"는 사실입니다. 문제는 무엇을 대가로 지불하느냐입니다.
alpine은 glibc 대신 musl libc를 씁니다. 파이썬 생태계에서 이것이 곧바로 비용이 됩니다. PyPI의 바이너리 휠 대부분은 manylinux 태그로 배포되고, 이는 glibc를 전제합니다. musl 환경에서는 musllinux 휠이 있는 패키지만 바이너리로 설치되고, 없으면 pip가 소스에서 컴파일합니다.
# python:3.12-slim
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.whl (12.7 MB)
Successfully installed pandas-2.2.3
real 0m9.412s
# python:3.12-alpine
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3.tar.gz (4.4 MB)
Building wheel for pandas (pyproject.toml) ... done
Successfully installed pandas-2.2.3
real 11m38.204s
빌드 도구를 설치해야 하니 이미지도 다시 커지고, CI 시간은 70배가 됩니다. 최근에는 주요 패키지가 musllinux 휠을 함께 배포하지만, 사내 패키지나 오래된 의존성 하나만 걸려도 그대로 재현됩니다.
두 번째 비용은 DNS입니다. musl의 리졸버는 오랫동안 512바이트를 넘는 UDP 응답에 대해 TCP로 재시도하지 않았고, A와 AAAA 질의를 같은 소켓으로 동시에 보냈습니다. 쿠버네티스의 기본 ndots: 5 설정과 만나면 검색 도메인이 여러 개 붙어 응답이 커지고, conntrack 경쟁까지 겹쳐 간헐적인 Name does not resolve가 발생합니다. musl 1.2.4에서 TCP 폴백이 들어갔고 Alpine 3.18 이상은 이를 포함하지만, 고정된 구버전 알파인 이미지를 쓰는 현장이 여전히 많습니다.
세 번째는 성능입니다. musl의 malloc은 glibc보다 멀티스레드 할당에서 느립니다. 스레드를 많이 쓰는 JVM이나 네이티브 확장이 많은 워크로드에서는 이미지 100MB를 아끼고 지연 시간을 잃을 수 있습니다.
| 베이스 | 압축 전 크기 | libc | 셸/패키지 매니저 | 적합한 경우 | 주의점 |
|---|---|---|---|---|---|
debian:bookworm | 약 117MB | glibc | 있음 | 빌더 스테이지 | 런타임으로는 과함 |
debian:bookworm-slim | 약 75MB | glibc | 있음 | 대부분의 런타임 | 문서/로케일 제거됨 |
python:3.12-slim | 약 130MB | glibc | 있음 | 파이썬 서비스 기본값 | 컴파일러 없음 |
alpine:3.20 | 약 8MB | musl | 있음 | 정적 바이너리, 셸 필요 | 휠 재빌드, DNS, malloc |
distroless/base | 약 20MB | glibc | 없음 | 동적 링크 바이너리 | 셸 디버깅 불가 |
distroless/static | 약 2MB | 없음 | 없음 | Go/Rust 정적 빌드 | cgo 사용 시 실패 |
scratch | 0B | 없음 | 없음 | 완전 정적 바이너리 | CA/tzdata 직접 복사 |
실무 기본값은 단순합니다. Go와 Rust는 distroless static, 파이썬과 자바는 slim, 알파인은 최종 이미지가 정적 바이너리이거나 셸 도구가 꼭 필요한 경우에만 씁니다.
distroless를 고를 때 진짜 결정은 크기가 아니라 운영 방식입니다. 컨테이너에 셸이 없으므로 docker exec -it ... sh가 동작하지 않습니다. 대신 디버그 컨테이너를 붙입니다.
kubectl debug -it api-7d9f8c6b4-x2ktl \
--image=busybox:1.36 --target=api --share-processes
.dockerignore는 이미지가 아니라 빌드 컨텍스트를 줄인다
.dockerignore의 효과가 자주 오해됩니다. 이것은 이미지 크기 최적화 도구가 아니라 전송량 최적화 도구입니다. docker build .을 실행하면 현재 디렉터리 전체가 먼저 빌더로 전송됩니다. 여기에 .git과 로컬 node_modules가 있으면 매 빌드마다 수백 MB를 보냅니다.
docker build -t myapp .
[+] Building 41.6s (12/12) FINISHED
=> [internal] load build context 18.3s
=> => transferring context: 612.44MB 18.1s
18초가 파일 복사에만 쓰였습니다. .dockerignore를 추가한 뒤를 봅니다.
.git
.gitignore
node_modules
dist
coverage
**/*.log
.env
.env.*
Dockerfile
docker-compose*.yml
README.md
[+] Building 21.9s (12/12) FINISHED
=> [internal] load build context 0.4s
=> => transferring context: 3.71MB 0.3s
COPY . .을 쓴다면 이미지 크기에도 그대로 영향을 줍니다. 하지만 .env를 제외하는 이유는 크기가 아니라 보안입니다. 컨텍스트에 들어간 시크릿은 COPY . . 한 줄로 이미지에 영구히 박힙니다.
BuildKit은 컨텍스트를 증분 전송하므로 두 번째 빌드부터는 변경분만 보냅니다. 그래도 CI에서는 매번 새 빌더이므로 처음의 18초가 매 파이프라인마다 반복됩니다.
크기보다 중요한 것 — 레이어 재사용률과 실제 풀 시간
여기서 방향을 한 번 틀어야 합니다. 목표는 이미지 크기가 아니라 배포 시간과 비용입니다. 그리고 그 둘은 생각만큼 비례하지 않습니다.
레지스트리에서 이미지를 받을 때 도커는 이미 로컬에 있는 레이어를 다시 받지 않습니다. 그리고 전송되는 것은 압축된 레이어입니다.
docker pull registry.example.com/api:v312
v312: Pulling from api
9c704ecd0c69: Already exists
2f8a1e3b7d44: Already exists
b17d2a9e4c31: Already exists
7e2c9f04a8b6: Pull complete
d3a91b2e5f77: Pull complete
Digest: sha256:4f9a...
Status: Downloaded newer image for registry.example.com/api:v312
400MB 이미지에서 실제로 받은 것이 12MB일 수 있습니다. 반대로, 200MB짜리 "작은" 이미지라도 의존성 레이어가 매 커밋마다 무효화되면 매번 200MB를 전송합니다. 레이어 순서가 크기보다 배포 시간을 더 크게 좌우합니다.
그래서 Dockerfile은 변경 빈도가 낮은 것부터 높은 것 순으로 배치합니다.
FROM python:3.12-slim
# 1. 거의 안 바뀜: 시스템 패키지
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 2. 가끔 바뀜: 의존성 목록
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3. 매 커밋 바뀜: 소스
COPY src/ ./src/
CMD ["python", "-m", "src.main"]
측정할 값은 세 가지입니다. 압축 전송 크기, 콜드 스타트에서의 풀 시간, 그리고 롤링 업데이트 시 실제로 새로 받는 레이어의 비율입니다.
# 압축 크기는 매니페스트에서 확인
crane manifest registry.example.com/api:v312 \
| jq '[.layers[].size] | add / 1048576 | round'
71
# 콜드 캐시 풀 시간 측정
docker image rm registry.example.com/api:v312 >/dev/null
time docker pull registry.example.com/api:v312
real 0m6.284s
이 숫자들이 목표치 안에 있다면, 200MB를 150MB로 깎기 위해 alpine으로 옮기고 파이썬 휠을 재빌드하는 일은 순손실입니다. 반대로 매 배포마다 900MB짜리 node_modules 레이어를 새로 받고 있다면, 이미지 총 크기를 건드리기 전에 레이어 순서부터 고쳐야 합니다.
마치며 — 지우는 것이 아니라 처음부터 넣지 않는 것
이미지 최적화에서 기억할 문장은 하나입니다. 레이어는 되감기지 않으므로, 최종 이미지에 들어가면 안 되는 것은 애초에 그 스테이지에서 만들지 않아야 합니다.
우선순위는 이 순서가 실용적입니다. 먼저 .dockerignore로 컨텍스트를 정리하고, 빌드 도구가 필요하다면 멀티스테이지로 분리하고, 정리는 생성과 같은 RUN 안에서 하고, 베이스는 런타임 특성에 맞춰 고르고, 마지막으로 변경 빈도 순으로 레이어를 배치합니다. 그리고 이 모든 작업의 성과는 이미지 크기가 아니라 콜드 스타트 풀 시간으로 검증합니다.
현재 단락 (1/185)
배포는 됩니다. 문제는 이미지가 1.9GB라는 점입니다. 레지스트리 스토리지 비용이 매달 오르고, 노드가 스케일아웃될 때마다 파드가 Ready 상태가 되기까지 90초가 걸립니다. ...