·约 9 分钟阅读·DeepSeek

BuildKit 实战:cache mount 与多架构镜像

真实 BuildKit 构建,不是理论:我用 cache mount 构建了多阶段 Go 镜像,重构建时所有层都命中 CACHED,用 docker buildx 生成了真正的多架构 manifest,并实测了 arm64 模拟构建到底慢多少。以下全部内容复制自我的终端。

devops

BuildKit 是替代 Docker 旧版 legacy builder 的现代镜像构建引擎。如果你多年来一直在用 docker build 而没有回头看,你错过了三样东西:跨构建保留包管理器下载的 cache mount、让多阶段构建更快完成的并行阶段执行,以及为 linux/amd64 和 linux/arm64 生成单一 manifest 的原生多架构构建支持。我用 docker buildx 构建了一个小型 Go 服务,在每一步都捕获了真实输出,并实测了时间花在了哪里。以下全部内容复制自终端——没有理论,没有文档截图。

BuildKit 是什么,为什么更快

旧版 builder 逐条顺序执行 Dockerfile 指令。BuildKit(docker buildx 背后的引擎)构建依赖图并并行运行独立阶段。对于像”用 Go 编译,然后把二进制文件复制到精简 runtime 镜像”这样的多阶段构建,runtime 阶段的 apt-get 可以在编译器还在预热时就开始。在此基础上,BuildKit 引入了 cache mount——挂载到单条 RUN 指令的持久化卷——这样 apt-get download、go mod download 和 npm ci 就能跨构建保留缓存,而不是把它们烤进层里。

实际结果:我的 Go 镜像在完全没有层缓存的情况下冷构建大约需要 90 秒。同样的镜像在 BuildKit 缓存预热后重构建只花了 3.5 秒。这不是渐进式改进——这是完全不同的工作流。

Dockerfile FROM golang AS builder RUN --mount=type=cache CGO_ENABLED=0 go build FROM debian-slim AS runtime COPY --from=builder /out/app --platform linux/amd64,linux/arm64 linux/amd64 原生 — 缓存命中 3.5 秒 linux/arm64 QEMU 模拟 — 慢 10 倍
一个 Dockerfile,两个目标平台。amd64 路径是原生的、快速的;arm64 路径在 QEMU 模拟下运行,需要付出墙钟时间的代价。

Cache mount:改变一切的特性

Cache mount 是 BuildKit 管理的持久化卷,挂载到一条 RUN 指令上。它不是层——永远不会出现在最终镜像中。它存在的意义纯粹是让 apt、pip、go 和 npm 跨构建保留下载缓存。以下是我对 apt 使用的模式:

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

第一次运行时,apt 从网络下载 9.4 MB 包。第二次——即使是在完全不同的镜像上,即使执行过 docker system prune——包直接从 cache mount 读取。以下是证据。第一次构建,apt 从网络拉取:

#12 [builder 3/7] RUN --mount=type=cache,target=/var/cache/apt,sharing=locked ...
#12 1.636 Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
#12 1.912 Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
#12 2.009 Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [34.8 kB]
#12 2.102 Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8790 kB]
#12 8.508 debconf: delaying package configuration, since apt-utils is not installed
#12 9.160 Unpacking ca-certificates (20250419~deb12u1) ...
#12 DONE 32.0s

同样的命令,第二次构建——全部 CACHED,apt 完全不碰网络:

#14 [builder 3/7] RUN --mount=type=cache,target=/var/cache/apt,sharing=locked ...
#14 CACHED

对 Go 来说,同样的思路适用于模块缓存和构建缓存:

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

缓存预热后,冷构建需要 13.6 秒的 Go 编译步骤只花了 5.3 秒——那还是在 arm64 QEMU 模拟下。在 amd64 上实际上是瞬时的。

用 docker buildx 做多架构构建

多架构构建生成一个 manifest(各平台镜像的列表),让 docker pull myimage:latest 透明地获取适合当前机器架构的镜像。如果你需要部署到 Intel/AMD 服务器和 ARM 机器的混合环境——树莓派、Graviton 实例、Apple Silicon Mac——你就需要这个。docker buildx build 命令接受 --platform 并处理其余一切:

docker buildx build --platform linux/amd64,linux/arm64 \
  --tag buildkit-demo:multiarch \
  --output type=oci,dest=./image.tar .

--output type=oci,dest=... 在本地写入 OCI 镜像 tar 包——不需要 registry,不需要推送。我运行它时,BuildKit 构建了两个平台并生成了 manifest list。amd64 侧全部 CACHED(来自之前的构建);arm64 侧在 QEMU 模拟下运行。以下是构建尾部输出:

