Small images boot faster, save bandwidth, and have smaller attack surface. Here are the techniques that actually work.
Multi-stage builds
The single biggest win. Build in one stage, copy only the artifacts to a minimal runtime stage. A Go binary of 15 MB ends up in a 17 MB image. Compare to a naive golang:1.22 image at 900+ MB.
Base image choice
From smallest to largest for Go/Rust static binaries:
- scratch — nothing. Binary only.
- gcr.io/distroless/static-debian12 — just CA certs, tzdata, user DB. About 2 MB.
- alpine:3.19 — minimal userland with apk. About 5 MB.
- debian:12-slim — full Debian trimmed. About 75 MB.
- ubuntu:24.04 — full Ubuntu. About 80 MB.
For Python, Java, Node, you need something with a working userland. Alpine works but has musl libc, which sometimes causes subtle bugs with pre-built wheels.
Things that balloon images unexpectedly
- apt-get install without apt-get clean and rm -rf /var/lib/apt/lists/*
- pip install caching wheels in /root/.cache
- npm install retaining dev dependencies (use npm ci –omit=dev)
- Build tools kept in the final image
- Log files and /tmp contents included
Measuring
docker images shows total size, but docker history myimage shows the delta each layer added. For deeper analysis, dive myimage:tag gives a layer-by-layer interactive explorer.