4995 字
25 分钟
BuildKit 与构建缓存进阶
2026-11-15

在 《Dockerfile 入门:把自己的应用打包成镜像》 里,我们已经用「先 COPY 依赖清单、再 COPY 源码」的办法吃到了第一口缓存红利:改业务代码时不用重装依赖。但真实项目里,构建慢的大头往往不在源码层,而在依赖本身——apt-get install 每次都要重新下载几百 MB 的包,pip install 在 CI 上一遍遍拉同样的 wheel,换一台机器构建更是从零开始。这一篇就把构建这件事讲透:主角是 Docker 现在的默认构建引擎 BuildKit,内容包括它的并行构建模型、RUN 缓存挂载、构建密钥挂载、注册表远程缓存,最后是缓存的管理与排障。读完之后,你应该能把一个「每次构建 8 分钟」的项目压到 1 分钟以内,而且在 CI 和同事的机器上同样快。

BuildKit 是什么:默认引擎早已换代#

BuildKit 是 Docker 官方的第二代构建引擎,从 Docker 18.09 起就可以通过环境变量开启,到 Docker 23.0 起,docker build 已经默认走 BuildKit,旧构建器(legacy builder)被标记为弃用。所以只要你的 Docker 不是太老,你其实一直在用 BuildKit,只是可能还没用上它的进阶能力。

它和旧构建器最核心的区别有三点:

对比项旧构建器(legacy)BuildKit
执行模型严格按 Dockerfile 从上到下逐条执行先把 Dockerfile 解析成依赖图(DAG),无依赖关系的步骤并行执行
多阶段构建每个阶段都会完整构建,哪怕最后没用到只构建最终镜像真正依赖的阶段和层,无用阶段直接跳过
构建输出一条条滚动日志,出错难定位树状进度视图,哪个阶段在跑、哪个在等缓存一目了然
扩展能力无缓存挂载、密钥挂载、SSH 挂载、远程缓存、多平台构建
前端语法跟随 Docker 版本可用 # syntax= 指令单独指定 Dockerfile 前端版本

并行执行带来的收益在多阶段构建里最明显:一个阶段在编译 Go 程序、另一个阶段在拉 Node 依赖,两条线同时跑,互不等待。旧构建器只能排队。

切换进度输出:排查构建问题先开 plain#

BuildKit 默认的进度输出是给人看的交互式视图(tty 模式),在 CI 日志里会变成一堆刷新控制符,想看完整的每步日志时反而碍事。两种改法:

Terminal window
# 单次构建:命令行参数
docker build --progress=plain -t myapp .
# 长期生效:环境变量
export BUILDKIT_PROGRESS=plain
docker build -t myapp .

--progress 可选值有 auto(默认)、tty、plain。构建排障时一律先切 plain,每条指令的完整输出都会顺序打出来,哪一步失败、失败前执行了什么,一清二楚。

Dockerfile 第一行:# syntax 指令#

从这一篇开始,所有 Dockerfile 的第一行都建议这样写:

# syntax=docker/dockerfile:1
FROM python:3.12-slim
# ...

这一行不是注释摆设,它的作用是指定解析这份 Dockerfile 的前端(frontend)版本。BuildKit 会按需拉取 docker/dockerfile:1 这个前端镜像来解析语法,好处是:新语法(比如缓存挂载、密钥挂载)由前端版本决定,不必为了用一个新特性去升级整台机器的 Docker;同一份 Dockerfile 在不同机器上构建行为一致。注意它必须写在文件最前面,前面只能有解析器指令和空行,写到中间就只是普通注释了。

RUN 缓存挂载:让依赖下载只发生一次#

这是 BuildKit 最实用的特性,没有之一。先说清它和普通层缓存的区别:

  • 层缓存:指令和输入都没变时,整层直接复用。但只要这一层需要重新执行(比如依赖清单变了),层里的一切都要重来——pip install 就得把所有包重新下载一遍。
  • 缓存挂载(cache mount):给 RUN 指令挂一个持久化目录,这个目录不属于任何镜像层,构建结束后保留在 BuildKit 的缓存区里,下次构建同一条 RUN 时原样挂回来。依赖清单只新增了一个包时,包管理器只需要下载那一个,其余全从缓存目录里现成拿。

