A Docker image is not one blob — it is a stack of read-only layers, roughly one per filesystem-changing instruction (RUN, COPY, ADD). Almost every “why is my build slow / my image huge” question reduces to three mechanisms. Naming the mechanism, not the symptom, is what actually makes this stick.
1. A cache miss propagates downward
On each build, Docker asks per layer: is this cached? The cache is a chain — once one layer misses, every layer below it rebuilds too. So order matters: rarely-changing steps go up top, frequently-changing steps go to the bottom.
The canonical win is dependencies before source:
1
2
3
4
5
6
7
8
# Bad: editing one line of code reinstalls everything
COPY . .
RUN pip install -r requirements.txt
# Good: the install layer stays cached unless requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
COPY . . hashes the files it copies, so any code edit misses the cache and cascades into pip install. Lift the dependency step above the source copy and code edits no longer touch it.
A .dockerignore (dropping .git, __pycache__, .venv) keeps irrelevant files from busting that same cache.
2. A RUN’s cache key is the command string
Docker does not inspect what a RUN does — its cache key is the literal command text. So this is a trap:
1
2
RUN apt-get update # string never changes → cached forever → stale index
RUN apt-get install -y curl
Combine them so a single key covers both, and changing the install list re-runs the update:
1
2
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
BuildKit adds a cache mount that survives across builds without baking into the image — downloaded wheels are reused even when requirements.txt changes:
1
2
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
3. Multi-stage discards the build layers
Build time needs compilers and headers; runtime needs only the artifact. Use multiple FROMs and copy just the result forward:
1
2
3
4
5
6
7
8
9
10
11
FROM python:3.12 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*
COPY . .
CMD ["python", "app.py"]
The final image contains only the last stage’s layers plus whatever COPY --from pulled in. Every build layer — compilers, source, intermediate files — is thrown away. The Go version of this pattern goes from ~800 MB (compiler included) to ~10 MB (just the binary).
Checklist
- Dependencies before source; add a
.dockerignore. - One
RUNforapt-get update && install, clean up/var/lib/apt/lists. - Multi-stage + a
slim/distrolessbase;--no-install-recommends,--no-cache-dir. - Prefer
COPYoverADDunless you need tar extraction or a remote URL.