Docker image layers and build caching
Article.
An image is a stack of read-only layers. RUN, COPY and ADD each add one
layer of filesystem changes; ENV, CMD, LABEL, USER and EXPOSE only
change the image config. When rebuilding, BuildKit reuses a step’s cached
result if its cache key is unchanged, and the first changed step
invalidates every step after it.
Requirements did not change, but CI reinstalls every dependency on every
commit. Why? The answer depends on how each instruction’s cache key is
computed. Measured with BuildKit (Docker Engine 29.8) on python:3.14-slim.
How each instruction is keyed
FROM python:3.14-slim
RUN date +%s%N > /built_at
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
ENV MODE=prod
CMD ["python", "app.py"]| Change | What was rebuilt |
|---|---|
| nothing | nothing: 4 of 4 steps cached |
touch requirements.txt |
nothing: 4 of 4 cached |
edit requirements.txt |
everything from that COPY on |
COPYandADDare keyed on file contents.touchchanged only the modification time, and the step stayed cached; editing the file invalidated it and every step after it, including thepip install.RUNis keyed on the command text, not on what it does. Thedatestep stayed cached across all of these builds:/built_atstill held the first build’s timestamp. ARUN apt-get updateorRUN curl .../latestnever notices that upstream changed; only editing the line,--no-cache, or an earlier invalidation reruns it.- Config instructions are free.
docker history:
0B CMD ["python" "app.py"]
0B ENV MODE=prod
8.19kB COPY app.py .
14.6MB RUN pip install --no-cache-dir -r requirements.txt
8.19kB COPY requirements.txt .Order: least to most frequently changed
The first changed step invalidates everything below it, so the order of the Dockerfile is the order of cache invalidation. Dependencies before source:
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project # cached until the lockfile changes
COPY src/ src/
RUN uv sync --locked # installs the project itselfA code change then rebuilds only the last two steps. Copying the whole
directory before installing (COPY . . then install) reinstalls every
dependency on every commit, which is the usual answer to the opening
question.
A .dockerignore (.git, .venv, __pycache__, test data) keeps
irrelevant files from invalidating a broad COPY and keeps the build context
small.
Cache mounts: when dependencies do change
A dependency change invalidates the install step no matter how it is ordered. A cache mount keeps the package manager’s download cache between builds without putting it in the image:
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system -r requirements.txtadd one small package to numpy + pandas, rebuild:
without a cache mount: Prepared 8 packages in 2.75s (downloads all)
with a cache mount: Prepared 1 package in 0.148s (only the new one)With large wheels (PyTorch is gigabytes) that is the difference between
minutes and seconds. The mount lives on the builder, so in CI it helps only
if the builder persists between runs; ephemeral runners need the build cache
exported (--cache-to type=registry or type=gha), where mode=max
caches the intermediate stages of a multi-stage build too, at the cost of
pushing more data.
Size: layers only ever add
RUN head -c 50000000 /dev/urandom > /big
RUN rm /bigdocker history:
4.1kB RUN rm /big
50MB RUN head -c 50000000 /dev/urandom > /bigDeleting the file in a later layer hides it, but its 50 MB stay in the
earlier layer and in every pull. Creating and deleting in the same RUN
adds nothing.
The same applies to secrets: a token copied in and deleted
later is still in the image history, extractable by anyone who can pull it.
Use RUN --mount=type=secret for build-time secrets, and multi-stage builds
so compilers and build tools never reach the final image
(Multi-stage builds).
The number of layers barely matters for size; what matters is what each
layer leaves behind. Chaining every command with && to “minimise layers”
is advice from before multi-stage builds and cache mounts.
A tested uv multi-stage pattern
FROM python:3.14-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.9.0 /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-editable
FROM python:3.14-slim
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
USER 1000
CMD ["python", "-m", "demo"]Three details were found by running it. uv is not in the Python image, so it
is copied in from uv’s own image, pinned. Without --no-editable, uv sync
installs the project as an editable link back to /app/src, and the
runtime stage, which copies only .venv, failed with No module named demo.
And --locked, not --frozen: with a stale lockfile --frozen exited 0 and
silently installed the old set, while --locked failed with “The lockfile at
uv.lock needs to be updated”.
A Dockerfile parsing trap
COPY requirements.txt app.py /app/ # copy sourcesERROR: failed to build: failed to solve: failed to compute cache key ... not foundDockerfile comments must start a line. Mid-line, #, copy and sources
became extra arguments and the build failed; USER 1000 # not root sets the
user to that whole string and the container fails to start. RUN lines
escape this only because the shell treats # as a comment.
Where it breaks in production
Stale security fixes from a cached apt-get update
The RUN apt-get update && apt-get install ... layer was cached months ago, so
every build ships the same old packages. Rebuild base layers on a schedule
with --no-cache (or --pull plus a cache bust), and let an image scanner
flag what is old.
A broad COPY before the install
COPY . . first: any edit to any file, a README included, reinstalls all
dependencies. Copy the manifests, install, then copy the source.
A secret in a layer
A .npmrc or pip.conf with a token is copied in, used, deleted. It is still
in the earlier layer. Build secrets go through --mount=type=secret; check
existing images with docker history and a secret scanner.
Cache that never reaches CI
Builds are fast on the laptop and slow in CI, because each CI job starts with
an empty builder. Export and import the cache (--cache-from/--cache-to
with a registry or the CI’s cache backend).
The trade not taken
Reproducible builds argue the other way: a cached layer means a build today may not match a clean build of the same commit. Pinning base images by digest and dependencies by lockfile keeps the cache and reproducibility together; the remaining trade is between cached speed and picking up upstream security fixes, settled by scheduled clean rebuilds.
Related
Interview angle 4
- “Requirements did not change, but CI reinstalls every dependency. Why?” -
either a broad
COPY . .before the install, or a CI builder with no cache.COPYis keyed on file contents:touchkept the cache, an edit broke it.- Follow-up: “You added a cache mount and CI is still slow.” - the mount lives on the builder; ephemeral CI runners start empty. Export the build cache to a registry or the CI cache.
- “How does BuildKit decide a step is cached?” -
COPY/ADD: checksums of the copied files.RUN: the command text and the previous layer, not what the command fetches;RUN datekept its first value for every rebuild. - “Why doesn’t deleting a file in a later layer shrink the image?” -
layers only add: the 50 MB layer stayed, the
rmlayer was 4.1 kB. Create and delete in the sameRUN, or use multi-stage builds.- Follow-up: “So a deleted secret is still there?” - yes, in the earlier layer. Use build secret mounts.
- “Which instructions create layers?” -
RUN,COPY,ADD(andWORKDIRwhen it creates the directory);ENV,CMD,USER,EXPOSEare 0-byte config entries.