필사 모드: Shrinking Docker Images — The Truth About Layers, Multi-Stage Builds, and What a Base Image Really Costs
English- Introduction — Where Did That 1.9GB Node Image Come From
- Do Not Guess Where the Image Grows — Pinpoint It
- Why Deleting Files Does Not Shrink the Image
- Multi-Stage Builds — What Exactly to Copy
- Choosing a Base Image — When alpine Costs You
- .dockerignore Shrinks the Build Context, Not the Image
- More Important Than Size — Layer Reuse Rate and Real Pull Time
- Closing — Not Deleting It, Never Putting It In
Introduction — Where Did That 1.9GB Node Image Come From
It deploys. The problem is that the image is 1.9GB. Registry storage costs climb every month, and every time a node scales out, a pod takes 90 seconds to reach Ready. Every rollback costs another 90 seconds.
Open docker images and the node:22 you used as a base is already 1.1GB, and nobody can explain where the remaining 800MB came from. Search for help in that state and most answers are "use alpine" or "merge your RUN commands into one." Both are half right, and the other half is a net loss depending on your situation.
This article starts with the mechanism that makes images grow. Once you understand how the union filesystem stacks layers, three things fall into place at once: why RUN rm -rf cannot remove a single byte, why multi-stage is the only reliably correct answer, and why size itself is the wrong optimization target for some teams.
Do Not Guess Where the Image Grows — Pinpoint It
The first step in optimization is not editing the Dockerfile, it is opening up the layers. Docker records the size each command produced, exactly as it is.
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
Three things are already visible. npm install is 901MB, installing build tools is 412MB, and COPY . . is 286MB. Source code cannot possibly be 286MB, so .git or a local node_modules went in wholesale.
docker history shows only the total per layer. To see what is wasted inside a layer, use 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 counts the bytes that were overwritten or deleted in an upper layer but still sit untouched in a lower one. Those 408MB are transferred and stored, yet are not even visible inside the container. Why that number exists is the subject of the next section.
Why Deleting Files Does Not Shrink the Image
A Docker image is a stack of read-only layers, and that stack never rewinds. When a file is deleted in an upper layer, overlayfs does not erase the original — it adds a whiteout entry. In the mounted result the file appears to be gone, but the data in the lower layer is physically still there and is still included in the image.
So the Dockerfile below saves nothing at all.
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:...
The purge layer added 2.1MB instead of removing anything. The deletion markers and the updated package database were written into a new layer. The 396MB stays exactly where it was.
There is one rule. Whatever you create must be deleted inside the same RUN that created it.
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/*
Now there is a single layer, and only that layer's final state remains. The advice that commonly gets over-extended from here is "so merge every RUN into one." That destroys cache reuse. What you should merge is only the commands where creation and cleanup form a pair. Dependency installation and source builds should in fact be kept apart.
Each package manager has its own designated cleanup target.
# 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 and pip install --no-cache-dir are options that never leave a cache behind, not after-the-fact cleanup. When something can be solved with an option rather than with ordering, using the option is always the safer route.
Multi-Stage Builds — What Exactly to Copy
Even if you follow the rule of cleaning up inside the same RUN, compilers and header files still have to exist somewhere. Multi-stage removes that existence from the final image entirely. The builder stage's layers never enter the final image's manifest at all.
The heart of this is not syntax but the judgment call about what to bring into the last stage. Start with a bad example.
# Anti-pattern: uses multi-stage but copies everything wholesale
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 brings over devDependencies, source code, test fixtures, and the build cache — all of it. All you did was swap the base, so the savings are near zero. Done properly, it looks like this.
# 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"]
There is a reason for four stages. deps includes devDependencies and is used for the build, while prod-deps installs only production dependencies separately. The two stages are independent of each other, so BuildKit runs them in parallel. The final image contains only the runtime dependencies, the bundled output, and package.json.
Compiled languages are even more dramatic.
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 matters. With cgo enabled, the binary is dynamically linked against glibc and dies instantly on distroless/static or scratch. When static linking is impossible, use distroless/base, which ships glibc and the CA certificates.
The three things easiest to forget with scratch are CA certificates, timezone data, and /etc/passwd. If TLS verification fails with x509: certificate signed by unknown authority, it is usually the first one.
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"]
Choosing a Base Image — When alpine Costs You
"alpine makes it smaller" is true. The question is what you pay in exchange.
alpine uses musl libc instead of glibc. In the Python ecosystem that becomes a cost immediately. Most binary wheels on PyPI ship with a manylinux tag, which presumes glibc. In a musl environment only packages that publish a musllinux wheel install as binaries, and where none exists, pip compiles from source.
# 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
You have to install build tools, so the image grows again, and CI time becomes 70 times longer. These days the major packages ship musllinux wheels too, but a single in-house package or one aging dependency reproduces this exactly.
The second cost is DNS. For a long time musl's resolver did not retry over TCP for UDP responses larger than 512 bytes, and it sent A and AAAA queries simultaneously over the same socket. Combine that with Kubernetes' default ndots: 5 setting and several search domains get appended, responses grow larger, conntrack races pile on top, and you get intermittent Name does not resolve failures. TCP fallback landed in musl 1.2.4 and Alpine 3.18 and later include it, but plenty of shops still run pinned old alpine images.
The third is performance. musl's malloc is slower than glibc's at multithreaded allocation. On a JVM that uses many threads, or a workload heavy in native extensions, you may save 100MB of image and lose latency.
| Base | Uncompressed size | libc | Shell / package manager | Good for | Watch out for |
|---|---|---|---|---|---|
debian:bookworm | about 117MB | glibc | Yes | Builder stage | Overkill as a runtime |
debian:bookworm-slim | about 75MB | glibc | Yes | Most runtimes | Docs and locales stripped |
python:3.12-slim | about 130MB | glibc | Yes | Default for Python services | No compiler |
alpine:3.20 | about 8MB | musl | Yes | Static binaries, shell needed | Wheel rebuilds, DNS, malloc |
distroless/base | about 20MB | glibc | No | Dynamically linked binaries | No shell debugging |
distroless/static | about 2MB | None | No | Go/Rust static builds | Fails when cgo is used |
scratch | 0B | None | No | Fully static binaries | Copy CA/tzdata yourself |
The practical defaults are simple. Go and Rust get distroless static, Python and Java get slim, and alpine is used only when the final image is a static binary or when you genuinely need shell tooling.
When choosing distroless, the real decision is not size but operating practice. There is no shell in the container, so docker exec -it ... sh does not work. You attach a debug container instead.
kubectl debug -it api-7d9f8c6b4-x2ktl \
--image=busybox:1.36 --target=api --share-processes
.dockerignore Shrinks the Build Context, Not the Image
The effect of .dockerignore is frequently misunderstood. It is not an image-size optimization tool, it is a transfer-volume optimization tool. Run docker build . and the entire current directory is transferred to the builder first. If .git and a local node_modules are in there, you ship hundreds of megabytes on every build.
docker build -t myapp .
[+] Building 41.6s (12/12) FINISHED
=> [internal] load build context 18.3s
=> => transferring context: 612.44MB 18.1s
Eighteen seconds went to file copying alone. Here is what it looks like after adding .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
If you use COPY . ., this affects image size directly as well. But the reason to exclude .env is not size, it is security. A secret that enters the context gets permanently baked into the image by a single COPY . . line.
BuildKit transfers the context incrementally, so from the second build onward it sends only the changes. Even so, CI gets a fresh builder every time, so that first 18 seconds repeats in every pipeline.
More Important Than Size — Layer Reuse Rate and Real Pull Time
This is where you need to change direction once. The goal is not image size, it is deployment time and cost. And those two are not as proportional as you would think.
When pulling an image from a registry, Docker does not re-download layers it already has locally. And what gets transferred is the compressed layers.
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
Out of a 400MB image, what you actually downloaded may be 12MB. Conversely, even a "small" 200MB image transfers all 200MB every time if the dependency layer is invalidated on every commit. Layer ordering drives deployment time more than size does.
So a Dockerfile is arranged from least frequently changed to most frequently changed.
FROM python:3.12-slim
# 1. Almost never changes: system packages
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 2. Changes occasionally: the dependency list
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3. Changes on every commit: the source
COPY src/ ./src/
CMD ["python", "-m", "src.main"]
There are three values worth measuring: compressed transfer size, pull time on a cold start, and the fraction of layers actually re-downloaded during a rolling update.
# Check the compressed size from the manifest
crane manifest registry.example.com/api:v312 \
| jq '[.layers[].size] | add / 1048576 | round'
71
# Measure pull time with a cold cache
docker image rm registry.example.com/api:v312 >/dev/null
time docker pull registry.example.com/api:v312
real 0m6.284s
If those numbers are within target, then moving to alpine and rebuilding Python wheels to trim 200MB down to 150MB is a net loss. Conversely, if you re-download a 900MB node_modules layer on every deploy, fix the layer ordering before you touch total image size.
Closing — Not Deleting It, Never Putting It In
There is one sentence to remember about image optimization. Layers do not rewind, so anything that must not end up in the final image must not be created in that stage in the first place.
This priority order is the practical one. First clean up the context with .dockerignore, then split off build tools with multi-stage if you need them, do cleanup inside the same RUN as creation, choose a base that matches your runtime characteristics, and finally order the layers by change frequency. And validate all of this work against cold-start pull time, not image size.
현재 단락 (1/185)
It deploys. The problem is that the image is 1.9GB. Registry storage costs climb every month, and ev...