一句话概括:层缓存省的是「整层重做」,缓存挂载省的是「重做时还要重复下载」,两者叠加才是完整的提速。

实战一:给 pip 挂缓存#

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]

pip 默认会把下载过的 wheel 存进 /root/.cache/pip,下次装同样的包直接用本地缓存、不走网络——但普通构建里这一层一旦失效,缓存目录就随层一起没了。加上 --mount=type=cache,target=/root/.cache/pip 之后,这个目录由 BuildKit 在镜像之外单独保管:

  • 第一次构建:正常下载,wheel 落进缓存挂载;
  • 改了源码重新构建:pip install 这一层可能因为上下文变化重跑,但包全在缓存里,秒过;
  • 往 requirements.txt 里加一个新包:只有新包需要下载,其余命中缓存。

实战二:给 apt 挂缓存(有个坑要先填)#

# syntax=docker/dockerfile:1
FROM debian:bookworm-slim
RUN rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates

先解释第一行那个坑:Debian / Ubuntu 官方镜像里有个 apt 配置 docker-clean,它让 apt 在每次安装结束后自动删掉下载的 .deb 缓存包——这是为了减小镜像体积,但正好把我们想保留的缓存清光了。所以用缓存挂载前必须先删掉它。然后正式挂载:

# syntax=docker/dockerfile:1
FROM debian:bookworm-slim
RUN rm -f /etc/apt/apt.conf.d/docker-clean
RUN --mount=type=cache,target=/var/cache/apt/archives \
--mount=type=cache,target=/var/lib/apt/lists \
apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates

两个目录都要挂:/var/cache/apt/archives 存下载的 .deb 包,/var/lib/apt/lists 存 apt-get update 拉来的软件包索引,少挂哪个都会多走一遍网络。

WARNING

删掉 docker-clean 并挂载缓存目录后,.deb 包不会再被清掉——但它们在缓存挂载里,不会进入最终镜像层,镜像体积不受影响。这正是缓存挂载和「把缓存 COPY 进镜像」的区别,别担心镜像变大。

cache 挂载的常用参数#

参数作用说明
target容器内的挂载路径必填,如 target=/root/.cache/pip
id缓存的标识名不写时默认用 target 路径生成;多个 Dockerfile 想共享同一份缓存时,显式写相同的 id
sharing并发共享策略shared(默认,多构建可同时读)/ locked(同一时间只允许一个构建写)/ private(每个构建独立一份)
mode、uid、gid权限设置一般用不到,缓存目录权限不对导致写入失败时再考虑

id 的典型用法:项目里有两份 Dockerfile(应用一份、定时任务一份),都装同一批系统包,给两边的 apt 缓存写上相同的 id=apt-cache,就能共用一份缓存,而不是各存一份。

再补一个并发的坑:apt 和 dpkg 本身对缓存目录有文件锁,多个构建同时写同一份缓存时偶尔会撞锁报错。遇到这种情况,把对应挂载改成 sharing=locked,让构建排队独占写入,慢一点但稳:

RUN --mount=type=cache,target=/var/cache/apt/archives,sharing=locked \
apt-get update && apt-get install -y curl

其他两种挂载:bind 与 tmpfs#

--mount 不只有 cache,顺带把另外两个常用的讲清:

bind 挂载:构建时把构建上下文(或某个构建阶段)里的文件临时挂进 RUN 的环境,用完即走,不产生层。

# 从名为 build 的阶段里,只取编译产物做校验,不把整个阶段拷进来
RUN --mount=type=bind,from=build,source=/out/app,target=/tmp/app \
/tmp/app --version

最常见的场景是多阶段构建中「借用」前一阶段的文件执行某个命令(跑一次测试、生成校验和),比先 COPY --from 落一层再删掉干净得多。

tmpfs 挂载:挂一块内存盘,构建结束即消失,适合放编译中间文件:

