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 缓存命中的抽象条件
可以用一个简化公式表示某个构建操作的缓存键:
其中:
- :第 个构建操作的缓存键;
- :操作本身,例如
COPY、RUN、ENV; - :父状态,即前序步骤产生的文件系统结果;
- :显式输入,例如构建上下文中的文件;
- :挂载配置、工作目录、环境等元数据;
- :相关构建参数、平台和前端信息;
- :工具链或 BuildKit 前端使用的其他输入;
- :内容寻址哈希函数。
当新的构建操作得到相同的有效缓存键,并且当前 BuildKit 实例能够找到对应的缓存记录时,BuildKit 可以直接复用结果,而不重新执行该操作。
这个公式是理解缓存的模型,不是 BuildKit 对外承诺的内部哈希格式。具体缓存记录会随着 BuildKit、Dockerfile 前端和驱动实现变化,但以下因果关系稳定成立:
- 父状态改变,会影响后续操作。
- 被
COPY或ADD纳入的输入文件改变,会影响对应操作。 - 某个
RUN的缓存命中,并不意味着它会重新读取所有外部可变资源。 - 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 会根据文件内容及相关元数据判断输入是否发生变化。文件修改时间通常不会单独导致 ADD 或 COPY 缓存失效;内容、路径、权限等会影响结果。
例如:
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 内容没有变化,COPY 和 RUN 通常会显示缓存命中。
如果只修改 hello.txt:
printf 'changed\n' > hello.txt
docker buildx build --progress=plain -t cache-demo:three .
则:
FROM仍可复用;COPY hello.txt .的输入发生变化,不能复用;- 后续
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
这里的推导是:
npm ci的主要输入是package.json和package-lock.json;- 源代码不参与依赖解析;
- 因此先复制锁文件并安装依赖,可以让源代码变更不影响依赖安装层;
- 最后再复制源代码并执行编译。
但这不是无条件成立的。如果 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
这里有两个不同结果:
pip install安装到系统环境中的 Python 包,会进入该RUN的文件系统结果,可能进入后续镜像层;/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[最终运行时镜像]
第一次构建时:
COPY go.mod go.sum ./产生依赖描述状态;go mod download下载模块,同时填充/go/pkg/mod;COPY . .复制源代码;go build使用模块缓存和编译缓存;- 最终阶段只复制
/out/server。
修改一个 .go 文件后:
go mod download仍可命中 Layer 缓存;go build通常因源代码状态变化而重新执行;- Go module cache 和编译缓存仍可从 Cache mount 复用;
- 最终镜像只包含二进制文件,不包含 Go 工具链和缓存目录。
修改 go.mod 或 go.sum 后:
- 依赖描述的
COPY失效; go mod download重新执行;- 依赖缓存中已有的模块仍可能被复用;
- 后续编译步骤因父状态变化而重新计算。
这里同时使用了三种缓存层次:
- Layer/BuildKit 结果缓存:跳过整个构建步骤;
- Go Cache mount:步骤重新执行时减少模块下载和编译工作;
- 多阶段结果裁剪:不把构建工具和缓存带入最终镜像。
六、远程缓存:让另一个构建器复用结果
本地缓存只存在于某个 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 和问题排查。重点观察:
- 哪个步骤显示缓存命中;
- 哪个步骤第一次重新执行;
- 失效点之前是否有
COPY、ARG、FROM或平台变化; - 重新执行的命令是否访问了网络、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 的下载缓存可能仍然减少了实际网络传输。
因此诊断时要分别回答:
- 这条
RUN是否被 BuildKit 跳过? - 如果重新执行,工具自己的缓存是否被复用?
- Cache mount 是否仍存在于当前构建器?
- 当前构建器是否与上次构建使用同一个缓存后端?
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.mod和go.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-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.mod和go.sum内容有效;- 使用
--load时,当前 Buildx 驱动能够把结果加载到本地 Docker 镜像存储。
修改 cmd/server/main.go 后再次构建:
docker buildx build \
--progress=plain \
--load \
-t cache-example:dev \
.
预期行为:
COPY go.mod go.sum ./通常命中;go mod download通常命中;COPY . .失效;go build重新执行;- 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 命令必须能够在空缓存目录上正常工作。
十二、诊断结论应如何建立
面对“构建突然变慢”或“缓存似乎没有生效”,可以按以下因果链建立结论:
-
先确认使用的是哪个构建器
docker buildx ls docker buildx inspect --bootstrap -
确认是 Layer/执行缓存未命中,还是 Cache mount 未命中
docker buildx build --progress=plain ... -
定位第一个失效步骤
检查它之前的COPY、ARG、FROM、平台和 Dockerfile 变化。 -
确认上下文是否意外变化
检查.dockerignore、生成文件、构建目录和 CI checkout 行为。 -
确认远程缓存是否真的被导入
检查 Registry 权限、缓存引用、平台和导出模式。 -
用干净构建验证隐式依赖
使用新建构建器或清理缓存,确认空 Cache mount 下构建仍正确。 -
最后才决定是否清理或扩大缓存
清理缓存能缓解磁盘问题,但不能修复错误的输入建模;扩大远程缓存能提高复用率,但会增加存储、推送时间和清理复杂度。
最重要的判断标准不是“缓存越多越好”,而是:
每个会影响结果的输入都应被显式表达;每个可再生的中间数据都可以放入 Cache mount;每个需要跨构建器复用的 BuildKit 结果都应导出到合适的远程缓存后端;缓存被删除后,构建仍必须能够从零完成。
这一区分建立起来后,Layer、Cache mount 和远程缓存就不再是三个模糊的“加速开关”,而是构建图中具有不同状态、边界和故障路径的三个独立机制。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏
- 下一篇:Docker Build Secret 与 SSH Mount:凭据注入、缓存和泄漏防护
- 延伸:Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
- 延伸:Docker Buildx Bake:多目标、矩阵、缓存、平台和 CI 编排
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论