Docker 基础体系 · 第 31/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

BuildKit 缓存深入:Layer、Cache Mount、远程缓存和失效诊断

BuildKit 是现代 Docker 构建的默认后端之一。它不仅能复用最终镜像中的文件系统层,还能维护构建步骤的中间结果、编译器下载目录、包管理器缓存,并将这些缓存导出到本地目录、镜像仓库或 CI 缓存服务。

要正确理解构建缓存,必须先区分四个容易混淆的对象:

  • Layer:镜像文件系统的不可变层,最终可能进入镜像。
  • Build cache record:BuildKit 对某个构建操作及其输入的可复用结果记录。
  • Cache mount:通过 RUN --mount=type=cache 暴露给构建命令的持久化临时目录,不进入镜像层。
  • Remote cache:把 BuildKit 的缓存记录及相关数据导出到本地目录、OCI Registry、GitHub Actions Cache 等外部位置。

它们的生命周期、校验方式和失效条件并不相同。把“镜像层缓存”“包管理器缓存”和“远程缓存”统称为 Layer Cache,通常会导致错误的诊断结论。


一、BuildKit 缓存到底缓存了什么

1.1 Dockerfile 会被转换为构建图

BuildKit 不会简单地从上到下执行 Dockerfile 并把每一行当作字符串缓存。Dockerfile 前端会将其转换为低层构建图,通常称为 LLB(Low-Level Build)图。

例如:

FROM debian:bookworm-slim

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

可以抽象为:

基础镜像
  │
  ├── 设置工作目录
  │
  ├── 复制 package.json 和 package-lock.json
  │       │
  │       └── 执行 npm ci
  │
  ├── 复制其余源代码
  │
  └── 执行 npm run build

每个步骤都会产生一个新的文件系统状态或执行结果。后续步骤的输入包含前一步的结果,因此前面发生变化时,后面的结果通常也无法继续复用。

1.2 缓存命中的抽象条件

可以用一个简化公式表示某个构建操作的缓存键:

Ki=H(Oi,Pi,Ii,Mi,Ai,Ti)K_i = H( O_i,\, P_i,\, I_i,\, M_i,\, A_i,\, T_i )

其中:

  • KiK_i:第 ii 个构建操作的缓存键;
  • OiO_i:操作本身,例如 COPYRUNENV
  • PiP_i:父状态,即前序步骤产生的文件系统结果;
  • IiI_i:显式输入,例如构建上下文中的文件;
  • MiM_i:挂载配置、工作目录、环境等元数据;
  • AiA_i:相关构建参数、平台和前端信息;
  • TiT_i:工具链或 BuildKit 前端使用的其他输入;
  • HH:内容寻址哈希函数。

当新的构建操作得到相同的有效缓存键,并且当前 BuildKit 实例能够找到对应的缓存记录时,BuildKit 可以直接复用结果,而不重新执行该操作。

这个公式是理解缓存的模型,不是 BuildKit 对外承诺的内部哈希格式。具体缓存记录会随着 BuildKit、Dockerfile 前端和驱动实现变化,但以下因果关系稳定成立:

  1. 父状态改变,会影响后续操作。
  2. COPYADD 纳入的输入文件改变,会影响对应操作。
  3. 某个 RUN 的缓存命中,并不意味着它会重新读取所有外部可变资源。
  4. Cache mount 的目录内容与该 RUN 的文件系统结果分属不同生命周期。

1.3 Layer 不是“每条 Dockerfile 指令的完整缓存对象”

镜像 Layer 是文件系统差异的不可变快照。例如:

RUN echo hello > /message

执行后,/message 可能出现在某个镜像层中。后续镜像构建可以复用这个结果。

但 BuildKit 的缓存记录比最终镜像层更丰富:

  • 某些中间阶段没有出现在最终镜像中;
  • 某些执行步骤的结果会作为后续步骤输入,但最终阶段被丢弃;
  • BuildKit 可以并行计算互不依赖的阶段;
  • 一个缓存记录可以描述执行操作、输入引用和输出引用,而不只是一个 tar 层。

因此:

镜像层是结果存储的一种表现;BuildKit cache record 是构建图上的可复用执行结果。

执行以下命令时,看到的是镜像历史,不是完整的 BuildKit 缓存数据库:

docker image history myapp:dev

而以下命令主要查看构建器持有的缓存空间:

docker buildx du
docker buildx du --verbose

不同 Docker Engine、Buildx 和驱动版本对详细输出略有差异,但诊断时应同时看镜像历史和构建器缓存,不能用其中一个替代另一个。


二、Layer 缓存的命中和失效

2.1 COPY 使用文件内容和元数据参与判断

