当一个仓库里有五六个镜像、每个都要针对两种架构构建、还要分别推送到不同 registry 时,docker buildx build 的参数会迅速膨胀成难以维护的复制粘贴。Bake 的价值就在于把这堆参数收敛进一个声明式文件:一次定义、按目标调用、在 CI 里只构建变更的部分。本篇讲清 Bake 的文件格式、编排语义、变量体系与 CI 落地方式。
1. 从命令行参数到声明式文件
先看 Bake 要解决的原始问题。没有 Bake 时,多目标构建通常写成脚本:
docker buildx build --target api -t reg/api:$TAG --platform linux/amd64,linux/arm64 --push ./api
docker buildx build --target web -t reg/web:$TAG --platform linux/amd64,linux/arm64 --push ./web
docker buildx build --target worker -t reg/worker:$TAG --platform linux/amd64,linux/arm64 --push ./worker
问题有三:平台与 tag 规则重复、缓存配置无法统一、CI 中无法只构建改动的那一个。Bake 把这三点都收进文件。
# 默认按顺序查找这些文件
docker buildx bake --print
# docker-compose.yml / docker-compose.yaml / docker-bake.json
# docker-bake.hcl / docker-bake.override.hcl
调用方式很直接:
docker buildx bake # 构建所有 target
docker buildx bake api # 只构建 api
docker buildx bake api web # 构建多个
docker buildx bake --push # 全局追加 push
docker buildx bake --set api.tags=reg/api:dev # 临时覆盖
1.1 解析优先级
Bake 会合并多个文件,顺序与优先级是:
| 优先级 | 文件 | 说明 |
|---|---|---|
| 低 | docker-compose.yml | 取 services.*.build 作为 target |
| 中 | docker-bake.json | JSON 形式,便于程序生成 |
| 高 | docker-bake.hcl | HCL 形式,支持变量与函数 |
| 最高 | docker-bake.override.hcl | 环境相关覆盖,通常不入库 |
同名 target 后者覆盖前者的字段,未冲突的字段做合并。-f 可显式指定文件并改变顺序:
docker buildx bake -f docker-bake.hcl -f ci.hcl --print
2. HCL 基础:target、group 与变量
HCL 是 Bake 的主力格式。一个最小可用的文件如下:
variable "TAG" {
default = "dev"
}
variable "REGISTRY" {
default = "registry.example.com"
}
group "default" {
targets = ["api", "web"]
}
target "api" {
context = "./api"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/api:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
}
target "web" {
context = "./web"
tags = ["${REGISTRY}/web:${TAG}"]
}
几个关键点:
group "default"决定不带参数执行bake时构建哪些 target;不定义时默认构建全部 target。variable可被环境变量覆盖:环境变量TAG=v1.2.3会自动覆盖variable "TAG",无需额外配置。- 变量插值用
${VAR},字符串拼接直接写在引号里;注意 HCL 的${需要转义时写成$${。
TAG=v1.2.3 docker buildx bake --print
# 输出解析后的完整构建计划,用于确认变量替换结果
2.1 target 的常用字段
| 字段 | 等价 CLI | 说明 |
|---|---|---|
| context | 位置参数 | 构建上下文 |
| dockerfile | -f | Dockerfile 路径 |
| target | –target | 多阶段构建的阶段名 |
| tags | -t | 镜像标签列表 |
| platforms | –platform | 目标平台列表 |
| args | –build-arg | 构建参数 map |
| cache-from | –cache-from | 缓存来源 |
| cache-to | –cache-to | 缓存导出 |
| output | -o | 输出类型(image/local/registry) |
| labels | –label | OCI 标签 |
| secrets | –secret | 构建期密钥 |
| ssh | –ssh | 转发 SSH agent |
字段名与 CLI 的映射关系并非一一对应,docker buildx bake --print 是最权威的对照工具——它输出的 JSON 就是 Bake 实际会传给 BuildKit 的配置。
3. inherits:用继承消除重复
多目标之间大量参数是共享的(平台、registry 前缀、缓存配置)。inherits 让 target 复用另一个 target 的字段:
target "base" {
platforms = ["linux/amd64", "linux/arm64"]
args = {
GO_VERSION = "1.23"
}
cache-from = ["type=registry,ref=${REGISTRY}/cache:build"]
cache-to = ["type=registry,ref=${REGISTRY}/cache:build,mode=max"]
}
target "api" {
inherits = ["base"]
context = "./api"
tags = ["${REGISTRY}/api:${TAG}"]
}
target "worker" {
inherits = ["base"]
context = "./worker"
tags = ["${REGISTRY}/worker:${TAG}"]
}
继承的合并规则:
- 列表类字段(platforms、tags、cache-from):子 target 的列表会追加到父列表之后,而非替换。想替换需显式清空或改用变量控制。
- map 类字段(args、labels):逐 key 合并,同名 key 子级覆盖父级。
- 标量字段(context、dockerfile):子级覆盖父级。
这条「列表追加」规则是最容易踩的坑:父 target 定义了 platforms,子 target 再加一个平台,结果是三个而不是两个。
target "api" {
inherits = ["base"]
platforms = ["linux/amd64"] # 结果仍是 amd64 + arm64 + amd64
}
3.1 用 matrix 批量生成 target
当一个镜像需要按「架构 × 变体」组合构建时,手写 target 会爆炸。matrix 按笛卡尔积自动展开:
target "app" {
name = "app-${tgt}"
matrix = {
tgt = ["alpine", "debian"]
}
context = "./app"
dockerfile = "Dockerfile.${tgt}"
tags = ["${REGISTRY}/app:${TAG}-${tgt}"]
}
生成的 target 名为 app-alpine、app-debian,可通过 docker buildx bake app-alpine 单独调用。matrix 也支持多个维度:
target "runtime" {
name = "rt-${arch}-${libc}"
matrix = {
arch = ["amd64", "arm64"]
libc = ["glibc", "musl"]
}
platforms = ["linux/${arch}"]
args = { LIBC = "${libc}" }
}
4. 变量体系与 CI 注入
Bake 的变量来源有四层,优先级从低到高:
variable块里的default。- 环境变量(同名即覆盖)。
--set命令行覆盖,形如--set 'api.tags=reg/api:ci'。variable块里的validation规则只做校验,不提供值。
# CI 中最常见的用法:用提交 SHA 与分支名打标签
export TAG="${CI_COMMIT_SHA:0:8}"
export REGISTRY="registry.example.com"
docker buildx bake --push
4.1 用函数做条件逻辑
HCL 支持有限的表达式,可用来做条件分支:
variable "PUSH" {
default = false
}
target "api" {
context = "./api"
tags = ["${REGISTRY}/api:${TAG}"]
output = PUSH ? ["type=registry"] : ["type=docker"]
}
注意 output 与 --push/--load 的交互:显式声明 output 后,--push 会被忽略,二者不应同时使用。CI 里推荐统一用 --push 控制,把 output 留给本地场景。
4.2 用 --set 做一次性覆盖
--set 不需要修改文件,适合 CI 的矩阵 job:
docker buildx bake --set 'api.platforms=linux/amd64' --set 'api.tags=reg/api:pr-123' api
--set 的键路径语法是 target.field,对列表字段是整体替换(与 inherits 的追加语义相反),这点必须记住,否则会出现「以为覆盖了,其实叠加了」。
5. 多平台与缓存导出
Bake 最大的价值在于把多平台与缓存配置集中定义,避免每个 build 命令各自为政。
target "release" {
context = "."
dockerfile = "Dockerfile"
platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7"]
tags = ["${REGISTRY}/app:${TAG}", "${REGISTRY}/app:latest"]
cache-from = [
"type=registry,ref=${REGISTRY}/app:buildcache",
]
cache-to = [
"type=registry,ref=${REGISTRY}/app:buildcache,mode=max",
]
provenance = "mode=max"
sbom = "true"
}
要点:
cache-to的mode=max会导出所有中间阶段的层,缓存命中率更高但推送体积更大;mode=min只导出最终阶段的层。CI 中通常选max。provenance与sbom生成 attestation,推送时会额外生成 manifest 引用,需要 registry 支持 OCI 1.1 的 referrers 或 fallback tag 方案。- 多平台构建要求 builder 使用
docker-container驱动,默认的docker驱动不支持:
docker buildx create --name multi --driver docker-container --bootstrap --use
docker buildx inspect --bootstrap
关于缓存模型与各种后端的取舍,见 构建缓存进阶 ;多平台与 manifest list 的细节见 多平台镜像构建与 Buildx 。
5.1 分平台缓存策略
不同平台可以指向不同的缓存位置,避免互相覆盖:
target "app-amd64" {
inherits = ["app"]
platforms = ["linux/amd64"]
cache-to = ["type=registry,ref=${REGISTRY}/app:cache-amd64,mode=max"]
}
target "app-arm64" {
inherits = ["app"]
platforms = ["linux/arm64"]
cache-to = ["type=registry,ref=${REGISTRY}/app:cache-arm64,mode=max"]
}
在 CI 里按 runner 架构分别构建再合并 manifest,比单机 QEMU 模拟构建快一个数量级。
6. CI 落地:按需构建与目标复用
Bake 在 CI 中的典型用法是「只构建变更的服务」。GitHub Actions 中可用路径过滤驱动:
name: build
on:
push:
paths:
- 'api/**'
- 'docker-bake.hcl'
jobs:
bake:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REG_USER }}
password: ${{ secrets.REG_TOKEN }}
- name: Bake
env:
TAG: ${{ github.sha }}
REGISTRY: ${{ vars.REGISTRY }}
run: docker buildx bake --push api
更进一步,可以让 Bake 自己判断:把 --print 的输出交给脚本,与上一次构建的 target 列表做差集。Bake 本身不提供变更检测,这层逻辑需要外层编排,通常结合 CI 的路径过滤或 monorepo 工具完成。相关模式可参考 CI 流水线中的容器构建
与 GitHub Actions 缓存优化
。
6.1 用 bake 统一本地与 CI
本地开发常需要 --load 进本地镜像库,CI 需要 --push。用变量区分即可,避免维护两份文件:
variable "OUTPUT_MODE" {
default = "load"
}
target "app" {
context = "."
tags = ["${REGISTRY}/app:${TAG}"]
output = ["type=${OUTPUT_MODE}"]
}
# 本地
docker buildx bake
# CI
OUTPUT_MODE=registry docker buildx bake
7. 调试与常见坑位
Bake 的报错信息往往指向 HCL 而非实际构建,掌握三个工具能省下大量时间:
# 1. 打印完整构建计划(最重要)
docker buildx bake --print > plan.json
# 2. 只构建并观察进度,不推送
docker buildx bake --progress=plain api
# 3. 校验 HCL 语法(不连 builder)
docker buildx bake --print -f docker-bake.hcl 2>&1 | head
常见问题与定位方式:
| 现象 | 原因 | 处理 |
|---|---|---|
failed to solve: no buildx builder | 用了默认 docker 驱动 | buildx create --driver docker-container |
| tags 出现重复条目 | inherits 列表追加语义 | 用 --print 确认后改为变量控制 |
--set 覆盖无效 | 键路径写错(应为 target.field) | 用 --print 对照 |
| 缓存始终 miss | cache-to 的 ref 被并发覆盖 | 分平台或分 target 使用不同 ref |
| 变量未替换 | 用了 $VAR 而非 ${VAR} | HCL 中统一用 ${} |
| CI 本地结果不一致 | 本地用了 override 文件 | 检查 docker-bake.override.hcl 是否入库 |
7.1 关于 override 文件
docker-bake.override.hcl 会被自动加载且优先级最高,适合放个人开发环境的覆盖(比如把 registry 指向本地)。务必把它写进 .gitignore,否则 CI 会意外加载个人配置,导致「本地能推、CI 推错仓库」的严重问题。
# .gitignore
docker-bake.override.hcl
8. 小结
Bake 的定位不是「另一个构建命令」,而是构建配置的单一事实来源:平台、标签、缓存、密钥、输出方式全部收敛进一个文件,命令行只负责选择目标与覆盖变量。落地时记住三条经验:
- 用
inherits抽公共字段,但警惕列表字段的追加语义,--print是唯一可靠的验证手段。 - 用
matrix处理组合爆炸,用--set处理 CI 矩阵,两者配合可让一个文件覆盖全部流水线场景。 - 把
docker-bake.override.hcl排除在版本控制之外,避免个人配置污染 CI。
做到这三点,Bake 文件本身就能充当镜像交付清单,配合 provenance 与 sbom 开关生成的 attestation,可以形成从构建到准入的完整闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。