RUN --mount=type=tmpfs,target=/tmp/build \
make -C /src BUILD_DIR=/tmp/build

中间产物既不进镜像层、也不进缓存区,纯粹借内存加速 IO。日常用得不多,知道有这个选项即可。

构建密钥:secret 挂载,别再把密码写进 Dockerfile#

构建时经常需要凭据:拉私有 Git 仓库、从私有 PyPI / npm 源装包、调需要 Token 的 API。老办法是写进 ARG 或 ENV:

# 反面教材,千万别这么干
ARG GITHUB_TOKEN
RUN git clone https://oauth2:${GITHUB_TOKEN}@github.com/myorg/private-repo.git

这为什么危险,在 《Docker 安全与资源限制实战》 里讲过一遍,这里再钉死结论:写进 ARG / ENV 的密钥会留在镜像元数据里,任何人拿到镜像用 docker history 和 inspect 就能读出来,等于把密码印在包裹上寄出去。即使你在一个 RUN 里下载完又删掉文件也没用——层和构建参数的历史记录还在。

BuildKit 的正解是 secret 挂载:密钥只在某一条 RUN 执行期间以文件形式出现在 /run/secrets/ 下,不进入任何层、不进入镜像历史。

Dockerfile 侧:

# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN --mount=type=secret,id=github_token \
TOKEN=$(cat /run/secrets/github_token) \
&& git clone https://oauth2:${TOKEN}@github.com/myorg/private-repo.git /src

构建时把密钥喂进去,两种喂法:

Terminal window
# 喂法一:从环境变量取
export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
docker build --secret id=github_token,env=GITHUB_TOKEN -t myapp .
# 喂法二:从文件取
docker build --secret id=github_token,src=./token.txt -t myapp .

注意两边的 id 必须对上:Dockerfile 里 id=github_token,构建命令里也得是 id=github_token。构建结束后可以验证一下镜像里确实没有它:

Terminal window
docker history myapp # 看不到密钥
docker inspect myapp # Config 里也没有

另外还有个同类需求是拉仓库不想用 Token、想直接用本机的 SSH 密钥,BuildKit 提供了 --mount=type=ssh 配合 docker build --ssh default,通过 SSH agent 转发凭据,密钥本体连文件都不落,原理和 secret 挂载一样都是「只借不给」。

远程缓存:让 CI 和同事的机器也命中缓存#

本地缓存再快,也只在你这台机器上。换台电脑、换个 CI Runner,缓存全空,又得从头下载。BuildKit 支持把缓存导出到镜像仓库,任何能拉到那个仓库的地方都能导入复用,这叫远程缓存(remote cache)。

WARNING

远程缓存的导入导出需要用 docker buildx build 命令(本机单机构建时它和 docker build 等价),并且构建器要能把缓存推到仓库,记得先 docker login。另外 千万不要把缓存推到公共仓库还当成私有资产,缓存里可能含有构建过程中的中间文件,按和镜像同等的可见性对待。

两种导出方式的区别先看表:

对比项type=inlinetype=registry
缓存存在哪嵌进构建出的镜像本身里推成一个单独的缓存镜像(用一个专用 tag)
导出参数--cache-to type=inline--cache-to type=registry,ref=仓库/镜像:缓存tag
导入参数--cache-from type=registry,ref=那个镜像--cache-from type=registry,ref=仓库/镜像:缓存tag
缓存范围只有最终镜像包含的层可选 mode=min(仅最终层)或 mode=max(所有阶段的所有层)
适合场景简单项目、只想让别人拉镜像时顺带复用多阶段构建、CI 流水线,正经用法选这个

完整命令(以 registry 方式、mode=max 为例):

Terminal window
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:latest \
--push .

逐段拆解:

  • --cache-from:构建前先去仓库把 buildcache 这个 tag 对应的缓存拉下来,本次构建能命中的层和缓存挂载直接复用;
  • --cache-to ... mode=max:构建后把缓存推回去。mode=max 会把所有构建阶段(包括多阶段里那些不进最终镜像的中间阶段)的缓存都导出,命中率最高,代价是缓存体积大、推送慢;mode=min 只导出最终镜像的层,体积小但中间阶段的缓存下次用不上;
  • -t ... --push:顺手把成品镜像也推上去。注意 buildx 在使用远程缓存等高级功能时,构建结果默认只留在构建器里,要加 --push 推走或 --load 加载到本机 Docker 才能用。