对于构建上下文中的文件,BuildKit 会根据文件内容及相关元数据判断输入是否发生变化。文件修改时间通常不会单独导致 ADDCOPY 缓存失效;内容、路径、权限等会影响结果。

例如:

FROM alpine:3.20

WORKDIR /src
COPY hello.txt .
RUN sha256sum hello.txt

目录结构:

.
├── Dockerfile
└── hello.txt

第一次构建:

docker buildx build --progress=plain -t cache-demo:one .

典型输出会显示每个步骤执行。

第二次构建:

docker buildx build --progress=plain -t cache-demo:two .

如果 Dockerfile、基础镜像解析结果和 hello.txt 内容没有变化,COPYRUN 通常会显示缓存命中。

如果只修改 hello.txt

printf 'changed\n' > hello.txt
docker buildx build --progress=plain -t cache-demo:three .

则:

  1. FROM 仍可复用;
  2. COPY hello.txt . 的输入发生变化,不能复用;
  3. 后续 RUN sha256sum hello.txt 的父状态变化,也不能复用。

这就是“缓存失效向后传播”。

2.2 一个命令中的外部变化不一定会触发重新执行

考虑下面的 Dockerfile:

FROM alpine:3.20

RUN apk add --no-cache curl

如果远端软件仓库中的 curl 包发生了变化,或者仓库发布了新的版本,已有的 RUN 缓存并不会因为远端内容变化而自动失效。BuildKit 通常只知道 Dockerfile 操作和已声明输入发生了什么,不会每次都联网重新验证命令的所有外部副作用。

因此,以下两个目标不同:

  • 构建缓存:尽可能复用相同输入的结果;
  • 主动获取最新依赖:明确要求重新执行依赖解析或安装。

需要更新依赖时,可使用:

docker buildx build --pull --no-cache -t myapp:latest .

它们作用不同:

  • --pull:尝试拉取更新的基础镜像;
  • --no-cache:不复用已有构建缓存;
  • 两者同时使用,才接近“基础镜像和所有构建步骤都重新执行”。

但这仍不等于完全可重复构建。若依赖没有锁定版本、仓库内容可变、构建脚本读取当前时间,结果仍可能变化。

2.3 Dockerfile 指令的排列直接决定缓存粒度

不推荐:

FROM node:22-bookworm-slim

WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

只修改一个源代码文件,就会导致 COPY . . 变化,继而使 npm ci 重新执行。

更合理的结构是:

FROM node:22-bookworm-slim AS build

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

这里的推导是:

  1. npm ci 的主要输入是 package.jsonpackage-lock.json
  2. 源代码不参与依赖解析;
  3. 因此先复制锁文件并安装依赖,可以让源代码变更不影响依赖安装层;
  4. 最后再复制源代码并执行编译。

但这不是无条件成立的。如果 package.json 中包含会根据源码生成依赖的脚本,或者安装脚本读取其他文件,那么必须把那些文件也视为 npm ci 的输入。

2.4 .dockerignore 会改变缓存输入集合

构建上下文中的文件是否发送给构建器,会受到 .dockerignore 影响:

.git
node_modules
dist
coverage
.env

如果一个文件被排除,它不会参与后续 COPY . . 的输入集合,也不能被该指令复制进去。

这带来两个直接结果:

  • 不必要的文件不会使上下文传输和缓存计算变大;
  • 被排除的文件无法作为构建输入,不能在 Dockerfile 中依赖它。

例如,把 .env 排除是避免误复制,但不能解决“程序在构建时需要配置”的问题。构建时敏感信息应使用 Secret mount,而不是把 .env 复制进镜像。


三、Cache Mount:不进入镜像层的持久化构建目录

3.1 Cache mount 的语义

RUN --mount=type=cache 会为某个构建命令挂载一个由构建器管理的目录:

# syntax=docker/dockerfile:1

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt

这里有两个不同结果:

  1. pip install 安装到系统环境中的 Python 包,会进入该 RUN 的文件系统结果,可能进入后续镜像层;
  2. /root/.cache/pip 中的下载缓存由 BuildKit 管理,不会因为命令结束而自动删除,但也不会进入最终镜像层。

第二次构建时,即使 pip install 这条 RUN 因为其他输入变化而重新执行,/root/.cache/pip 仍可能包含之前下载的 wheel 或源码包,从而减少网络下载。

因此 Cache mount 解决的是:

重新执行构建命令时,如何复用命令内部的可再生数据。

它不解决:

如何让整个 RUN 步骤直接命中并跳过执行。

3.2 Layer Cache 与 Cache Mount 的对比

假设修改了应用源代码,导致下面的构建步骤重新执行:

COPY . .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

可能发生的过程是:

COPY . .                         失效
        │
        ▼
RUN pip install                  重新执行
        │
        ├── 安装结果重新写入镜像文件系统
        └── pip 下载目录从 cache mount 中复用

如果没有 Cache mount,pip install 可能每次都重新下载依赖。

如果存在完全命中的 Layer/BuildKit cache record,则整个 RUN 都不会执行,Cache mount 也不会被访问。

3.3 Cache mount 内容不是可靠的构建输入

Cache mount 应当被视为:

  • 可持久化;
  • 可复用;
  • 可丢失;
  • 可能包含旧数据;
  • 不保证初始为空;
  • 不保证不同构建器之间存在。

因此使用它的命令必须能处理缓存未命中、缓存损坏和缓存内容过期。

正确:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

因为 pip 会在缓存缺失时重新下载。

不正确的思路是把 Cache mount 当作唯一输入:

RUN --mount=type=cache,target=/opt/generated \
    cp /opt/generated/generated-config.json /app/config.json

如果 /opt/generated/generated-config.json 只在某次历史构建中存在,这个构建就依赖了不可见的外部状态。换一个全新的构建器,或者执行缓存清理后,构建可能直接失败。

3.4 Cache mount 的 id 和并发策略

Cache mount 可以指定独立的 ID:

RUN --mount=type=cache,id=myapp-pip-cache,target=/root/.cache/pip \
    pip install -r requirements.txt

如果多个目录使用同一个 id,它们可能共享同一个缓存目录。通常应根据工具、项目或目标平台划分 ID,避免不兼容的数据互相污染。

sharing 控制并发访问方式:

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

常见取值含义是:

  • shared:多个构建可以并发使用;
  • private:需要独立缓存实例时使用;
  • locked:同一缓存被占用时,让其他构建等待。

包管理器经常不适合多个进程同时修改同一缓存数据库,因此 apt 使用 sharing=locked 更稳妥。shared 并不等于工具本身支持并发写入;BuildKit 只负责提供访问策略,不会替包管理器修复锁竞争或数据库损坏。

3.5 apt 示例中的边界

一个常见写法是:

# syntax=docker/dockerfile:1

