Why size matters more than it looks
An oversized image is rarely the thing that pages you, which is why it stays oversized. But the cost shows up everywhere. A new Kubernetes node cannot start your pod until it has pulled the image. A CI job spends its first minute downloading layers. A rollback is slower than it should be. And every extra package in the image is something that a vulnerability scanner will eventually flag.
This is one part of our Docker containerisation work. Reduction is not a vanity exercise: we do it where the saving shows up in deploy time, cost or risk, and we stop when it does not.
Start by measuring, not guessing
Before changing anything, we look at where the bytes are.
# size and layer breakdown
docker image ls myapp:current
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' myapp:current | head -20
# interactive layer explorer
dive myapp:current
Nearly every case has the same few culprits:
- A full toolchain (compiler,
build-essential, headers) left in the final image. node_modulesor virtual environments containing dev dependencies.- Package manager caches such as
aptlists orpipcaches never cleaned in the sameRUNlayer. - A
COPY . .that pulled in.git, test fixtures, local databases and build output. See.dockerignoreand layer caching. - A base image far larger than the application needs.
The techniques, in order of payoff
1. Multi-stage builds
Move compilation and dependency installation into a builder stage and copy only the output into the runtime stage. This one change usually accounts for most of the saving. The mechanics are covered in Dockerfile multi-stage builds.
2. A smaller runtime base
| Starting point | Typical move | Caveat |
|---|---|---|
| Full language image | Slim variant of the same distribution | Check native libraries still load |
| Slim image | Distroless runtime | Plan how you will debug without a shell |
| Debian or Ubuntu | Alpine | Test for musl compatibility |
| Static binary | scratch or distroless static | Copy CA certificates and timezone data |
3. Clean up inside the same layer
A file deleted in a later layer still exists in the earlier one. Cleanup must happen in the layer that created the mess:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
4. Strip what the application never reads
Documentation, man pages, source maps, test directories inside dependencies, and unused locales add up. For Java, jlink can build a trimmed runtime containing only the modules needed. For Python, avoid installing build dependencies in the final stage and use --no-cache-dir.
5. Install only production dependencies
npm ci --omit=dev, pip install from a compiled wheel set, or go build with -ldflags="-s -w" all reduce what ships. These are small, safe changes.
A worked example, by language
The same goal looks different in each ecosystem, which is why we measure rather than apply a recipe.
| Language | Usual cause of bloat | Typical fix |
|---|---|---|
| Node.js | Dev dependencies and build output in the final image | Build in one stage, install production dependencies only in the runtime stage |
| Python | Compilers installed to build wheels, pip caches | Build wheels in a builder stage and copy the virtual environment across |
| Java | Full JDK and build tool in the runtime image | Run on a JRE or trimmed runtime, ship only the packaged jar |
| Go and Rust | Little, if built as static binaries | Check for debug symbols and an oversized base |
For compiled languages that already produce a single binary, the honest answer is often “you are nearly there”. We would rather tell you that than invent work.
Compressed size versus on-disk size
Two numbers get confused. Registries and pulls deal with compressed layers, while docker image ls shows the uncompressed size on disk. Pull time follows the compressed figure and the number of layers that are not already cached on the node, not the headline size. This matters for ordering: layers that rarely change, such as the base and dependencies, should come first so nodes already hold them and only the small application layer is fetched on each deploy. Smaller helps, but stable layers help as much.
Guarding against regrowth
Images grow back quietly. A new dependency, a copied directory or a forgotten debug tool adds weight one pull request at a time. A simple CI step compares the built image size to a recorded budget and fails or warns when it is exceeded:
size=$(docker image inspect myapp:ci --format '{{.Size}}')
echo "image size: ${size} bytes"
We then compare that value to a threshold stored in the repository. The budget is set from the measured baseline plus agreed headroom, and a human can raise it deliberately with a reason in the pull request.
Where we stop
There is a point where further shrinking costs more than it saves. Removing the shell from an image that your on-call team debugs weekly is a bad trade. Chasing the last few megabytes by hand-picking shared libraries creates an image nobody wants to maintain. We usually stop when three conditions hold: the image contains only what the application needs, it is built from a base your team can operate, and further reductions would require unusual tricks.
A note on headline numbers. Our service description mentions typical reductions of 60 to 80 percent. That figure describes images that started with full toolchains and large bases. It is not a promise. A lean image has less to gain, and we say so after measuring.
Failure modes after shrinking
- TLS errors because CA certificates were not carried into a minimal base.
- Wrong time zones or locale behaviour when
tzdatais missing. - Crashes on startup from a missing shared library that the old image had by accident.
- Healthchecks that call
curlorwgetwhich no longer exist. We switch to a built-in check or an application endpoint tested by the orchestrator. - Slower debugging. We provide a debug variant or document
kubectl debugwith an ephemeral container.
Delivery and keeping it small
We deliver pull requests with a comparison table per service and keep behavioural checks in CI. To prevent regression, a CI step can compare the new image size against the previous release and warn when it grows past an agreed threshold. Because a smaller base has fewer packages, it also improves results in vulnerability scanning with Trivy.
How to tell it is working
Compare image size, pull time on a cold node, and the number of scanner findings before and after. A healthy outcome is faster scale-out, shorter CI downloads, a shorter findings list and an application that behaves exactly as before. If you want your images measured, get in touch.