~/blog/docker-multistage.md

Docker Multi-Stage Builds That Stay Small

· #docker #infrastructure

REPOSITORY   TAG       SIZE
myapi        v1        1.42GB

For a FastAPI service with maybe 40MB of actual application code and dependencies. The other 1.38GB was build-essential, apt’s package cache, pip’s wheel cache, and — because the COPY . . came before pip install, in the wrong order — a full re-download of every dependency on nearly every build, since editing any source file invalidated the layer the install lived in. None of that belongs in the image that runs in production; all of it was there because a single-stage Dockerfile doesn’t distinguish between “things I need to build the app” and “things I need to run it.”

One stage, mixed concerns

The single-stage version looks reasonable until you actually read what ends up in the final layer:

FROM python:3.11
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "app:app", "--host", "0.0.0.0"]

build-essential compiles wheels for packages with C extensions. Once those wheels are built, the compiler that built them is dead weight — but FROM python:3.11 at the top means every layer after it, including the final CMD, is built on an image that still has the full compiler toolchain sitting in it. There’s no boundary in a single stage between “tools I needed” and “artifacts I still need.”

Splitting build from runtime

Multi-stage builds give you that boundary directly: name a stage, build everything in it, then start a fresh, smaller image and copy over only the finished artifacts.

# ---- build stage ----
FROM python:3.11 AS builder
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

# ---- runtime stage ----
FROM python:3.11-slim
COPY --from=builder /install /usr/local
COPY . .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0"]

python:3.11-slim never sees build-essential — it isn’t installed there, so it isn’t in the layer history to prune later, it just never exists in the final image. COPY --from=builder reaches into the first stage and pulls out only /install, the compiled packages, leaving the compiler and the apt cache behind in a stage that gets discarded once the build finishes. That one change took the image from 1.42GB to around 180MB, and the drop is entirely the compiler toolchain and package caches that were never needed after the wheels were built.

Layer order decides whether your cache is useful

Multi-stage builds solve image size. They don’t automatically solve build speed — that’s a layer-ordering problem, and it bites just as hard inside a build stage as outside one. Docker caches each layer and reuses it if the inputs haven’t changed, but that cache is only as good as the order you hand it:

# bad: any source change invalidates the pip install layer too
FROM python:3.11 AS builder
COPY . .
RUN pip install -r requirements.txt
# good: requirements.txt rarely changes, so this layer usually just hits cache
FROM python:3.11 AS builder
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
COPY . .

The second version copies only requirements.txt before running pip install. Edit app.py and that layer’s cache key — a hash of requirements.txt — hasn’t changed, so Docker skips reinstalling every dependency and reuses the cached layer. On a service with a real dependency tree, that’s the difference between a ten-second rebuild and a three-minute one, every single time you touch application code instead of a dependency.

What I learned

The size problem and the speed problem look like the same complaint — “the Docker workflow is slow and heavy” — but they have different causes and different fixes. Multi-stage builds fix what ships. Layer ordering fixes how often you pay to rebuild it. Skip either one and the other doesn’t compensate: a small final image built with COPY . . before pip install is still going to reinstall the world on every source change, and a well-ordered single-stage build still ships a compiler to production.

cd .. cd ~ (back to terminal)