Split View: 도커 빌드 캐시가 자꾸 깨지는 이유 — 레이어 캐시 키, ARG, BuildKit 캐시 마운트
도커 빌드 캐시가 자꾸 깨지는 이유 — 레이어 캐시 키, ARG, BuildKit 캐시 마운트
- 들어가며 — 주석 한 줄 고쳤는데 왜 npm ci가 다시 도는가
- 캐시 무효화에는 규칙이 하나뿐이다
- COPY의 캐시 키는 경로가 아니라 파일 내용이다
- 의존성 레이어 분리 — 정석 패턴과 그 함정
- ARG, 그리고 캐시를 매번 깨는 것들
- CI 러너에는 캐시가 없다 — 캐시 마운트와 레지스트리 캐시
- 재현성과 캐시는 서로를 밀어낸다
- 마치며 — 캐시가 안 먹으면 먼저 어디서 깨졌는지부터 본다
들어가며 — 주석 한 줄 고쳤는데 왜 npm ci가 다시 도는가
CI 파이프라인이 처음에는 90초였습니다. 지금은 8분입니다. 로그를 보면 매번 같은 줄에서 시간을 씁니다.
=> [builder 4/7] RUN npm ci 287.4s
=> [builder 5/7] RUN npm run build 41.2s
의존성은 3주째 그대로인데도 매번 5분 가까이 다시 설치합니다. 로컬에서는 두 번째 빌드가 4초에 끝나므로 "캐시가 되긴 되는 것 같은데" 라는 애매한 상태로 방치됩니다.
원인은 거의 항상 둘 중 하나입니다. Dockerfile의 명령 순서가 캐시 키를 매번 바꾸고 있거나, CI 러너에 애초에 캐시가 존재하지 않는 것입니다. 두 문제는 해결책이 완전히 다르므로 먼저 어느 쪽인지 구분해야 합니다.
캐시 무효화에는 규칙이 하나뿐이다
도커 빌드 캐시는 체인입니다. 각 레이어의 캐시 키는 부모 레이어의 다이제스트와 자기 명령으로 계산됩니다. 그래서 부모가 바뀌면 자식은 내용과 무관하게 전부 다시 실행됩니다.
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- 소스 한 글자만 바뀌어도 여기서 깨짐
RUN npm ci # 4 <- 그래서 여기도 깨짐
RUN npm run build # 5 <- 여기도
COPY . .이 세 번째에 있으므로 소스 파일 하나만 수정해도 3번 이후가 전부 무효화됩니다. npm ci는 package-lock.json이 그대로인지 아닌지와 무관하게 다시 실행됩니다. 도커는 명령의 의미를 모르고, 그 자리에 있는 레이어의 부모가 달라졌다는 사실만 압니다.
이 규칙의 따름정리가 최적화의 전부입니다. 자주 바뀌는 것을 아래로 내리고, 드물게 바뀌는 것을 위로 올립니다.
COPY의 캐시 키는 경로가 아니라 파일 내용이다
명령별로 캐시 키 계산 방식이 다릅니다. 이 차이를 모르면 엉뚱한 곳을 고치게 됩니다.
RUN의 캐시 키는 명령 문자열 그 자체입니다. 명령이 무엇을 하는지는 보지 않습니다. 그래서 아래 두 줄은 몇 달이 지나도 영원히 캐시에 적중합니다.
RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh
원격 저장소의 패키지 인덱스가 갱신되어도, 설치 스크립트가 바뀌어도 도커는 알지 못합니다. 이것이 apt-get update와 apt-get install을 반드시 같은 RUN에 붙여야 하는 진짜 이유입니다. 크기 때문이 아니라 캐시 때문입니다.
COPY와 ADD의 캐시 키는 대상 파일들의 내용 해시입니다. 여기서 흔한 오해가 하나 있습니다. "파일을 열어 저장만 해도 캐시가 깨진다"는 말은 구 빌더 시절의 이야기입니다. 클래식 빌더는 파일 메타데이터에 수정 시각을 포함했기 때문에 touch 한 번으로 캐시가 날아갔습니다. BuildKit은 내용 해시와 모드, 소유권만 봅니다.
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 . .
touch만으로는 깨지지 않습니다. 반대로 CI에서 git clone을 새로 하면 모든 파일의 mtime이 현재 시각이 되는데, BuildKit에서는 이것이 캐시에 영향을 주지 않습니다. 여전히 캐시가 깨진다면 원인은 mtime이 아니라 다른 곳에 있습니다.
의존성 레이어 분리 — 정석 패턴과 그 함정
정석은 의존성을 선언하는 파일만 먼저 복사하는 것입니다.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
이제 소스만 바뀌면 COPY . .부터 무효화되고 npm ci는 캐시에서 나옵니다.
언어별로 대응되는 파일 쌍은 이렇습니다.
# 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
여기에 두 가지 함정이 있습니다.
첫째, 모노레포에서 COPY package.json ./만 복사하면 워크스페이스 하위 패키지의 매니페스트가 빠져 npm ci가 실패합니다. 와일드카드로 디렉터리 구조를 유지하며 복사해야 합니다.
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
패키지가 늘어날 때마다 이 목록을 갱신해야 하는 것이 번거롭다면, BuildKit 1.7 이상에서 COPY --parents를 쓸 수 있습니다.
# syntax=docker/dockerfile:1.7-labs
COPY --parents package.json package-lock.json packages/*/package.json ./
RUN npm ci
둘째, 버전을 고정하지 않은 의존성 선언은 캐시를 재현성 없는 상태로 만듭니다. requirements.txt에 requests라고만 적혀 있으면 캐시가 살아 있는 동안에는 6개월 전 버전이 계속 쓰이고, 캐시가 한 번 깨지는 순간 최신 버전으로 점프합니다. 락 파일을 쓰는 이유가 여기에도 있습니다.
ARG, 그리고 캐시를 매번 깨는 것들
ARG는 캐시 동작이 빌더에 따라 달라 혼란을 일으킵니다. 클래식 빌더에서는 ARG 선언 자체가 이후 모든 레이어의 캐시 키에 들어갔습니다. BuildKit은 다릅니다. 실제로 그 값을 참조하는 명령만 무효화됩니다.
# 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
GIT_SHA가 매 커밋 달라져도 npm ci는 그 값을 참조하지 않으므로 캐시에 적중합니다. 참조하는 LABEL 두 줄만 다시 계산됩니다. 이 순서를 뒤집어 ARG를 참조하는 ENV를 위쪽에 두면 전부 무너집니다.
# 안티패턴
ARG GIT_SHA
ENV APP_REVISION=$GIT_SHA # 여기서부터 매 커밋 캐시 전멸
COPY package.json ./
RUN npm ci
캐시를 매번 깨는 전형적인 원인은 다음과 같습니다. 빌드 시각이나 커밋 해시를 상단 ENV에 넣는 것, COPY . .을 의존성 설치보다 위에 두는 것, .dockerignore가 없어 .git 디렉터리 변경이 컨텍스트 해시를 바꾸는 것, 그리고 베이스 이미지를 latest로 두어 원격 다이제스트가 바뀔 때마다 최상단이 무효화되는 것입니다.
CI 러너에는 캐시가 없다 — 캐시 마운트와 레지스트리 캐시
Dockerfile을 완벽하게 정리해도 CI에서는 여전히 매번 npm ci가 돕니다. 당연합니다. 캐시는 빌더의 로컬 스토리지에 있는데, GitHub Actions의 러너는 매 실행마다 새 VM입니다. 로컬에 캐시가 없으면 캐시 히트도 없습니다.
해결 수단은 두 가지이고, 둘은 서로를 대체하지 않습니다.
첫 번째는 캐시 마운트입니다. 패키지 매니저의 다운로드 캐시를 레이어에 굽지 않고 별도 볼륨에 두는 방식입니다.
# 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 ./...
여기서 가장 많이 밟는 함정을 명확히 해야 합니다. 캐시 마운트는 레지스트리 캐시로 내보내지지 않습니다. --cache-to type=registry나 type=gha를 아무리 잘 설정해도 RUN --mount=type=cache의 내용은 포함되지 않습니다. 캐시 마운트는 빌더 인스턴스에 종속된 로컬 상태이기 때문입니다. 매번 새로 뜨는 러너에서는 아무 효과가 없습니다.
캐시 마운트가 실제로 이득이 되는 곳은 개발자 로컬 머신, 그리고 BuildKit 데몬이 계속 살아 있는 셀프 호스티드 러너나 원격 빌더입니다.
# 팀이 공유하는 원격 빌더에 연결
docker buildx create --name shared --driver remote \
tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .
두 번째는 레지스트리 캐시입니다. 레이어 캐시를 OCI 아티팩트로 레지스트리에 올리고, 다음 빌드에서 내려받습니다. 이쪽은 러너가 매번 새로 떠도 동작합니다.
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가 핵심입니다. 기본값인 mode=min은 최종 스테이지의 레이어만 내보내므로, 멀티스테이지 빌드에서 정작 비싼 빌더 스테이지가 캐시되지 않습니다. 멀티스테이지를 쓰면서 캐시가 안 먹는다는 신고의 대부분이 이것입니다.
GitHub Actions라면 type=gha가 편합니다.
- 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
scope를 브랜치별로 나누되, GitHub 캐시는 리포지토리당 10GB 한도이고 오래된 항목부터 축출됩니다. 스코프를 커밋 단위로 잘게 쪼개면 서로를 밀어내 히트율이 오히려 떨어집니다.
| 방식 | 저장 위치 | mode=max | 새 러너에서 유효 | 주 용도 | 함정 |
|---|---|---|---|---|---|
| 로컬 레이어 캐시 | 빌더 디스크 | 해당 없음 | 아니오 | 개발자 머신 | CI에서 무용 |
type=inline | 이미지 자체 | 미지원 | 예 | 단일 스테이지 | 멀티스테이지 캐시 누락 |
type=registry | 레지스트리 | 지원 | 예 | 범용 CI | 스토리지 비용, 정리 필요 |
type=gha | Actions 캐시 | 지원 | 예 | GitHub Actions | 리포당 10GB 한도 |
type=local | 디스크 경로 | 지원 | 조건부 | 셀프 호스티드 | 무한 증가, 수동 정리 |
RUN --mount=cache | 빌더 로컬 | 내보내기 불가 | 아니오 | 영속 빌더 | 러너가 새로 뜨면 무효 |
멀티 아키텍처 빌드에서는 캐시가 플랫폼별로 분리된다는 점을 알아야 합니다. linux/amd64와 linux/arm64는 서로 다른 캐시 체인을 가지므로, 하나의 캐시 ref에 mode=max로 함께 내보내는 것이 관리 면에서 낫습니다.
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 .
QEMU 에뮬레이션으로 arm64를 빌드하면 네이티브 대비 5배에서 20배까지 느려집니다. 캐시를 조율하는 것보다 아키텍처별 네이티브 러너로 나눠 빌드하고 마지막에 매니페스트만 합치는 편이 대부분 더 빠릅니다.
docker buildx imagetools create \
--tag registry.example.com/api:v312 \
registry.example.com/api:v312-amd64 \
registry.example.com/api:v312-arm64
재현성과 캐시는 서로를 밀어낸다
캐시를 극대화하려는 압력과 재현 가능한 빌드를 원하는 압력은 방향이 반대입니다. 이 긴장을 인정하고 경계를 정해야 합니다.
FROM node:22는 편하지만 원격에서 다이제스트가 갱신되는 순간 모든 레이어가 무효화됩니다. 그리고 어제 빌드한 이미지와 오늘 빌드한 이미지가 다른 베이스를 쓰게 됩니다. 다이제스트로 고정하면 두 문제가 함께 사라집니다.
FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31
갱신은 사람이 아니라 도구가 하게 둡니다.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
apt-get update도 같은 문제의 다른 얼굴입니다. 같은 RUN에 붙이면 재현성은 좋아지지만 install 목록이 바뀔 때마다 인덱스를 다시 받습니다. 인덱스 다운로드를 캐시 마운트로 옮기면 둘 다 얻을 수 있습니다.
# 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
rm -f /etc/apt/apt.conf.d/docker-clean이 필요한 이유는 공식 데비안 이미지가 설치 직후 캐시를 자동 삭제하도록 설정되어 있기 때문입니다. 그 설정을 지우지 않으면 캐시 마운트가 매번 비어 있습니다.
빌드 결과를 비트 단위로 재현해야 한다면 BuildKit 0.13 이상에서 타임스탬프를 고정할 수 있습니다.
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
--output type=registry,name=registry.example.com/api:v312,rewrite-timestamp=true .
마치며 — 캐시가 안 먹으면 먼저 어디서 깨졌는지부터 본다
캐시 문제는 추측으로 고쳐지지 않습니다. --progress=plain으로 빌드 로그를 뽑아 CACHED가 사라지는 첫 지점을 찾고, 그 한 줄의 캐시 키가 무엇인지부터 확인해야 합니다.
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
CACHED가 붙지 않은 첫 단계가 #9이므로, 그 위는 건드릴 필요가 없고 그 아래는 손댈 방법이 없습니다. 고칠 지점은 항상 그 한 줄입니다.
기억할 것은 하나입니다. 캐시는 체인이므로 위쪽에서 한 번만 깨져도 아래는 전부 무의미해집니다. 그래서 Dockerfile은 변경 빈도의 오름차순으로 쓰고, CI에서는 레지스트리 캐시를 mode=max로 붙이고, 캐시 마운트는 빌더가 살아 있는 환경에서만 기대해야 합니다.
Why the Docker Build Cache Keeps Breaking — Layer Cache Keys, ARG, and BuildKit Cache Mounts
- 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.