FROM debian:bookworm-slim

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends \
      ca-certificates \
      curl \
 && rm -rf /var/lib/apt/lists/*

这里:

  • /var/cache/apt 保存下载的 .deb 等缓存;
  • /var/lib/apt 覆盖 apt 的部分状态目录;
  • apt-get update 每次实际执行时刷新索引;
  • 删除 /var/lib/apt/lists/* 是为了避免索引进入镜像层;
  • 下载包仍可从 Cache mount 复用。

如果 apt-get update && apt-get install 整条 RUN 命中 Layer 缓存,则不会执行更新。若要求重新获取仓库索引,应使该步骤失效,例如显式更新构建参数:

docker buildx build \
  --build-arg APT_CACHE_BUSTER="$(date +%Y-%m-%d)" \
  -t debian-tools:dev .

Dockerfile:

ARG APT_CACHE_BUSTER
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

这会强制该步骤按日期重新执行,但日期本身降低了可重复性。生产构建更适合固定软件源、锁定版本,并在明确的依赖更新流程中主动刷新缓存。


四、Cache mount、Secret 和 Bind mount 不能混用理解

BuildKit 的 RUN --mount 支持多种挂载类型,它们的目的不同。

4.1 Cache mount

RUN --mount=type=cache,target=/root/.cache/go-build \
    go test ./...

用于保存可再生的构建缓存,例如 Go 编译缓存、npm 下载缓存、Cargo registry。

4.2 Secret mount

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

构建命令可以读取 Secret,但 Secret 不应写入镜像层。

构建命令:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t private-app:dev .

注意一个重要边界:Secret 内容通常不会作为普通 Dockerfile 输入参与缓存键。也就是说,替换 Secret 文件本身不一定使对应 RUN 自动失效。如果 Secret 内容改变后必须重新执行,应显式改变一个非敏感的构建输入,或使用:

docker buildx build --no-cache -t private-app:dev .

不要这样传递凭据:

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken=$NPM_TOKEN \
 && npm ci

因为构建参数可能出现在构建历史、缓存元数据或诊断输出中。Secret mount 只解决“避免直接写入镜像和 Dockerfile”,并不自动解决依赖结果缓存过期的问题。

4.3 Bind mount

RUN --mount=type=bind,from=build,source=/app,target=/src,ro \
    sh /src/scripts/check.sh

Bind mount 用于把上下文或其他阶段的文件临时提供给命令,默认不把这些文件复制进当前层。它适合测试、生成中间产物或避免把大型输入写入结果层。

但挂载内容和最终文件系统结果是两个概念。若命令没有把输出复制到当前根文件系统,后续阶段不能自动看到挂载目录中的文件:

RUN --mount=type=bind,source=.,target=/src,ro \
    cd /src && make

如果 make 只把结果写回 /src,结果位于临时挂载中,命令结束后不会成为镜像内容。需要保留的输出必须写入未被挂载覆盖的路径,例如:

RUN --mount=type=bind,source=.,target=/src,ro \
    cd /src && make install DESTDIR=/out

然后再通过多阶段构建复制 /out


五、多阶段构建中的缓存关系

考虑下面的 Dockerfile:

# syntax=docker/dockerfile:1

FROM golang:1.23 AS build

WORKDIR /src

COPY go.mod go.sum ./
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 \
    CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12

COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

数据流如下:

flowchart LR
    A[go.mod/go.sum] --> B[go mod download]
    B --> C[Go module cache mount]
    B --> D[build stage filesystem]
    E[源代码] --> F[go build]
    C --> F
    D --> F
    F --> G[/out/server]
    G --> H[最终运行时镜像]

第一次构建时:

  1. COPY go.mod go.sum ./ 产生依赖描述状态;
  2. go mod download 下载模块,同时填充 /go/pkg/mod
  3. COPY . . 复制源代码;
  4. go build 使用模块缓存和编译缓存;
  5. 最终阶段只复制 /out/server

修改一个 .go 文件后:

  • go mod download 仍可命中 Layer 缓存;
  • go build 通常因源代码状态变化而重新执行;
  • Go module cache 和编译缓存仍可从 Cache mount 复用;
  • 最终镜像只包含二进制文件,不包含 Go 工具链和缓存目录。

修改 go.modgo.sum 后:

  • 依赖描述的 COPY 失效;
  • go mod download 重新执行;
  • 依赖缓存中已有的模块仍可能被复用;
  • 后续编译步骤因父状态变化而重新计算。

这里同时使用了三种缓存层次:

  1. Layer/BuildKit 结果缓存:跳过整个构建步骤;
  2. Go Cache mount:步骤重新执行时减少模块下载和编译工作;
  3. 多阶段结果裁剪:不把构建工具和缓存带入最终镜像。

六、远程缓存:让另一个构建器复用结果

本地缓存只存在于某个 BuildKit 构建器的存储中。以下场景会使本地缓存失去作用:

  • CI 每次使用全新的虚拟机;
  • 构建在多个 Runner 之间调度;
  • 开发机和 CI 使用不同构建器;
  • 构建器磁盘被定期清理;
  • 构建在不同平台节点执行。

远程缓存通过 --cache-to 导出,通过 --cache-from 导入。

6.1 Registry 缓存

先创建并使用一个 Buildx 构建器:

docker buildx create \
  --name ci-builder \
  --driver docker-container \
  --use \
  --bootstrap

登录镜像仓库:

docker login registry.example.com

构建并导出缓存:

docker buildx build \
  --platform linux/amd64 \
  -t registry.example.com/team/myapp:main \
  --cache-from type=registry,ref=registry.example.com/team/myapp:buildcache \
  --cache-to type=registry,ref=registry.example.com/team/myapp:buildcache,mode=max \
  --push \
  .

下一次构建使用相同的 --cache-from 时,BuildKit 会从 Registry 导入可用缓存记录。

参数含义:

  • type=registry:使用 OCI Registry 作为缓存后端;
  • ref=...:buildcache:缓存引用,最好与运行镜像标签分开;
  • mode=max:尽量导出更多中间阶段和中间结果;
  • --push:把最终镜像推送到仓库。

mode=min 通常只导出最终结果路径上需要的较少缓存;mode=max 更适合 CI,因为中间构建阶段也可能被后续构建复用,但缓存占用和推送数据通常更多。

缓存引用不应随意与生产镜像标签混用。将缓存写入专用引用,例如:

team/myapp:buildcache
team/myapp:buildcache-amd64
team/myapp:buildcache-arm64

更容易管理权限、清理策略和平台边界。

6.2 Inline 缓存

Inline 缓存把部分构建缓存信息嵌入镜像相关元数据中,使用方式通常是:

docker buildx build \
  -t registry.example.com/team/myapp:main \
  --cache-from type=registry,ref=registry.example.com/team/myapp:main \
  --cache-to type=inline \
  --push \
  .

Inline 方式简单,适合缓存规模较小、希望缓存和镜像一起分发的场景。它通常不如独立 Registry cache 灵活,尤其是在需要导出大量中间阶段时。

使用哪种方式取决于构建器驱动和 Docker/Buildx 版本。若构建时报“不支持 cache exporter”,应先检查:

docker buildx version
docker buildx inspect --bootstrap
docker info

不同驱动对缓存导出后端的支持可能不同,不能仅凭 Dockerfile 判断能力是否存在。

6.3 Local、GitHub Actions 和其他后端

本地目录缓存示例:

docker buildx build \
  --cache-from type=local,src=/var/lib/buildkit-cache \
  --cache-to type=local,dest=/var/lib/buildkit-cache-new,mode=max \
  -t myapp:local \
  .

某些实现要求先导出到新目录,再通过目录替换避免并发写入和旧缓存残留问题。生产 CI 中应结合 Runner 的缓存机制管理该目录,而不是把它当作永久可靠存储。

GitHub Actions 中常见配置是:

docker buildx build \
  --cache-from type=gha \
  --cache-to type=gha,mode=max \
  -t myapp:ci \
  .

type=gha 依赖 GitHub Actions 环境和 Buildx/BuildKit 支持。在普通本地 Shell 中执行通常没有有效的 Actions Cache 环境变量。

远程缓存只负责缓存数据的传输和复用,不改变缓存键的语义。如果两个构建使用不同的:

  • Dockerfile;
  • 构建上下文;
  • 构建参数;
  • 目标平台;
  • 基础镜像;
  • Secret 或外部依赖策略;

它们仍可能无法命中同一缓存,或者命中后得到并不适合当前目标的结果。

6.4 Compose 中配置远程缓存

现代 Compose Build Specification 支持在构建配置中声明缓存来源和目标:

services:
  app:
    image: registry.example.com/team/myapp:dev
    build:
      context: .
      cache_from:
        - type=registry,ref=registry.example.com/team/myapp:buildcache
      cache_to:
        - type=registry,ref=registry.example.com/team/myapp:buildcache,mode=max

执行:

docker compose build app

生产 CI 中通常还需要显式登录 Registry,并确认当前 Compose 版本支持这些字段。Compose 配置本身不保证远程仓库权限、缓存保留时间或不同 Runner 之间的网络可达性。


七、远程缓存的数据流和故障路径

一次带远程缓存的构建可以抽象为:

sequenceDiagram
    participant C as CI Runner
    participant B as BuildKit
    participant R as Registry Cache
    participant I as Image Registry

    C->>B: 读取 Dockerfile、上下文、构建参数
    B->>R: 导入 cache-from
    R-->>B: 返回可用缓存记录
    B->>B: 计算构建图和缓存键
    B->>B: 命中则复用,未命中则执行
    B->>R: 导出 cache-to
    B->>I: 推送最终镜像
    B-->>C: 返回构建结果和摘要

典型故障路径包括:

Registry 认证失败

failed to solve: ... unauthorized

此时通常不是 Dockerfile 缓存逻辑错误,而是:

  • 未执行 docker login
  • CI 使用了错误的 Registry;
  • 缓存引用没有读取权限;
  • 缓存导出引用没有写入权限。

应先单独验证:

docker pull registry.example.com/team/myapp:buildcache
docker push registry.example.com/team/myapp:test-permission

不应为了绕过权限错误而直接删除 --cache-from,因为这样会把“远程缓存不可用”隐藏成“构建变慢”。

导入成功但没有命中

远程缓存可正常读取,但每一步都重新执行,常见原因是:

  • 使用了不同的构建上下文;
  • CI 把当前提交号作为构建参数,导致后续步骤都不同;
  • 构建平台不同;
  • Dockerfile 在缓存导出后发生变化;
  • 缓存引用被覆盖或已过期;
  • 使用了不兼容的构建器或前端版本。

应使用:

docker buildx build --progress=plain ...

观察具体从哪一步开始变为执行,而不是只看总耗时。

导出成功但下一次看不到

可能原因包括:

  • 缓存写到了不同的 Registry 或引用;
  • Registry 垃圾回收删除了缓存 blob;
  • 多个并发 Job 同时覆盖同一个缓存引用;
  • CI 缓存服务达到大小或保留期限;
  • 导出过程被取消;
  • 当前构建器无法读取该后端格式。

共享缓存引用最好按分支或用途设计,例如:

myapp:buildcache-main
myapp:buildcache-feature-x

也可以使用一个稳定的主分支缓存作为回退来源,再使用提交或分支专属缓存作为首选来源。具体写法取决于 CI 系统支持的缓存后端。


八、缓存失效诊断:先定位“哪种缓存没有命中”

缓存问题不能只用“加 --no-cache”处理,因为 --no-cache 只能证明重新执行后结果是否正确,不能解释原来的失效原因。

建议按以下顺序诊断。

8.1 先固定输出格式

docker buildx build \
  --progress=plain \
  -t myapp:debug \
  .

plain 会输出更接近逐步执行的日志,适合 CI 和问题排查。重点观察:

  • 哪个步骤显示缓存命中;
  • 哪个步骤第一次重新执行;
  • 失效点之前是否有 COPYARGFROM 或平台变化;
  • 重新执行的命令是否访问了网络、Secret 或 Cache mount。

如果只使用交互式进度条,日志被折叠后很难判断失效传播路径。

8.2 比较 Dockerfile 和上下文

首先检查:

git diff -- Dockerfile .dockerignore
git status --short

然后确认上下文中实际包含哪些文件:

find . -maxdepth 2 -type f -not -path './.git/*' | sort

常见问题:

  • 生成文件偶然进入上下文;
  • dist/ 没有写入 .dockerignore
  • 构建脚本修改了被 COPY . . 复制的文件;
  • CI 在构建前生成了版本文件;
  • 本地和 CI 的构建上下文目录不一致。

8.3 检查基础镜像和平台

docker buildx inspect --bootstrap
docker buildx imagetools inspect node:22-bookworm-slim

FROM node:22-bookworm-slim 不是单一文件,而是可能对应多平台镜像索引。--platform linux/amd64--platform linux/arm64 使用的基础镜像内容不同,编译产物也不能随意共享。

如果多平台构建共用远程缓存,应让 BuildKit 根据平台区分结果,或者为不同平台使用独立缓存引用:

--cache-to type=registry,ref=...:buildcache-amd64,mode=max

平台不一致是“开发机命中、CI 不命中”或“amd64 产物被误用于 arm64”的重要来源。

8.4 检查构建参数和环境

以下写法会扩大失效范围:

ARG GIT_COMMIT
RUN echo "$GIT_COMMIT" > /build-info.txt

如果 CI 每次都传入不同的 GIT_COMMIT,该 RUN 及其后续步骤都会变化。

更细粒度的方式是把版本信息放在靠后的步骤:

FROM node:22-bookworm-slim AS build

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

ARG GIT_COMMIT
RUN printf '%s\n' "$GIT_COMMIT" > /app/dist/BUILD_COMMIT

这样提交号变化不会迫使依赖安装重新执行。

但如果版本信息会影响编译逻辑,则必须放在编译步骤之前,不能为了缓存而把真实输入延后。

8.5 区分缓存命中与命令内部缓存

下面的日志可能同时出现两种情况:

CACHED [build 3/6] RUN npm ci
[build 5/6] RUN npm run build
...
npm WARN cache miss

第一种是 BuildKit 直接复用 RUN npm ci 的结果;第二种是 npm run build 已重新执行,但其 Cache mount 内部没有可用数据。

反过来也可能出现:

[build 3/6] RUN npm ci
npm http fetch GET 200 ...

这表示 Layer 缓存没有命中,但 npm 的下载缓存可能仍然减少了实际网络传输。

因此诊断时要分别回答:

  1. 这条 RUN 是否被 BuildKit 跳过?
  2. 如果重新执行,工具自己的缓存是否被复用?
  3. Cache mount 是否仍存在于当前构建器?
  4. 当前构建器是否与上次构建使用同一个缓存后端?

8.6 用干净构建验证真正的输入

可以创建临时构建器:

docker buildx create \
  --name clean-check \
  --driver docker-container \
  --use \
  --bootstrap

docker buildx build \
  --builder clean-check \
  --progress=plain \
  --no-cache \
  -t myapp:clean-check \
  .

这个测试可以发现隐藏依赖,例如:

  • 命令依赖宿主机已有目录;
  • Cache mount 中残留了必要文件;
  • 构建脚本依赖未声明的环境变量;
  • 构建器本地存在某个未被 Dockerfile 复制的工具;
  • Secret 改变后结果仍来自旧缓存。

完成后可以删除临时构建器:

docker buildx rm clean-check

8.7 查看和清理缓存

查看缓存占用:

docker buildx du --verbose

清理未使用缓存:

docker buildx prune

强制清理更多内容:

docker buildx prune --all

清理会破坏后续构建的缓存命中,并可能删除 Cache mount 中的包下载和编译数据。它不会自动修复 Dockerfile 的缓存设计,只会让下一次构建从更干净的状态开始。

在自动化环境中,清理应配合验证:

docker buildx prune --all --force
docker buildx build --progress=plain -t myapp:after-prune .

如果清理后构建失败,说明构建依赖了未显式声明的缓存状态,或者工具的默认行为没有正确处理冷启动。


九、常见缓存误解和反例

9.1 误解:加了 Cache mount,RUN 就不会重新执行

反例:

COPY . .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

修改任意被 COPY . . 纳入的文件后,RUN 仍可能重新执行。Cache mount 只让 pip 复用下载文件,不会让前一个 COPY 的缓存键保持不变。

9.2 误解:远程缓存等于远程镜像

远程缓存不是一个可直接运行的镜像,也不一定包含最终镜像标签。它是供 BuildKit 查询和导入的缓存数据。

因此:

docker pull registry.example.com/team/myapp:buildcache

未必有意义,甚至可能失败。应通过 --cache-from type=registry,... 让 BuildKit 使用它。

9.3 误解:--no-cache 会清空所有 Cache mount

--no-cache 的主要语义是构建步骤不使用已有执行缓存。它不等价于删除构建器磁盘上的所有 Cache mount 数据。

如果要验证 Cache mount 冷启动,需要清理构建器缓存或使用全新的构建器。否则即使 Layer 不命中,包管理器仍可能从旧 Cache mount 中读取数据。

9.4 误解:改变 Secret 会自动刷新依赖

例如:

RUN --mount=type=secret,id=token \
    curl -H "Authorization: Bearer $(cat /run/secrets/token)" \
         https://example.com/artifact \
      -o /opt/artifact

如果 Secret 所代表的远端资源变化,但 Dockerfile 的其他输入未变化,不能假设该 RUN 必然重新执行。更安全的方案是:

  • 对下载内容使用固定版本和校验值;
  • 在外部流程中显式使用 --no-cache
  • 使用非敏感的版本参数作为缓存失效输入;
  • 下载后验证 SHA-256。

例如:

ARG ARTIFACT_VERSION=1.4.2
ARG ARTIFACT_SHA256

RUN --mount=type=secret,id=token \
    curl -fsSL \
      -H "Authorization: Bearer $(cat /run/secrets/token)" \
      "https://example.com/artifact/${ARTIFACT_VERSION}" \
      -o /tmp/artifact \
 && echo "${ARTIFACT_SHA256}  /tmp/artifact" | sha256sum -c -

这里版本和哈希是可审计的构建输入,Token 仍只通过 Secret 提供。

9.5 误解:删除文件就不会进入镜像层

RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/tool.tar.gz \
 && tar -xzf /tmp/tool.tar.gz -C /usr/local \
 && rm /tmp/tool.tar.gz

最终文件系统中确实没有 /tmp/tool.tar.gz,但在同一个 RUN 内删除后,通常不会把它保留为该层的最终文件。若下载和删除跨越不同层:

RUN curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz
RUN rm /tmp/tool.tar.gz

文件可能仍存在于前一个镜像层的数据中,只是被后续层标记删除。这样既不能有效减小镜像存储,也可能留下敏感内容。

Cache mount 可以用于下载临时数据,避免把下载缓存写入镜像层:

RUN --mount=type=cache,target=/var/cache/download \
    curl -fsSL https://example.com/tool.tar.gz \
      -o /var/cache/download/tool.tar.gz \
 && tar -xzf /var/cache/download/tool.tar.gz -C /usr/local

前提是下载文件本身不应作为最终镜像内容,且每次使用时应验证版本或校验值。


十、缓存设计与可重复构建之间的取舍

缓存追求复用,重复构建追求明确输入。两者并不矛盾,但需要把外部状态显式化。

10.1 用锁文件固定依赖集合

推荐:

COPY package.json package-lock.json ./
RUN npm ci

不推荐:

COPY package.json ./
RUN npm install

前者让依赖解析受到锁文件约束,后者可能根据当前 Registry 状态生成不同的依赖树。

Python、Go、Rust 等生态也有类似原则:

  • Python 使用固定版本的 requirements lock 或锁定工具;
  • Go 提交 go.modgo.sum
  • Rust 提交 Cargo.lock,并固定工具链;
  • 系统包使用明确版本或经过验证的仓库快照。

10.2 缓存命中不是供应链验证

即使 Layer 命中,也只能说明 BuildKit 认为输入没有变化。它不能证明:

  • 基础镜像没有被重新解释;
  • 远端依赖没有被替换;
  • 构建产物符合安全策略;
  • 依赖没有已知漏洞;
  • 生成过程没有依赖隐藏状态。

生产流程应将缓存复用与校验分开:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt \
 && python -m pip check

对下载的二进制或归档文件,应验证固定摘要:

ARG TOOL_SHA256

RUN --mount=type=cache,target=/var/cache/download \
    curl -fsSL https://example.com/tool.tar.gz \
      -o /var/cache/download/tool.tar.gz \
 && echo "${TOOL_SHA256}  /var/cache/download/tool.tar.gz" | sha256sum -c -

10.3 构建缓存可以丢失,构建结果不能依赖缓存存在

一个健壮的 Dockerfile 应满足:

Cache presentfaster build\text{Cache present} \Rightarrow \text{faster build}

但不能依赖:

Cache presentbuild correctness\text{Cache present} \Rightarrow \text{build correctness}

也就是说,缓存存在时可以更快;缓存被删除、迁移到另一台机器或初始为空时,构建仍应成功并得到同样的逻辑结果。


十一、一个可直接运行的完整示例

目录结构:

cache-example/
├── Dockerfile
├── go.mod
├── go.sum
└── cmd/
    └── server/
        └── main.go

Dockerfile

# syntax=docker/dockerfile:1

FROM golang:1.23-bookworm AS build

WORKDIR /src

COPY go.mod go.sum ./

RUN --mount=type=cache,id=cache-example-mod,target=/go/pkg/mod,sharing=locked \
    --mount=type=cache,id=cache-example-build,target=/root/.cache/go-build,sharing=locked \
    go mod download

COPY . .

RUN --mount=type=cache,id=cache-example-mod,target=/go/pkg/mod,sharing=locked \
    --mount=type=cache,id=cache-example-build,target=/root/.cache/go-build,sharing=locked \
    CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w" \
      -o /out/server \
      ./cmd/server

FROM gcr.io/distroless/static-debian12

COPY --from=build /out/server /server

USER nonroot:nonroot
ENTRYPOINT ["/server"]

首次构建:

docker buildx build \
  --progress=plain \
  --load \
  -t cache-example:dev \
  .

前置条件:

  • Docker Engine 支持 Buildx 和 BuildKit;
  • 当前平台能够构建 linux/amd64 或默认目标平台;
  • go.modgo.sum 内容有效;
  • 使用 --load 时,当前 Buildx 驱动能够把结果加载到本地 Docker 镜像存储。

修改 cmd/server/main.go 后再次构建:

docker buildx build \
  --progress=plain \
  --load \
  -t cache-example:dev \
  .

预期行为:

  1. COPY go.mod go.sum ./ 通常命中;
  2. go mod download 通常命中;
  3. COPY . . 失效;
  4. go build 重新执行;
  5. Go 的模块缓存和编译缓存仍可能命中。

导出到 Registry:

docker buildx build \
  --platform linux/amd64 \
  --progress=plain \
  -t registry.example.com/team/cache-example:latest \
  --cache-from type=registry,ref=registry.example.com/team/cache-example:buildcache-amd64 \
  --cache-to type=registry,ref=registry.example.com/team/cache-example:buildcache-amd64,mode=max \
  --push \
  .

在新的 CI Runner 上执行相同命令时,第一次仍可能需要下载基础镜像和导入缓存,但已完成的中间步骤可以从 Registry cache 复用。若构建器是全新的,Cache mount 不应被假定为已经存在;Go 命令必须能够在空缓存目录上正常工作。


十二、诊断结论应如何建立

面对“构建突然变慢”或“缓存似乎没有生效”,可以按以下因果链建立结论:

  1. 先确认使用的是哪个构建器

    docker buildx ls
    docker buildx inspect --bootstrap
    
  2. 确认是 Layer/执行缓存未命中,还是 Cache mount 未命中

    docker buildx build --progress=plain ...
    
  3. 定位第一个失效步骤
    检查它之前的 COPYARGFROM、平台和 Dockerfile 变化。

  4. 确认上下文是否意外变化
    检查 .dockerignore、生成文件、构建目录和 CI checkout 行为。

  5. 确认远程缓存是否真的被导入
    检查 Registry 权限、缓存引用、平台和导出模式。

  6. 用干净构建验证隐式依赖
    使用新建构建器或清理缓存,确认空 Cache mount 下构建仍正确。

  7. 最后才决定是否清理或扩大缓存
    清理缓存能缓解磁盘问题,但不能修复错误的输入建模;扩大远程缓存能提高复用率,但会增加存储、推送时间和清理复杂度。

最重要的判断标准不是“缓存越多越好”,而是:

每个会影响结果的输入都应被显式表达;每个可再生的中间数据都可以放入 Cache mount;每个需要跨构建器复用的 BuildKit 结果都应导出到合适的远程缓存后端;缓存被删除后,构建仍必须能够从零完成。

这一区分建立起来后,Layer、Cache mount 和远程缓存就不再是三个模糊的“加速开关”,而是构建图中具有不同状态、边界和故障路径的三个独立机制。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。