构建缓存进阶:Buildx 远程缓存、CI 加速与缓存失效

从 BuildKit 内容寻址的缓存模型出发,讲解 cache key 与指令摘要生成规则、cache 挂载持久化依赖目录,深入 cache-from 与 cache-to 的 registry、local、gha 后端写法与参数,剖析 CI 多 job 缓存复用与缓存失效根因(COPY 顺序、apt 元数据、时间戳、ARG 变更),并给出 buildx du 清理与命中率诊断。

前置阅读:建议先阅读 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 digesttag 被重新推送
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=minmode=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仅单机可见
ghaGitHub CacheGitHub 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 用 ghascope 隔离命名空间
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 时间通常能下降一个数量级。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. 容器 CPU 调度与 NUMA:绑核、实时性与 QoS 保障
  2. Docker Daemon 运维:systemd 集成、配置调优与日志治理
  3. OCI 镜像与工件规范:manifest、index 与 artifact 生态