- Published on
Docker 构建缓存为什么总是失效 — 层缓存键、ARG 与 BuildKit 缓存挂载
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 只改了一行注释,为什么 npm ci 又跑了一遍
- 缓存失效只有一条规则
- COPY 的缓存键是文件内容,不是路径
- 依赖层的分离 — 标准写法与它的陷阱
- ARG,以及那些每次都会打断缓存的东西
- CI runner 上没有缓存 — 缓存挂载与镜像仓库缓存
- 可复现性与缓存互相排斥
- 结语 — 缓存不生效时,先看它是在哪里断的
引言 — 只改了一行注释,为什么 npm ci 又跑了一遍
CI 流水线一开始是 90 秒。现在是 8 分钟。看日志会发现,每次都把时间花在同一行上。
=> [builder 4/7] RUN npm ci 287.4s
=> [builder 5/7] RUN npm run build 41.2s
依赖已经三周没动过,却每次都要重装将近五分钟。本地第二次构建 4 秒就结束,于是就停留在"缓存好像是有效的吧"这种含糊状态里没人管。
原因几乎总是二者之一。要么是 Dockerfile 里的指令顺序每次都在改变缓存键,要么是 CI runner 上根本就不存在缓存。两个问题的解法完全不同,所以必须先分清是哪一种。
缓存失效只有一条规则
Docker 构建缓存是一条链。每一层的缓存键由父层的摘要和自身的指令计算得出。所以,父层一变,子层无论内容如何都会全部重新执行。
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 有没有变毫无关系。Docker 不理解指令的含义,它只知道那个位置上的层的父层变了。
这条规则的推论就是优化的全部内容。把经常变的往下挪,把很少变的往上提。
COPY 的缓存键是文件内容,不是路径
每条指令计算缓存键的方式不同。不了解这个差别,就会去修错地方。
RUN 的缓存键就是命令字符串本身。它不看这条命令做了什么。所以下面这两行过几个月也永远命中缓存。
RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh
远端仓库的软件包索引更新了也好,安装脚本改了也好,Docker 都不知道。这才是必须把 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
这里有两个陷阱。
第一,在 monorepo 里只复制 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,那么在缓存还活着的期间会一直用着半年前的版本,而缓存一旦断掉的那一刻就跳到最新版本。这也是使用锁文件的理由之一。
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 runner 上没有缓存 — 缓存挂载与镜像仓库缓存
就算把 Dockerfile 整理得完美无缺,CI 上照样每次都跑 npm ci。这很正常。缓存在构建器的本地存储里,而 GitHub Actions 的 runner 每次运行都是一台新虚拟机。本地没有缓存,就不会有缓存命中。
解决手段有两种,而且互相不能替代。
第一种是缓存挂载。把包管理器的下载缓存放到单独的卷里,而不是烙进层中。
# 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 里的内容都不会被包含进去。因为缓存挂载是绑定在构建器实例上的本地状态。在每次都新建的 runner 上,它没有任何效果。
缓存挂载真正能带来收益的地方,是开发者的本地机器,以及 BuildKit 守护进程持续存活的自托管 runner 或远程构建器。
# 连接到团队共享的远程构建器
docker buildx create --name shared --driver remote \
tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .
第二种是镜像仓库缓存。把层缓存作为 OCI 制品上传到镜像仓库,下次构建时再拉下来。这一种即使 runner 每次都是新的也照样有效。
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 | 在新 runner 上有效 | 主要用途 | 陷阱 |
|---|---|---|---|---|---|
| 本地层缓存 | 构建器磁盘 | 不适用 | 否 | 开发者机器 | 在 CI 上没用 |
type=inline | 镜像本身 | 不支持 | 是 | 单阶段 | 多阶段缓存缺失 |
type=registry | 镜像仓库 | 支持 | 是 | 通用 CI | 存储成本,需要清理 |
type=gha | Actions 缓存 | 支持 | 是 | GitHub Actions | 每仓库 10GB 上限 |
type=local | 磁盘路径 | 支持 | 有条件 | 自托管 | 无限增长,需手动清理 |
RUN --mount=cache | 构建器本地 | 无法导出 | 否 | 常驻构建器 | runner 一新建就失效 |
在多架构构建里,要知道缓存是按平台分离的。linux/amd64 和 linux/arm64 各有各的缓存链,所以用 mode=max 一起导出到同一个缓存 ref 在管理上更省事。
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 倍。与其去调和缓存,不如按架构分到各自的原生 runner 上构建,最后只把 manifest 合并起来,多数情况下更快。
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,是因为官方 Debian 镜像被配置成安装完立刻自动删除缓存。不删掉那份配置,缓存挂载每次都是空的。
如果需要按比特复现构建结果,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 挂上镜像仓库缓存,而缓存挂载只应在构建器持续存活的环境里去指望。