Image slimming: multi-stage, distroless, and layers that pull in seconds
A 1.2 GB image slows every pull, every deploy and every scaler scale-up, and ships a full shell as attack surface. Multi-stage builds plus a distroless or scratch final stage cut it to ~25 MB, shrink the CVE count, and order layers so a code change rebuilds in seconds.
The autoscaler was the bottleneck nobody had measured. Traffic spiked, the orchestrator scheduled new pods, and they sat in ContainerCreating for 90 seconds — pulling a 1.2 GB image onto cold nodes — while the queue backed up and the existing pods fell over under the load they were scaling to relieve. The image was a Go service: a single 15 MB static binary buried under a full Ubuntu base, the entire Go toolchain, build caches, apt lists, and a compiler. The rewrite was a multi-stage Dockerfile — build in a toolchain stage, copy only the binary into a distroless base — and the image went from 1.2 GB to 23 MB. Cold pulls dropped from 90 seconds to under 2. As a bonus the security scan that used to flag 180 CVEs in the OS packages flagged zero, because there were no OS packages left: no shell, no package manager, no libc utilities for an attacker to pivot through. The fat image had been three problems wearing one trench coat — slow deploys, slow scaling, and a wide attack surface.
Multi-stage: build heavy, ship light
The core technique is the multi-stage build: one stage holds the heavyweight toolchain, the final stage holds only the artifact. Only the last stage becomes the image; everything in the build stage — compilers, caches, dev dependencies — is discarded.
# ---- build stage: heavy, never shipped ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached unless go.mod/go.sum change
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# ---- final stage: tiny, shipped ----
FROM gcr.io/distroless/static-debian12 # ~2 MB base: no shell, no package manager
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]The final image is the distroless base (~2 MB) plus the binary — for a static Go binary, ~25 MB total versus 1.2 GB for the naive single-stage build off golang:1.23. That size is not a vanity metric; it is paid back on every operation. A pull on a cold node drops from ~90 s to ~2 s, which directly shortens autoscaler reaction time and rollout duration. The registry stores and transfers a fraction of the bytes. And the attack surface collapses: distroless ships no shell, no package manager, and no OS utilities, so a compromised process cannot sh, curl, apt-get, or pivot through libc tools — and the CVE scanner reports near-zero findings because there are almost no OS packages to have vulnerabilities. For a truly static binary you can even use FROM scratch (empty base), shipping literally just the binary; distroless is the pragmatic middle ground because it adds CA certificates, /etc/passwd, and timezone data that scratch lacks.
Beyond saving registry storage, what is the most direct production cost of shipping a 1.2 GB image instead of a 25 MB one?
Layer ordering: cache the slow steps
You have a 25 MB final image — but if you order the Dockerfile instructions wrong, every code change still triggers a multi-minute dependency reinstall. The image size is paid at pull time; the layer order is paid at build time, on every commit.
Image layers are content-addressed and cached: a layer is reused only if its instruction and all preceding layers are unchanged. Order instructions from least- to most-frequently-changing so a one-line code edit doesn’t bust the dependency-install layer. The single highest-leverage rule is copy and install dependencies before copying source code.
# GOOD — dependency layer survives source edits
COPY package.json package-lock.json ./
RUN npm ci # cached until package files change
COPY . . # source changes invalidate only from here down
RUN npm run build
# BAD — every source edit re-runs npm ci (minutes)
COPY . .
RUN npm ci # any file change busts this; full reinstall every buildIn the bad order, COPY . . lands before npm ci, so editing one line of source changes the build context, invalidates that COPY layer, and forces a full dependency reinstall — turning a 5-second incremental build into a multi-minute one. In the good order the dependency install sits on a stable layer keyed only by the lockfiles, so it is reused across every source-only change. Two more layer disciplines matter: a .dockerignore that excludes node_modules, .git, and test fixtures keeps junk out of the build context (smaller, faster, and it prevents a stale local node_modules from leaking in); and combining related RUN steps with cleanup in the same layer (apt-get install ... && rm -rf /var/lib/apt/lists/*) matters because removing files in a later layer does not shrink the image — the bytes are already committed in the earlier layer and only masked, not deleted. Layer count is also a real number: each layer is a separate tar fetched and extracted, so dozens of tiny RUN lines slow pulls; consolidate, but not so aggressively that you destroy cache reuse.
A Dockerfile installs a package in one RUN layer and deletes the cache in a later RUN layer, yet the image is no smaller. Why?
- 01Explain how a multi-stage build with a distroless final stage takes a 1.2 GB image to ~25 MB, and name the three production costs that shrinking pays back.
- 02Why does copying source before installing dependencies wreck build times, and why does deleting files in a later layer not shrink the image?
A fat image is a tax paid on every operation, not a one-time storage cost. A 1.2 GB image pulls in ~90 seconds onto a cold node, so it lengthens autoscaler reaction time and rollout duration exactly when you need to scale, it burns registry bandwidth on every transfer, and it ships a full OS — shell, package manager, libc utilities — as attack surface a compromised process can pivot through. The cure is a multi-stage build: a heavyweight stage compiles the artifact, and the final stage copies only that artifact into a tiny base. Because only the last stage becomes the image, the toolchain and caches are discarded, taking a static Go binary from 1.2 GB to ~25 MB on a distroless base, dropping cold pulls to ~2 s and the OS-package CVE count to near zero, since distroless ships no shell or package manager — FROM scratch goes further for a fully static binary, while distroless is the pragmatic middle that adds CA certs, /etc/passwd, and timezone data. Layer ordering is the other half: layers are content-addressed and cached, reused only if the instruction and all preceding layers are unchanged, so copy and install dependencies before source — otherwise a one-line edit invalidates the COPY layer and forces a full reinstall every build. A .dockerignore keeps junk like node_modules and .git out of the context, and cleanup must happen in the same RUN that created the files, because layers are additive: a later delete only masks bytes already committed in an earlier layer, it does not remove them. Slim images are simultaneously faster to deploy, faster to scale, cheaper to store, and smaller to attack. Now when you see a new service starting with a Dockerfile that does COPY . . before RUN npm install, you know exactly what is about to happen to every developer’s incremental build time — and the two-line fix to put it right.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.