在 《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 日志里会变成一堆刷新控制符,想看完整的每步日志时反而碍事。两种改法:
# 单次构建:命令行参数docker build --progress=plain -t myapp .
# 长期生效:环境变量export BUILDKIT_PROGRESS=plaindocker build -t myapp .--progress 可选值有 auto(默认)、tty、plain。构建排障时一律先切 plain,每条指令的完整输出都会顺序打出来,哪一步失败、失败前执行了什么,一清二楚。
Dockerfile 第一行:# syntax 指令
从这一篇开始,所有 Dockerfile 的第一行都建议这样写:
# syntax=docker/dockerfile:1FROM 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:1FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txtCOPY . .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:1FROM debian:bookworm-slimRUN 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:1FROM debian:bookworm-slimRUN rm -f /etc/apt/apt.conf.d/docker-cleanRUN --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_TOKENRUN 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:1FROM alpine:3.20RUN --mount=type=secret,id=github_token \ TOKEN=$(cat /run/secrets/github_token) \ && git clone https://oauth2:${TOKEN}@github.com/myorg/private-repo.git /src构建时把密钥喂进去,两种喂法:
# 喂法一:从环境变量取export GITHUB_TOKEN=ghp_xxxxxxxxxxxxdocker 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。构建结束后可以验证一下镜像里确实没有它:
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=inline | type=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 为例):
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 可以写多次,从多个来源依次找缓存(比如先找分支专属缓存、再找主干缓存),前面的找不到才看后面的:
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 指定构建到哪个阶段为止:
# 只构建到名为 builder 的阶段,不再往下走docker buildx build --target builder -t myapp:builder-check .这在排查「到底是编译挂了还是打包挂了」时特别好用:先单独把 builder 阶段构建出来,进容器看产物,再决定下一步查哪。
反过来也要知道:--target 构建出的镜像只是那个阶段的状态,别把中间阶段的镜像当成品推上线,里面往往带着编译器、源码和调试工具,又大又不安全。
缓存管理:别让缓存把磁盘吃光
缓存是拿磁盘换时间,攒久了也会变成负担——几个 GB 起步,项目多了几十 GB 也常见。先看占用:
# 看构建缓存总占用和明细docker builder du
# 更细的逐条缓存记录(buildx 版本)docker buildx du清理命令:
# 清理 30 天没用过的构建缓存docker builder prune --filter "until=720h"
# 保留最近 20GB,超出的按最久未用先清docker buildx prune --keep-storage 20GB
# 全部清掉(包括缓存挂载里的内容),慎用docker builder prune --allWARNING
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 两套镜像,缓存这篇打好的底子到时候会直接派上用场。