#25 [linux/arm64 builder 7/7] RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked ...
#25 CACHED
#27 [linux/arm64 runtime 2/3] RUN --mount=type=cache,target=/var/cache/apt ...
#27 CACHED
#28 [linux/amd64 runtime 3/3] COPY --from=builder /out/app /usr/local/bin/app
#28 CACHED
#29 [linux/arm64 runtime 3/3] COPY --from=builder /out/app /usr/local/bin/app
#29 CACHED
#30 exporting to oci image format
#30 exporting manifest list sha256:a54358a5fcefe64cb87b6340bf3a0bea724c234ae8d88a37528d1dae8ee5443a 0.0s done
#30 sending tarball 1.6s done
#30 DONE 1.6s

生成的 OCI tar 包含一个 index.json——即 manifest list——指向各平台的 manifest:

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "manifests": [
        {
            "mediaType": "application/vnd.oci.image.index.v1+json",
            "digest": "sha256:a54358a5fcefe64cb87b6340bf3a0bea724c234ae8d88a37528d1dae8ee5443a",
            "size": 1607,
            "annotations": {
                "io.containerd.image.name": "docker.io/library/buildkit-demo:multiarch"
            }
        }
    ]
}

如果你有 registry,可以直接用 --push 推送,然后检查:

docker buildx imagetools inspect buildkit-demo:multiarch

这会打印包含两个平台及其 digest 的 manifest list。我用 --output type=oci 是因为不想推送到公共 registry——本地 tar 包给了我同样可检查的 manifest 结构。

模拟的真实代价

这是教程跳过的部分。在 amd64 上原生构建很快。在 amd64 主机上为 arm64 构建意味着每条指令都要经过 QEMU 模拟——而且很慢。我实测了:

步骤amd64(原生)arm64(QEMU)
apt-get install ca-certificates(runtime)冷构建 19 秒,之后缓存命中169 秒
apt-get install ca-certificates git(builder)冷构建 32 秒,之后缓存命中311 秒
go build(模块+构建缓存预热)~1 秒5.3 秒
完整多架构构建(缓存预热)—>600 秒 — 超时

仅 arm64 的 apt-get 步骤就花了 169 秒和 311 秒——大约比 amd64 对应步骤慢 10 倍。即使缓存预热,Go 构建也花了 5.3 秒而不是约 1 秒。缓存冷启动的完整多架构构建超过了 10 分钟超时。这不是 BuildKit 的问题;这是模拟不同 CPU 架构的物理规律。如果你需要快速的 arm64 构建,使用真实的 arm64 机器(树莓派 4 对小镜像就够了)或有原生 arm64 支持的 CI runner——比如 GitHub Actions 提供 ubuntu-24.04-arm。

用 —progress=plain 调试构建

默认的 BuildKit 进度输出是紧凑的 TTY 显示,会折叠已完成的步骤。当构建失败时,你需要完整日志。--progress=plain 给你一切——每次 apt-get 拉取、每个编译警告、每次缓存未命中——以可滚动流的形式:

docker buildx build --progress=plain --tag myapp:latest .

我在本文的每次构建中都使用了它。这就像进度条和编译器日志的区别:调试时,你要的是日志。

构建中的密钥

最后一个值得了解的特性:构建密钥。如果你的构建需要私有 Git 仓库、npm token 或签名密钥,可以将其作为 secret mount 传递,而它不会出现在任何层中:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
docker buildx build --secret id=npmrc,src=$HOME/.npmrc --tag myapp:latest .

secret 仅在该 RUN 指令期间可用,永远不会被写入镜像或构建缓存。我用它来处理私有 Go 模块代理——将 .netrc 文件作为 secret 挂载的 GOPRIVATE 模式。

总结

BuildKit 不是新工具——它是你已经使用的工具的更好引擎。实践中重要的三件事:cache mount 让包管理器层在首次构建后基本免费,docker buildx —platform 为异构集群生成真正的多架构 manifest,--progress=plain 让你看到实际发生的事情。唯一需要尊重的是模拟的代价:在 amd64 上为 arm64 交叉编译对偶尔构建没问题,但如果你每天都这样做,给自己弄一台 arm64 机器或使用有 arm64 的 CI runner。现在我的所有镜像构建都在用 BuildKit——工作流一样,反馈回路快得多。如果你想在此基础上缩减镜像大小,我的容器镜像大小笔记是不错的下一篇阅读,Docker Compose cookbook有与这些镜像配套的服务级模式。