前置阅读:建议先阅读 Docker 构建优化完全指南:多阶段构建、BuildKit 与镜像体积最小化 与 Docker 镜像大小优化与层缓存策略深度指南。本篇聚焦 BuildKit 远程缓存后端、CI 缓存复用与缓存失效治理。
1. BuildKit 缓存模型:内容寻址与指令摘要
BuildKit 把 Dockerfile 编译成一张 LLB(Low-Level Builder)有向无环图,每个节点是一次不可变的操作:exec、file copy、source pull 等等。每个操作执行后会产出一个结果,结果由一个 cache key 唯一标识。cache key 不是随机数,而是对「操作定义加所有输入」做哈希得到的摘要(digest)。因此 BuildKit 的缓存本质是内容寻址:只要输入字节完全一致,摘要就一致,就能命中。
一个操作能否命中缓存,取决于它自身的摘要以及它所有上游依赖的摘要。上游任何一环变了,摘要就变,下游全部失效。这就是分层缓存在 BuildKit 里的严格版本:不是按层号,而是按内容依赖。
对不同指令,参与摘要的输入并不相同:
- FROM:镜像引用解析后的 manifest digest,注意不是 tag 字符串。
- COPY 与 ADD:被复制文件的内容校验和与权限位,而非修改时间。
- RUN:命令行字符串、构建时的环境变量(ENV 与 ARG 展开后的值)、工作目录、挂载定义,以及上游快照 digest。
- ENV、WORKDIR、USER、LABEL:指令参数本身。
一句话:cache key 是指令与输入的哈希,输入变一个字节,下游全废。
观察命中情况:
docker buildx build --progress=plain -t app . 2>&1 | grep -E 'CACHED|DONE'
CACHED 行表示该步直接复用缓存,DONE 表示真实执行。
| 指令 | 参与摘要的关键输入 | 易被忽略的失效源 |
|---|---|---|
| FROM | 镜像 manifest digest | tag 被重新推送 |
| COPY | 文件内容校验和 | 权限位、换行符差异 |
| RUN | 命令字符串与环境变量 | 上游层变化、ARG 取值 |
| ARG | 是否声明与默认值 | 传值变化即击穿下游 |
1.1 分层快照与执行缓存
BuildKit 内部区分两类缓存:执行缓存与快照缓存。前者记录某个 RUN 的输出文件系统 diff,后者是内容寻址存储里的内容块。二者都由 digest 索引。理解这点对后面的清理很重要,buildx du 统计的正是本地内容存储与执行缓存的占用。
2. 缓存挂载:持久化依赖目录
传统 Dockerfile 里 RUN pip install 或 go mod download 的下载物都写进镜像层,下一次构建即使命中也要重新下载,因为层缓存只对完全相同的输入有效。BuildKit 的 --mount=type=cache 把某个目录声明为缓存挂载:它不进入镜像层,而是跨构建持久保存在 builder 的缓存里。
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
# Go 模块缓存挂载:跨构建复用,不进镜像
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/app
关键参数:
- target:容器内挂载点。
- id:缓存实例标识,默认等于 target 的规范化路径;多个挂载可共享同一 id。
- sharing:locked、shared、private,控制并发构建时能否共享同一缓存,默认 locked 最安全。
- mode:挂载目录权限,例如 0755。
缓存挂载的命中不受上游层是否变化的影响,这正是它与层缓存的根本差别:即便 COPY 之后的源码变了,模块缓存仍在,go mod download 无需重新拉包。
一句话:cache 挂载让依赖下载物脱离镜像层,上游一变也不再重下。
2.1 各语言常见缓存目录
| 语言 | 典型缓存挂载目标 |
|---|---|
| Go | /go/pkg/mod 与 /root/.cache/go-build |
| Python | /root/.cache/pip |
| Node.js | /root/.npm |
| Rust | /usr/local/cargo/registry |
| Java | /root/.m2 |
3. 远程缓存后端:type=registry 的 min 与 max
单机 builder 的缓存只存在于本机,CI 的每次 job 都是全新机器,本地缓存等于零。远程缓存把 cache key 与内容导出到一个共享位置,让不同机器、不同 job 复用。最常用的是把缓存推进镜像仓库。
docker buildx build \
--cache-from type=registry,ref=registry.example.com/app:buildcache \
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
-t registry.example.com/app:1.2.3 \
--push .
--cache-to 的 mode 决定导出多少内容:
- mode=min:只导出最终镜像引用的层。体积小,但中间阶段(例如编译阶段的依赖层)不进缓存,下次改一行源码往往整段重编。
- mode=max:导出所有阶段、所有中间层,包括未进入最终镜像的编译层。命中率高得多,代价是缓存镜像体积可达最终镜像的数倍。
| 维度 | mode=min | mode=max |
|---|---|---|
| 导出范围 | 仅最终镜像层 | 全部中间层 |
| 缓存体积 | 小 | 大,常为镜像数倍 |
| 命中率 | 低 | 高 |
| 适用 | 简单单阶段 | 多阶段编译型项目 |
一句话:多阶段项目几乎总是选 max,用体积换命中率。
3.1 多后端与参数
--cache-to 可重复出现,同时导出到多个后端;--cache-from 也可叠加多个来源,BuildKit 会依次尝试。常用参数:
- ref:缓存镜像引用(registry)或目录路径(local)。
- scope:gha 与 local 后端用于隔离不同构建的命名空间。
- ignore-error:导出失败时不让整个构建失败,适合缓存非关键的场景。
- image-manifest:registry 后端将缓存导出为 OCI image manifest 而非 index,便于某些仓库兼容。
docker buildx build \
--cache-from type=registry,ref=reg/app:cache \
--cache-from type=local,src=/mnt/cache \
--cache-to type=registry,ref=reg/app:cache,mode=max,image-manifest=true,ignore-error=true \
--push -t reg/app:latest .
4. 其他后端:type=local 与 type=gha
除了 registry,BuildKit 还支持把缓存落到本地目录或 CI 提供的缓存服务。
type=local 把缓存写到一个目录,适合自托管 runner 挂载持久卷:
docker buildx build \
--cache-to type=local,dest=/mnt/buildcache,mode=max \
--cache-from type=local,src=/mnt/buildcache \
-t app .
type=gha 直接对接 GitHub Actions 的 Cache 服务,无需自建仓库:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/org/app:latest
cache-from: type=gha,scope=app
cache-to: type=gha,mode=max,scope=app
scope 是 gha 后端的关键参数:不同 scope 对应不同的缓存命名空间,避免多服务互相污染,默认 scope 为 buildkit。
inline 缓存是最轻的一种,把缓存信息内联进最终镜像的元数据:
docker buildx build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from registry.example.com/app:1.2.3 \
-t registry.example.com/app:1.2.3 --push .
它的局限很明确:只能携带最终镜像层的信息,中间阶段无法命中,因此仅适合极简单阶段构建。
| 后端 | 存储位置 | 适合场景 | 主要限制 |
|---|---|---|---|
| registry | 镜像仓库 | 跨机器、跨 CI | 需仓库写权限 |
| local | 本地目录 | 自托管持久 runner | 仅单机可见 |
| gha | GitHub Cache | GitHub Actions | 仓库级大小与保留期限制 |
| inline | 随镜像层 | 极简单阶段 | 只能缓存最终镜像层 |
5. CI 多 job 间的缓存复用
在 CI 里,构建、测试、发布常拆成多个 job,每个 job 跑在不同机器上。要让它们共享缓存,做法是让每个 job 都从同一远程缓存读写,并让写缓存与读缓存分离以降低写竞争。
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/org/app:${{ github.sha }}
cache-from: type=gha,scope=app
cache-to: type=gha,mode=max,scope=app
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- run: docker buildx build --cache-from type=gha,scope=app --load -t app .
要点:
- 先跑的 job 负责
cache-to写缓存,后跑的 job 用cache-from命中,避免多个 job 同时写同一 scope 造成覆盖。 - 对 registry 后端,用不同的 ref 或 tag 区分分支,例如给缓存镜像打上分支名后缀,防止特性分支污染主干缓存。
- 拉取缓存镜像同样需要认证;在 CI 里先
docker login,否则--cache-from type=registry会静默拉不到而全部 miss。
一句话:多 job 复用的本质是共享一个远程缓存地址,读写分离、按分支隔离。
6. 缓存失效的根因与治理
缓存命中率上不去,几乎都能归到下面几类根因。
6.1 COPY 顺序与粒度
最经典的问题是把源码和依赖声明一起 COPY:
# 反例:改一行源码即重装全部依赖
COPY . .
RUN pip install -r requirements.txt
正确做法是先 COPY 依赖清单、装依赖,再 COPY 源码:
# 正例:依赖层独立于源码层
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
6.2 apt 元数据与安装选项
apt-get update 与 apt-get install 必须写在同一层,否则 update 的结果会被层缓存固化,install 拉到的可能是过期索引。同时加 --no-install-recommends 减少无关包,让层更稳定。
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
6.3 时间戳与可复现构建
RUN 里凡是写入了当前时间的产物,都会让该层摘要每次不同,直接击穿缓存,典型如打包 tar、写版本文件。治理手段是固定时间戳:
ARG SOURCE_DATE_EPOCH=1700000000
RUN touch -d @${SOURCE_DATE_EPOCH} /app/build.info
SOURCE_DATE_EPOCH 是可复现构建的约定变量,把它固定住,同样的输入就得到同样的摘要。
6.4 ARG 变更与元数据指令
- ARG 一旦被某步引用,其取值就进入该步摘要,在 ARG 之后的所有下游步骤都会随之失效。把易变的 ARG(例如构建号、commit)尽量放在靠后的步骤。
- LABEL、ENV、USER 等元数据指令本身也参与摘要,调整它们会击穿其后所有层,把这类指令放到 Dockerfile 末尾。
- ADD 远程 URL:即使内容没变,HTTP 响应头(Last-Modified、ETag)变化也可能改变摘要,且无法离线复现。除非确有必要,改用 COPY 加显式下载步骤。
| 根因 | 表现 | 治理 |
|---|---|---|
| COPY 顺序不当 | 改源码即重装依赖 | 先清单后源码 |
| apt update 分离 | 装到过期索引 | 与 install 同层 |
| 时间戳写入 | 每次构建都 miss | 固定 SOURCE_DATE_EPOCH |
| ARG 位置靠前 | 改参数全量重编 | 易变 ARG 后置 |
| ADD 远程 URL | 摘要不稳定 | 改 COPY 加显式下载 |
一句话:缓存失效几乎都是输入不稳定,让每一步输入确定、把易变项后置即可。
7. 本地缓存的诊断、清理与精确击穿
BuildKit 的本地缓存会持续增长,需要定期查看与回收。
# 查看缓存占用明细
docker buildx du --verbose
# 按条件清理:只清某类型、某时间之前的记录
docker buildx prune --filter until=168h --filter type=exec.cachemount -f
--filter 常用取值:until(时间)、type(exec.cachemount、regular、source.local 等)、unused-for、id、parent。docker buildx prune -a 清空全部。
想只让某几步强制重跑而保留其余缓存,用 --no-cache-filter 精确击穿:
# 仅让 build 阶段失效,其余阶段继续命中
docker buildx build --no-cache-filter build -t app .
命中率诊断要点:
- 加
--progress=plain,逐行看 CACHED 与 DONE,定位第一个 DONE 即为失效起点。 - 若某步每次都 DONE 但输入看似没变,检查是否引用了未固定的 tag,或写入了时间戳。
- 用
--cache-from指定的 ref 若拉取失败,构建不会报错,只会全部 miss;务必在日志里确认缓存层被拉取。
8. CI 加速实战与远程缓存安全
8.1 分层与多阶段 target
把 CI 拆成依赖层、构建层、测试层三个 target,测试只构建到测试目标,发布只构建到运行目标,各自复用对应缓存。
FROM golang:1.22 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
FROM deps AS build
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/app
FROM build AS test
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go test ./...
FROM gcr.io/distroless/static AS runtime
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
# 只构建到 test 目标,跳过 runtime
docker buildx build --target test --cache-from type=gha,scope=app .
8.2 构建参数与秘密不污染缓存
- 用
--build-arg传的值会进入摘要,任何传入都会击穿后续层。对纯展示型参数,尽量改用 ENV 或运行时注入。 - 敏感信息绝不能用 ARG 传:ARG 值会写进镜像历史。改用
--secret,secret 挂载默认不进入缓存摘要。
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t app .
8.3 远程缓存安全
- 缓存镜像应推到私有仓库,并限制写权限:能写缓存的人可以投毒,让下游构建命中被篡改的层。
- 缓存 ref 与产物镜像分离,避免缓存镜像被误当作可运行镜像发布。
- 用
ignore-error=true时,导出失败会被吞掉,需在流水线另设告警,否则会长期静默失去缓存。 - 对不可信的 PR,谨慎允许其写共享缓存 scope,防止跨分支污染。
8.4 生产清单与踩坑速查
| 现象 | 根因 | 处置 |
|---|---|---|
| CI 每次全量重编 | 未配远程缓存 | 加 cache-from 与 cache-to |
| 缓存拉取无声失败 | 未登录仓库 | 构建前 docker login |
| 中间层不命中 | mode=min | 改 mode=max |
| 缓存体积失控 | 未定期回收 | buildx du 加 prune |
| 依赖每次重下 | 未用 cache 挂载 | 加 type=cache 挂载 |
| 秘密进入镜像 | 用 ARG 传密钥 | 改 secret 挂载 |
| 分支互相污染 | 共用缓存 ref | 按分支隔离 scope |
9. 总结
| 主题 | 关键结论 | 一句话记忆 |
|---|---|---|
| 缓存模型 | cache key 是指令与输入的哈希 | 输入变一字节下游全废 |
| cache 挂载 | 依赖目录脱离镜像层 | 上游变也不重下 |
| registry 后端 | mode=max 命中率远高于 min | 多阶段一律 max |
| local 与 gha | 自托管用 local,GH 用 gha | scope 隔离命名空间 |
| inline 缓存 | 只随最终镜像层导出 | 仅适合极简场景 |
| 多 job 复用 | 共享远程地址读写分离 | 按分支隔离缓存 ref |
| 缓存失效 | 输入不稳定是唯一根因 | 定时间、后置易变项 |
| 诊断清理 | du 看占用,prune 回收 | no-cache-filter 精确击穿 |
| 安全 | 缓存仓库须私有且限权 | 能写缓存就能投毒 |
Buildx 远程缓存的全部收益,都建立在 cache key 由输入内容唯一决定这一条上。理解了这点,选后端就只是选一个共享位置:跨机器用 registry,自托管用 local,GitHub Actions 用 gha,极简场景才用 inline。选完后端,剩下的功夫全在让输入稳定——先 COPY 依赖清单再 COPY 源码、apt update 与 install 同层、固定 SOURCE_DATE_EPOCH、把易变 ARG 与 LABEL 后置、用 cache 挂载把依赖下载物从镜像层里摘出来。诊断时以 --progress=plain 找到第一个 DONE 作为失效起点,用 buildx du 与 prune 控制体积,用 --no-cache-filter 只击穿必要的阶段。最后别忽略安全:缓存镜像能写就能投毒,务必推私有仓库并限制写权限,用 --secret 而非 ARG 传递凭据。把这几点固化进流水线模板,多阶段编译型项目的 CI 时间通常能下降一个数量级。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。