CI 场景的价值就在这里:GitHub Actions 这类 CI 的 Runner 每次都是全新环境,本地缓存指望不上。流水线里加上这两行参数后,第一次构建负责把缓存种到仓库里,之后每次构建先拉缓存再开工,依赖层、编译层大面积命中,构建时间从分钟级掉到几十秒。缓存的 tag 是固定的(buildcache),每次构建覆盖更新,不需要人工维护。

落到 GitHub Actions 里,用官方的 build-push-action 时这两行参数有现成的字段可填,不用手写命令:

- name: 构建并推送镜像
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: registry.example.com/myapp:latest
cache-from: type=registry,ref=registry.example.com/myapp:buildcache
cache-to: type=registry,ref=registry.example.com/myapp:buildcache,mode=max

如果镜像仓库本身就在 GitHub 体系内(比如推 GHCR),还有个更省事的后端 type=gha,缓存直接存在 Actions 的缓存空间里,不占仓库的 tag,两行改成 cache-from: type=gha 和 cache-to: type=gha,mode=max 即可。出了 GitHub 环境就用回 registry 方式,原理完全一样。

还有个细节:--cache-from 可以写多次,从多个来源依次找缓存(比如先找分支专属缓存、再找主干缓存),前面的找不到才看后面的:

Terminal window
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache-feature \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
-t registry.example.com/myapp:latest --load .

多阶段构建下的缓存与 —target#

多阶段构建里,BuildKit 只构建最终镜像依赖的那条链路:某个阶段如果没被后面的 COPY --from 或最终阶段引用,整个阶段会被跳过,连带它的缓存也不用操心。当你只想验证某个中间阶段(比如只想看看编译阶段能不能过)时,用 --target 指定构建到哪个阶段为止:

Terminal window
# 只构建到名为 builder 的阶段,不再往下走
docker buildx build --target builder -t myapp:builder-check .

这在排查「到底是编译挂了还是打包挂了」时特别好用:先单独把 builder 阶段构建出来,进容器看产物,再决定下一步查哪。

反过来也要知道:--target 构建出的镜像只是那个阶段的状态,别把中间阶段的镜像当成品推上线,里面往往带着编译器、源码和调试工具,又大又不安全。

缓存管理:别让缓存把磁盘吃光#

缓存是拿磁盘换时间,攒久了也会变成负担——几个 GB 起步,项目多了几十 GB 也常见。先看占用:

Terminal window
# 看构建缓存总占用和明细
docker builder du
# 更细的逐条缓存记录(buildx 版本)
docker buildx du

清理命令:

Terminal window
# 清理 30 天没用过的构建缓存
docker builder prune --filter "until=720h"
# 保留最近 20GB,超出的按最久未用先清
docker buildx prune --keep-storage 20GB
# 全部清掉(包括缓存挂载里的内容),慎用
docker builder prune --all
WARNING

docker builder prune --all 会把缓存挂载(type=cache)里攒的依赖包也一并清掉,下一次构建所有依赖重新下载,慢的那一遍又得重来。日常清理优先用 until 或 keep-storage 做「按量保留」,不要把 prune —all 写进定时任务无脑跑。

还有一个容易混淆的点:构建缓存和镜像、容器不在一个账本里。docker system df 能看到 Build Cache 一栏的占用,但平时清垃圾用的 docker image prune 不会动构建缓存,磁盘满了查到最后发现是构建缓存的锅,是很常见的剧情。

排障:缓存为什么没命中#

缓存没命中时先别急着加 --no-cache 硬刚,按下面顺序排查,大多是前三个原因:

1. 指令顺序不合理。 层缓存的规则是「一层变了,它后面的所有层全部作废」。把变化最频繁的东西(源码)放在最前面,后面的依赖安装层就永远命不中。原则在入门篇讲过:按变化频率从低到高排列指令——基础镜像、系统包、依赖清单、依赖安装、源码,越靠后变得越勤。

2. COPY 的范围太大。 一句 COPY . . 会把构建上下文里的每个文件都算进这一层的指纹,改任何一个文件(哪怕是 README)都会让这层失效。处理办法:先精确 COPY 依赖清单装依赖,最后才 COPY 源码;同时写好 .dockerignore,把 .git、node_modules、日志、临时文件排除在上下文之外——上下文内容变少,指纹才稳定。

3. 构建参数或基础镜像变了。 ARG 的值一变,从声明它的那层起缓存全部失效;基础镜像被上游更新后,本地还是旧的也会造成「以为命中其实重拉」。这就引出两个参数的正确用法区别:

参数作用什么时候用
--no-cache本次构建完全忽略层缓存,所有指令强制重跑怀疑缓存坏了、要验证「从零构建能不能过」时偶尔用一次
--pull构建前先检查并拉取更新的基础镜像想让基础镜像保持最新时用;基础镜像没更新时不影响后续缓存

两者别混用成日常习惯:每次构建都加 —no-cache 等于亲手关掉全部缓存,构建慢的毛病只会更重。还有个细节:--no-cache 只跳过层缓存,type=cache 挂载里的内容依然会被复用,所以「加了 —no-cache 依赖还是秒装完」不是缓存没关干净,而是缓存挂载在起作用——真要连它一起清,用前面说的 docker builder prune。

4. 换了机器或构建器。 本地缓存只属于本机这个构建器,换电脑、重装 Docker、CI 换 Runner 都会清零,这正是上一节远程缓存要解决的问题。如果已经配了远程缓存还命不中,检查 --cache-from 的 ref 地址和 tag 写得对不对、构建器有没有拉取权限,构建日志开头会明确打出缓存导入成功还是失败。

5. 同一条 RUN 写法变了。 层缓存对指令文本敏感,把 apt-get install curl 改成 apt-get install -y curl 这种「等价改写」也会被判定为新指令而重跑。无意义的格式抖动尽量避免,Dockerfile 定稿后不要频繁动老指令的写法。

6. 构建器不是同一个。 缓存是按「构建器」隔离存放的。用 docker buildx ls 能看到当前有哪些构建器:默认的 default 构建器用的是 Docker 内置 BuildKit,而自己 docker buildx create 出来的 docker-container 驱动构建器跑在一个独立容器里,两边的本地缓存互不相通。今天用 A 构建器构建、明天切到 B,缓存就像凭空消失了一样。团队里要把用哪个构建器说死,CI 里更要固定,别让缓存毁在这种低级不一致上。

小结#

  • BuildKit 是 Docker 23+ 的默认构建引擎,核心优势是把 Dockerfile 当依赖图并行执行、无用阶段跳过,排障时先开 --progress=plain 看全量日志;
  • Dockerfile 第一行写 # syntax=docker/dockerfile:1,让语法版本独立于 Docker 版本;
  • 提速的大头是 RUN 缓存挂载:给 pip 挂 /root/.cache/pip、给 apt 挂 archives 与 lists 两个目录(先删 docker-clean),依赖下载只发生一次;
  • 构建要凭据用 secret 挂载,密钥只在构建瞬间以文件出现,绝不写进 ARG / ENV;
  • 跨机器复用缓存靠远程缓存,正经项目用 type=registry 加 mode=max,CI 场景收益最大;
  • 缓存要用也要管:docker builder du 看占用,按 until / keep-storage 温和清理;
  • 缓存没命中先查指令顺序、COPY 范围和上下文,别把 --no-cache 当日常开关。

下一篇《多平台镜像构建实战》会接着用 buildx,把同一份 Dockerfile 一次构建出 amd64 和 arm64 两套镜像,缓存这篇打好的底子到时候会直接派上用场。

BuildKit 与构建缓存进阶
https://blog.anexns.com/posts/docker-buildkit-cache/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0