필사 모드: Why the Docker Build Cache Keeps Breaking — Layer Cache Keys, ARG, and BuildKit Cache Mounts
English- Introduction — I Changed One Comment, So Why Is npm ci Running Again
- Cache Invalidation Has Exactly One Rule
- The COPY Cache Key Is File Content, Not the Path
- Splitting Off the Dependency Layer — The Standard Pattern and Its Traps
- ARG, and the Things That Break the Cache Every Time
- CI Runners Have No Cache — Cache Mounts and Registry Cache
- Reproducibility and Caching Push Against Each Other
- Closing — When the Cache Misses, First Find Where It Broke
Introduction — I Changed One Comment, So Why Is npm ci Running Again
The CI pipeline started at 90 seconds. It is now 8 minutes. The logs show the time going into the same line every run.
=> [builder 4/7] RUN npm ci 287.4s
=> [builder 5/7] RUN npm run build 41.2s
Dependencies have not changed in three weeks, yet it reinstalls for nearly five minutes every time. Locally the second build finishes in 4 seconds, so it gets left in the vague state of "the cache does seem to work, sort of."
The cause is almost always one of two things. Either the ordering of commands in the Dockerfile changes the cache key on every run, or the CI runner has no cache to begin with. The two problems have completely different solutions, so you must first determine which one you have.
Cache Invalidation Has Exactly One Rule
The Docker build cache is a chain. Each layer's cache key is computed from the parent layer's digest and its own command. So when the parent changes, every child re-runs regardless of its content.
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- breaks here if a single source character changes
RUN npm ci # 4 <- so this breaks too
RUN npm run build # 5 <- and this
Because COPY . . sits third, modifying a single source file invalidates everything from step 3 onward. npm ci re-runs whether or not package-lock.json is unchanged. Docker does not know what a command means; it only knows that the parent of the layer in that position is different.
The corollary of this rule is the whole of optimization. Push what changes often downward, and lift what changes rarely upward.
The COPY Cache Key Is File Content, Not the Path
Each command computes its cache key differently. Not knowing this difference sends you off fixing the wrong thing.
The RUN cache key is the command string itself. It does not look at what the command does. So the two lines below hit the cache forever, even months later.
RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh
Docker has no idea that the remote repository's package index was refreshed, or that the install script changed. This is the real reason apt-get update and apt-get install must always be attached to the same RUN. It is about the cache, not about size.
The cache key for COPY and ADD is a hash of the target files' contents. There is one common misconception here. The claim that "just opening and saving a file breaks the cache" belongs to the era of the legacy builder. The classic builder included modification time in file metadata, so a single touch blew the cache away. BuildKit looks only at content hashes, mode, and ownership.
DOCKER_BUILDKIT=1 docker build -t t1 . >/dev/null
touch src/index.ts
DOCKER_BUILDKIT=1 docker build -t t1 . 2>&1 | grep -E 'CACHED|RUN'
=> CACHED [2/5] WORKDIR /app
=> CACHED [3/5] COPY package.json package-lock.json ./
=> CACHED [4/5] RUN npm ci
=> CACHED [5/5] COPY . .
A touch alone does not break it. Conversely, a fresh git clone in CI sets every file's mtime to the current time, and under BuildKit that has no effect on the cache. If the cache still breaks, the cause is somewhere other than mtime.
Splitting Off the Dependency Layer — The Standard Pattern and Its Traps
The standard approach is to copy only the files that declare dependencies first.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Now a source-only change invalidates from COPY . . onward, and npm ci comes out of the cache.
The corresponding file pairs per language look like this.
# Python
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Go
COPY go.mod go.sum ./
RUN go mod download
# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release
# Java (Maven)
COPY pom.xml ./
RUN mvn -B dependency:go-offline
There are two traps here.
First, in a monorepo, copying only COPY package.json ./ leaves out the manifests of the workspace sub-packages and npm ci fails. You have to copy with wildcards while preserving the directory structure.
COPY package.json package-lock.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci
If updating this list every time a package is added is tedious, BuildKit 1.7 and later let you use COPY --parents.
# syntax=docker/dockerfile:1.7-labs
COPY --parents package.json package-lock.json packages/*/package.json ./
RUN npm ci
Second, dependency declarations without pinned versions leave the cache in an irreproducible state. If requirements.txt says only requests, then a six-month-old version keeps being used for as long as the cache lives, and the moment the cache breaks once, it jumps to the newest version. This is another reason lock files exist.
ARG, and the Things That Break the Cache Every Time
ARG causes confusion because its cache behavior differs between builders. On the classic builder, the ARG declaration itself entered the cache key of every subsequent layer. BuildKit is different. Only the commands that actually reference the value are invalidated.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
ARG GIT_SHA
ARG BUILD_TIME
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
LABEL org.opencontainers.image.revision=$GIT_SHA
LABEL org.opencontainers.image.created=$BUILD_TIME
Even when GIT_SHA differs on every commit, npm ci does not reference the value, so it hits the cache. Only the two LABEL lines that reference it are recomputed. Flip that ordering and put an ENV that references the ARG near the top, and everything collapses.
# Anti-pattern
ARG GIT_SHA
ENV APP_REVISION=$GIT_SHA # from here down, the cache is wiped out every commit
COPY package.json ./
RUN npm ci
The classic causes of a cache that breaks every time are these: putting the build time or commit hash in an ENV near the top, placing COPY . . above dependency installation, having no .dockerignore so that changes in the .git directory alter the context hash, and leaving the base image on latest so the top of the file is invalidated whenever the remote digest changes.
CI Runners Have No Cache — Cache Mounts and Registry Cache
Even with a perfectly organized Dockerfile, CI still runs npm ci every time. Of course it does. The cache lives in the builder's local storage, and a GitHub Actions runner is a fresh VM on every run. No local cache, no cache hits.
There are two remedies, and they do not substitute for each other.
The first is the cache mount. It keeps a package manager's download cache in a separate volume instead of baking it into a layer.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
FROM python:3.12-slim AS py
RUN \
pip install -r requirements.txt
FROM golang:1.23 AS go
RUN \
go build ./...
The trap most people step on here needs to be stated plainly. Cache mounts are never exported to a registry cache. No matter how well you configure --cache-to type=registry or type=gha, the contents of RUN --mount=type=cache are not included. A cache mount is local state bound to the builder instance. On a runner that comes up fresh every time, it does nothing.
Where cache mounts actually pay off is a developer's local machine, and self-hosted runners or remote builders where the BuildKit daemon stays alive.
# Connect to a remote builder the team shares
docker buildx create --name shared --driver remote \
tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .
The second is the registry cache. It uploads the layer cache to a registry as an OCI artifact and pulls it back down on the next build. This one works even when the runner is brand new every time.
docker buildx build \
--cache-from type=registry,ref=registry.example.com/api:buildcache \
--cache-to type=registry,ref=registry.example.com/api:buildcache,mode=max \
--tag registry.example.com/api:v312 \
--push .
=> importing cache manifest from registry.example.com/api:buildcache 1.4s
=> CACHED [deps 3/3] RUN npm ci 0.0s
=> [builder 5/5] RUN npm run build 38.7s
=> exporting cache to registry.example.com/api:buildcache 6.2s
mode=max is the key. The default mode=min exports only the final stage's layers, so in a multi-stage build the expensive builder stage is exactly what does not get cached. Most reports of "multi-stage builds where the cache does not work" come down to this.
On GitHub Actions, type=gha is convenient.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/org/api:${{ github.sha }}
cache-from: type=gha,scope=api-${{ github.ref_name }}
cache-to: type=gha,scope=api-${{ github.ref_name }},mode=max
Split scope per branch, but keep in mind that the GitHub cache has a 10GB limit per repository and evicts the oldest entries first. Slicing scopes down to individual commits makes them push each other out and the hit rate actually falls.
| Method | Stored in | mode=max | Works on a new runner | Main use | Trap |
|---|---|---|---|---|---|
| Local layer cache | Builder disk | Not applicable | No | Developer machine | Useless in CI |
type=inline | The image itself | Unsupported | Yes | Single stage | Multi-stage cache missing |
type=registry | Registry | Supported | Yes | General-purpose CI | Storage cost, needs pruning |
type=gha | Actions cache | Supported | Yes | GitHub Actions | 10GB limit per repo |
type=local | Disk path | Supported | Conditionally | Self-hosted | Grows without bound, manual pruning |
RUN --mount=cache | Builder-local | Cannot be exported | No | Persistent builders | Void once a fresh runner starts |
In multi-architecture builds you need to know that the cache is separated per platform. linux/amd64 and linux/arm64 have different cache chains, so exporting both to a single cache ref with mode=max is easier to manage.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=registry.example.com/api:buildcache \
--cache-to type=registry,ref=registry.example.com/api:buildcache,mode=max \
--tag registry.example.com/api:v312 --push .
Building arm64 through QEMU emulation is 5 to 20 times slower than native. Rather than orchestrating caches, splitting the build across native runners per architecture and merging only the manifest at the end is usually faster.
docker buildx imagetools create \
--tag registry.example.com/api:v312 \
registry.example.com/api:v312-amd64 \
registry.example.com/api:v312-arm64
Reproducibility and Caching Push Against Each Other
The pressure to maximize caching and the pressure to want reproducible builds point in opposite directions. You have to acknowledge that tension and draw a boundary.
FROM node:22 is convenient, but the moment the digest is refreshed upstream, every layer is invalidated. And the image you built yesterday ends up using a different base than the one you build today. Pinning to a digest makes both problems disappear together.
FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31
Let a tool, not a person, handle the updates.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
apt-get update is another face of the same problem. Attaching it to the same RUN improves reproducibility, but the index is re-downloaded every time the install list changes. Move the index download into a cache mount and you get both.
# syntax=docker/dockerfile:1.7
RUN \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends libpq5 ca-certificates
The reason rm -f /etc/apt/apt.conf.d/docker-clean is needed is that the official Debian images are configured to delete the cache automatically right after installation. Without removing that setting, the cache mount is empty every time.
If you need to reproduce build output bit for bit, BuildKit 0.13 and later can pin timestamps.
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
--output type=registry,name=registry.example.com/api:v312,rewrite-timestamp=true .
Closing — When the Cache Misses, First Find Where It Broke
Cache problems do not get fixed by guessing. Pull the build log with --progress=plain, find the first point where CACHED disappears, and start by checking what that one line's cache key is.
docker buildx build --progress=plain . 2>&1 | grep -E '^#[0-9]+ (CACHED|\[)'
#7 [deps 2/3] COPY package.json package-lock.json ./
#7 CACHED
#8 [deps 3/3] RUN npm ci
#8 CACHED
#9 [builder 4/6] COPY . .
#10 [builder 5/6] RUN npm run build
The first step without CACHED is #9, so there is no need to touch anything above it and no way to help anything below it. The place to fix is always that one line.
There is one thing to remember. The cache is a chain, so a single break near the top makes everything below it meaningless. That is why a Dockerfile is written in ascending order of change frequency, why CI attaches a registry cache with mode=max, and why cache mounts should only be expected to help in environments where the builder stays alive.
현재 단락 (1/157)
The CI pipeline started at 90 seconds. It is now 8 minutes. The logs show the time going into the sa...