Docker 基础体系 · 第 40/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker Buildx Bake:多目标、矩阵、缓存、平台和 CI 编排

docker buildx build 解决的是“一次构建一个目标”的问题:给定一个构建上下文、一个 Dockerfile、若干参数和平台,生成一个镜像或其他输出。docker buildx bake 则在此之上增加了一层声明式编排:把多个构建目标、目标之间的共同配置、矩阵展开、缓存策略、平台和输出统一写入一个配置文件,再由 BuildKit 执行。

这里的“目标”不是 Dockerfile 中的某一条指令,而是一次 BuildKit 构建请求。一个目标可以选择 Dockerfile 的某个 stage,也可以与其他目标共享 Dockerfile、上下文、缓存和构建图。

本文以现代 Docker Engine、Buildx、BuildKit 和 Compose 规范为背景,示例主要针对 Linux 容器镜像。Linux 容器可以在 Linux 主机上原生运行,也可以在其他系统上借助虚拟机或 Docker Desktop 运行;这些运行时边界不改变 Buildx 对 Linux 镜像的构建语义。Windows 容器镜像、Windows 容器节点和 Windows-specific Dockerfile 行为不在本文范围内。


一、Bake 到底解决什么问题

一个简单项目可能只有一个 Dockerfile:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag ghcr.io/acme/api:1.4.0 \
  --push .

当项目出现以下需求时,命令行参数会迅速变得难以维护:

  • 同一个仓库构建 apiworkermigrate 三个镜像;
  • 它们共享上下文、Dockerfile 和基础构建阶段;
  • 每个目标有不同的 Dockerfile stage、标签或构建参数;
  • 需要为 amd64arm64 发布多架构镜像;
  • CI 中希望复用远程缓存;
  • pull request 只验证构建,不推送生产标签;
  • release 需要同时构建多个版本或多个变体。

Bake 将这些信息建模为目标集合和目标组:

Bake 配置
   │
   ├── group:选择一批目标
   │       │
   │       ├── target api
   │       ├── target worker
   │       └── target migrate
   │
   └── 每个 target 转换为一个 BuildKit solve 请求
                         │
                         ├── 解析 Dockerfile 和上下文
                         ├── 查询本地或远程缓存
                         ├── 构建 LLB 图
                         ├── 并行执行可独立的节点
                         └── 写入 image、registry、cache 等输出

因此,Bake 不是新的镜像构建器。真正执行构建的是 BuildKit,Bake 主要负责:

  1. 读取和合并配置;
  2. 解析变量、继承和矩阵;
  3. 得到最终的目标集合;
  4. 将目标集合提交给 BuildKit;
  5. 根据目标的依赖和共享输入安排并发构建。

可以把一个 Bake target 抽象为:

T=(C,D,S,A,P,O,K,M)T = (C, D, S, A, P, O, K, M)

其中:

  • CC:构建上下文 context
  • DD:Dockerfile 路径;
  • SS:Dockerfile stage,即 target
  • AA:构建参数和变量;
  • PP:目标平台;
  • OO:输出,例如镜像、OCI 布局或本地目录;
  • KK:缓存输入和输出;
  • MM:标签、secret、SSH 等元数据。

Bake 的主要价值是让一组 TT 可以被声明、复用、展开和审查,而不是散落在多个 CI shell 脚本中。


二、最小 Bake 文件:target、group 和默认选择

在项目根目录创建 docker-bake.hcl

target "api" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "api"
  tags       = ["ghcr.io/acme/api:dev"]
}

target "worker" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "worker"
  tags       = ["ghcr.io/acme/worker:dev"]
}

group "default" {
  targets = ["api", "worker"]
}

对应的 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 \
    go mod download

COPY . .

FROM build AS api
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api

FROM build AS worker
RUN CGO_ENABLED=0 go build -o /out/worker ./cmd/worker

FROM gcr.io/distroless/static-debian12 AS api-runtime
COPY --from=api /out/api /api
ENTRYPOINT ["/api"]

FROM gcr.io/distroless/static-debian12 AS worker-runtime
COPY --from=worker /out/worker /worker
ENTRYPOINT ["/worker"]

上面的 Bake target = "api" 选择的是 Dockerfile 中名为 api 的 stage,而不是最终的 api-runtime。如果希望构建可运行镜像,应改成:

target "api" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "api-runtime"
  tags       = ["ghcr.io/acme/api:dev"]
}

这说明两个“target”容易混淆:

  • Bake target:配置文件中的一个构建目标;
  • Dockerfile target:FROM ... AS name 定义的 stage。

命令:

docker buildx bake

会选择名为 default 的 group。也可以显式选择目标:

docker buildx bake api
docker buildx bake api worker

或者选择 group:

docker buildx bake default

group 只负责组织目标,不直接定义 Dockerfile、缓存或平台。一个 group 可以包含多个 target,也可以包含其他 group,最终会展开为目标集合。

在执行真实构建前,应先查看 Bake 解析后的结果:

docker buildx bake --print

它会输出 JSON 形式的最终配置。这个步骤非常重要,因为变量、继承、override 文件和 --set 参数都会影响最终结果。排查“我写了配置但 BuildKit 没按预期执行”时,优先检查 --print,而不是直接猜测构建器行为。


三、HCL、JSON 和 Compose:Bake 配置从哪里来

常见配置文件包括:

  • docker-bake.hcl
  • docker-bake.override.hcl
  • JSON 格式的 Bake 文件
  • Compose 文件中的 build 配置

使用 HCL 是因为它支持变量、表达式、函数和继承,适合复杂项目。Bake 也可以显式指定文件:

docker buildx bake -f docker-bake.hcl -f docker-bake.ci.hcl

多个文件被加载时,后加载的配置可以覆盖或补充前面的配置。项目通常把稳定配置放在 docker-bake.hcl,把本地或 CI 差异放在 override 文件中,但必须通过 --print 验证合并结果。

Compose 文件可以作为构建定义的输入。例如:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: api-runtime
    image: ghcr.io/acme/api:dev

  worker:
    build:
      context: .
      dockerfile: Dockerfile
      target: worker-runtime
    image: ghcr.io/acme/worker:dev

可以使用:

docker buildx bake -f compose.yaml

Compose 的 services.*.build 会被转换为相应的构建目标。但 Compose 的主要抽象是应用服务编排,Bake 的主要抽象是构建目标编排;当需要矩阵、复杂继承、远程缓存或精细的构建输出时,直接使用 docker-bake.hcl 通常更清晰。不要假设 Compose 中所有运行时字段都会影响镜像构建;例如 portsdepends_on 和容器网络配置不是 Dockerfile 构建步骤。


四、变量、继承和 --set

1. 变量

variable "REGISTRY" {
  default = "ghcr.io/acme"
}

variable "TAG" {
  default = "dev"
}

target "api" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "api-runtime"
  tags       = ["${REGISTRY}/api:${TAG}"]
}

变量可用于集中控制注册表、版本和构建参数。CI 中可以通过环境变量或命令行覆盖,具体覆盖方式与 Buildx 版本和变量类型有关,因此应使用 --print 确认结果。例如:

REGISTRY=registry.example.com/acme TAG=1.4.0 \
  docker buildx bake api --print

对于目标字段,也可以使用 --set 覆盖:

docker buildx bake api \
  --set api.tags=registry.example.com/acme/api:1.4.0 \
  --print

--set 的路径是 Bake target 的字段路径。它适合 CI 对标签、平台、构建参数做小范围覆盖,但复杂逻辑仍应放进版本控制中的 HCL 文件,否则 CI 配置会重新变成难以审查的命令行脚本。

2. 继承

多个目标通常有相同配置:

variable "REGISTRY" {
  default = "ghcr.io/acme"
}

target "common" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]

  cache-from = [
    "type=registry,ref=${REGISTRY}/buildcache:main"
  ]

  cache-to = [
    "type=registry,ref=${REGISTRY}/buildcache:main,mode=max"
  ]
}

target "api" {
  inherits = ["common"]
  target   = "api-runtime"
  tags     = ["${REGISTRY}/api:dev"]
}

target "worker" {
  inherits = ["common"]
  target   = "worker-runtime"
  tags     = ["${REGISTRY}/worker:dev"]
}

inherits 表示把父 target 的字段带入子 target。子 target 可以覆盖字段,例如 apiworker 继承相同的平台与缓存,却有不同的 Dockerfile stage 和标签。

继承不是 Dockerfile stage 继承,也不意味着先构建父 target。它是配置合并关系。真正的执行仍由每个最终 target 的 Dockerfile 图决定。

一个重要边界是缓存引用的并发写入。如果多个并行目标同时向同一个远程缓存引用执行 cache-to,最终结果取决于导出过程和注册表行为,可能造成互相覆盖或产生不稳定的缓存内容。生产 CI 可以让多个目标共享只读的主分支缓存,同时为不同作业使用不同的写入引用,例如:

cache-from: registry.example.com/acme/buildcache:main
cache-to:   registry.example.com/acme/buildcache:run-12345

周期性任务再把经过验证的缓存策略化为稳定引用。


五、矩阵:把一个声明展开成多个目标

矩阵的含义是:对一个 target 声明多个维度,Bake 为每个维度组合生成一个实际目标。

variable "REGISTRY" {
  default = "ghcr.io/acme"
}

variable "TAG" {
  default = "dev"
}

target "variant" {
  matrix = {
    flavor = ["slim", "debug"]
  }

  name = "variant-${flavor}"

  context    = "."
  dockerfile = "Dockerfile"
  target     = "${flavor}"
  tags       = ["${REGISTRY}/app:${TAG}-${flavor}"]
}

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS slim
COPY app /app
ENTRYPOINT ["/app"]

FROM alpine:3.20 AS debug
RUN apk add --no-cache curl
COPY app /app
ENTRYPOINT ["/app"]

执行:

docker buildx bake variant --print

逻辑上会得到两个目标:

variant-slim  -> target=slim  -> app:dev-slim
variant-debug -> target=debug -> app:dev-debug

矩阵的展开规则可以形式化为笛卡尔积。若有两个维度:

matrix = {
  flavor  = ["slim", "debug"]
  version = ["1", "2"]
}

则目标集合是:

M=F×VM = F \times V

其中:

F={slim,debug}F = \{\text{slim}, \text{debug}\}

V={1,2}V = \{1, 2\}

因此:

M={(slim,1),(slim,2),(debug,1),(debug,2)}M = \{ (\text{slim},1), (\text{slim},2), (\text{debug},1), (\text{debug},2) \}

总目标数为:

N=i=1kDiN = \prod_{i=1}^{k} |D_i|

D_i 是第 ii 个维度的取值集合。两个 2 值维度产生 4 个目标,三个 5 值维度则产生 125 个目标。矩阵不是“自动提高并行度”的免费抽象;目标数量增加会增加调度、缓存导出、日志和注册表压力。

矩阵与多平台不是同一件事

下面两种写法语义不同:

target "release" {
  platforms = ["linux/amd64", "linux/arm64"]
  tags      = ["ghcr.io/acme/app:1.0.0"]
}

这是一个目标、两个平台。BuildKit 可以生成一个多平台镜像索引,两个平台镜像使用同一个标签。

而下面是矩阵:

target "per-platform" {
  matrix = {
    platform = ["linux/amd64", "linux/arm64"]
  }

  name      = "per-platform-${platform}"
  platforms = ["${platform}"]
  tags      = ["ghcr.io/acme/app:${platform}"]
}

这是两个目标,每个目标只构建一个平台。它们不会因为标签相同或名称相似,就自动合并成多平台 manifest。若要发布一个统一的多架构标签,最简单的方式通常是直接使用 platforms = [...] 的单个目标;若必须拆成多个作业,则需要后续使用 manifest 工具或 docker buildx imagetools create 显式创建索引,并确保各平台镜像已经推送到注册表。

另外,平台字符串可能包含 /,不适合直接作为 Docker 标签的一部分。实际项目应使用安全的命名转换,或为平台目标使用单独的标签变量,而不是盲目把 linux/amd64 拼进 tag。


六、缓存:Bake 如何把策略交给 BuildKit

缓存不是 Bake 自己保存的“编译结果表”,而是 BuildKit 对构建图节点和文件输入进行匹配、复用和导出的机制。Bake 负责把 cache-fromcache-to 配置传给每个目标。

常见缓存后端包括:

  • inline:将部分缓存元数据写入镜像;
  • registry:把缓存作为注册表中的独立引用;
  • local:写入本地目录;
  • gha:在 GitHub Actions 中使用 GitHub Actions Cache;
  • 其他后端是否可用取决于 BuildKit、Buildx 和运行环境。

一个适合 CI 的目标示例:

target "ci" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "test"

  cache-from = [
    "type=registry,ref=ghcr.io/acme/app:buildcache"
  ]

  cache-to = [
    "type=registry,ref=ghcr.io/acme/app:buildcache,mode=max"
  ]

  output = ["type=cacheonly"]
}

cacheonly 表示执行构建以验证构建过程,但不导出最终镜像。它适合 pull request 的构建检查;如果需要在本机加载镜像,应使用 type=docker 或命令行对应的本地输出方式,但多平台镜像通常不能直接加载到传统 Docker 镜像存储中。

Layer cache 与 cache mount 不是一回事

Dockerfile 中:

RUN npm ci

产生的是一个普通层。只要该指令以及它依赖的输入没有失效,BuildKit 可以复用整个 RUN 步骤。

而:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

使用的是 cache mount。它是供命令读取和写入的持久化缓存目录,不会直接成为最终镜像层。即使 npm ci 重新执行,已经下载的包也可能从 cache mount 中复用。

两者可以同时存在:

  1. Dockerfile 指令和输入未变化:整个 RUN 层直接命中;
  2. 指令失效但 cache mount 仍可用:命令重新执行,但下载过程可能更快;
  3. cache mount 也不存在:命令重新执行并重新下载。

这解释了为什么“层缓存失效”不等于“所有依赖都必须从网络重新下载”。

缓存失效通常由以下输入变化造成:

  • COPYADD 的文件内容变化;
  • 前置 stage 的结果变化;
  • ARG 或环境变量影响指令;
  • 基础镜像 digest 变化;
  • Dockerfile 指令变化;
  • 构建上下文中被纳入计算的文件变化。

一个常见反例是:

COPY . .
RUN npm ci

源码中任意一个文件变化,都可能使 COPY . . 及其后的 npm ci 失效。更适合依赖安装的顺序是:

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .

这样,只有依赖清单变化时才需要重新执行依赖安装步骤。这里的收益来自输入依赖关系的改变,而不是 Bake 的特殊优化。

缓存命中不代表输出可发布

缓存复用只能说明某个构建图节点的输入被认为等价。它不自动证明:

  • 使用的基础镜像仍符合当前安全要求;
  • 构建产物包含了最新的源码;
  • 构建结果适合当前目标平台;
  • 缓存来自可信来源。

生产构建通常应固定关键基础镜像 digest,并在发布流程中执行漏洞扫描、签名或制品验证。缓存是性能机制,不是供应链信任机制。


七、平台、多架构和 BuildKit 构建器

1. platforms 的含义

target "release" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "runtime"

  platforms = [
    "linux/amd64",
    "linux/arm64"
  ]

  tags = ["ghcr.io/acme/app:1.0.0"]
}

这里的目标平台集合表示最终镜像必须分别适用于 linux/amd64linux/arm64。BuildKit 会为每个平台解析基础镜像、执行对应构建步骤,并最终生成一个镜像索引。

构建器必须支持这些平台:

docker buildx ls
docker buildx inspect --bootstrap

如果构建器只支持 linux/amd64,请求 linux/arm64 会失败,除非构建器通过 QEMU 模拟或连接了原生 arm64 节点。

2. 原生节点与 QEMU

跨平台构建有三种常见执行方式:

  1. 在目标架构的原生节点上构建;
  2. 在当前节点上通过 QEMU 模拟目标架构;
  3. 将两者组合起来,例如 amd64 原生节点负责 amd64,arm64 节点负责 arm64。

QEMU 的优点是配置简单,缺点是某些编译、压缩或测试步骤会明显变慢,并且可能暴露与模拟相关的兼容性问题。原生节点通常更稳定,但需要维护多节点 BuildKit builder、网络和凭据。

这不是“Bake 选择哪种架构”的问题。Bake 只声明 platforms;实际执行能力由当前 Buildx builder 决定。

3. BUILDPLATFORM 与 TARGETPLATFORM

Dockerfile 可以区分构建机平台和目标平台:

FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS
ARG TARGETARCH

WORKDIR /src
COPY . .

RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -o /out/app ./cmd/app

FROM --platform=$TARGETPLATFORM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

在多平台构建中:

  • BUILDPLATFORM 是执行该 stage 的平台;
  • TARGETPLATFORM 是最终要生成的镜像平台;
  • TARGETOSTARGETARCH 是拆分后的目标系统和架构。

对于纯 Go 交叉编译,构建 stage 可以在构建机平台运行 Go 编译器,再产生目标架构二进制。对于必须执行目标架构程序的步骤,例如运行目标程序生成代码,仍可能需要 QEMU 或原生节点。

一个危险的写法是:

FROM --platform=linux/amd64 ubuntu:24.04

如果目标包括 linux/arm64,这会强制所有目标使用 amd64 基础镜像。即使构建过程侥幸完成,最终镜像也无法正确作为 arm64 镜像运行。除非确实需要固定平台,否则应让 BuildKit 根据目标平台解析 FROM

4. 输出方式决定能否发布

多平台目标通常应直接推送:

target "release" {
  platforms = ["linux/amd64", "linux/arm64"]
  tags      = ["ghcr.io/acme/app:1.0.0"]
  output    = ["type=registry"]
}

等价命令通常是:

docker buildx bake release --push

--push 会把镜像和多平台索引推送到注册表。--load 适合把单平台结果加载到本地 Docker 镜像存储;传统本地镜像存储不能直接容纳一个多平台镜像索引,因此多平台发布不能简单地用 --load 替代 --push

如果 CI 只执行:

docker buildx bake release

而没有 --push 或显式输出,镜像可能只存在于 BuildKit 缓存中,后续运行环境无法从注册表拉取。这是“构建成功但部署找不到镜像”的典型原因。


八、多个目标如何共享 Dockerfile 和构建图

考虑以下 Dockerfile:

FROM node:22 AS deps
WORKDIR /src
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM deps AS build-api
COPY . .
RUN npm run build:api

FROM deps AS build-worker
COPY . .
RUN npm run build:worker

FROM nginx:1.27-alpine AS api-runtime
COPY --from=build-api /src/dist/api /usr/share/nginx/html

FROM node:22-alpine AS worker-runtime
COPY --from=build-worker /src/dist/worker /app
CMD ["node", "/app/index.js"]

Bake:

target "base" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
}

target "api" {
  inherits = ["base"]
  target   = "api-runtime"
  tags     = ["ghcr.io/acme/api:dev"]
}

target "worker" {
  inherits = ["base"]
  target   = "worker-runtime"
  tags     = ["ghcr.io/acme/worker:dev"]
}

group "default" {
  targets = ["api", "worker"]
}

BuildKit 会把每个目标转成构建图。两个目标都依赖 deps,因此在内容和平台都相同的情况下,BuildKit 可以复用相同节点;它不需要因为存在两个镜像标签,就重复下载同一依赖。

但“共享 Dockerfile”不等于“必然只执行一次”。以下情况会使节点无法复用:

  • 两个目标使用不同平台;
  • 构建参数不同;
  • 上下文内容不同;
  • 前置 stage 的输入不同;
  • Dockerfile 实际引用了不同的 stage;
  • 一个目标使用了不同的 secret、网络或安全设置。

目标之间没有显式的执行先后关系。Bake 会提交目标集合,BuildKit 根据依赖图和资源能力调度。能够并行的目标可能并行执行,因此多个目标共享一个远程 cache ref 时要考虑导出竞争。


九、CI 中的一个完整编排示例

假设 CI 需要满足:

  • pull request:构建测试目标,不推送应用镜像;
  • main 分支:构建并推送 edge 标签;
  • release:构建 linux/amd64linux/arm64,推送版本标签;
  • 所有流程复用主分支缓存。

Bake 文件:

variable "REGISTRY" {
  default = "ghcr.io/acme"
}

variable "TAG" {
  default = "dev"
}

target "common" {
  context    = "."
  dockerfile = "Dockerfile"

  cache-from = [
    "type=registry,ref=${REGISTRY}/app:buildcache"
  ]
}

target "test" {
  inherits = ["common"]
  target   = "test"
  output   = ["type=cacheonly"]
}

target "release" {
  inherits  = ["common"]
  target    = "runtime"
  platforms = ["linux/amd64", "linux/arm64"]
  tags      = ["${REGISTRY}/app:${TAG}"]

  cache-to = [
    "type=registry,ref=${REGISTRY}/app:buildcache,mode=max"
  ]

  output = ["type=registry"]
}

group "default" {
  targets = ["test"]
}

CI 中可以执行:

# pull request:只验证构建
docker buildx bake test --progress=plain

# main:推送 edge
docker buildx bake release \
  --set release.tags=ghcr.io/acme/app:edge \
  --push \
  --progress=plain

# release:推送版本
TAG=1.4.0 docker buildx bake release \
  --push \
  --progress=plain

每一步的因果关系如下:

  1. test 选择 Dockerfile 的 test stage;
  2. output = ["type=cacheonly"] 不创建可部署镜像;
  3. release 选择 runtime stage;
  4. platforms 要求两个 Linux 平台;
  5. output = ["type=registry"] 让结果进入注册表;
  6. --push 是命令行对 registry 输出的直接启用方式;
  7. TAG 影响标签字符串,但不会自动改变 Dockerfile 内的版本逻辑;
  8. cache-from 让构建尝试读取已有远程缓存;
  9. cache-to 将本次构建的缓存导出到远程引用。

在 CI 中,必须先完成以下前置操作:

docker buildx version
docker buildx ls
docker buildx inspect --bootstrap

并完成注册表登录:

docker login ghcr.io

若使用 GitHub Actions,通常还需要配置 Buildx builder、登录 action 和适当的 GITHUB_TOKEN 权限。权限不足时,构建本身可能成功,但在 cache export 或 image push 阶段失败。


十、构建目标中的 secrets、SSH 和敏感数据

构建时需要私有依赖时,不应把凭据写入 ARG 或普通环境变量:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

Bake target:

target "private-build" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "runtime"

  secret = [
    "id=npmrc,src=${HOME}/.npmrc"
  ]
}

执行:

docker buildx bake private-build

Secret mount 的设计目标是让凭据只在对应构建步骤可见,不把它作为普通文件复制进最终镜像层。但如果 Dockerfile 主动执行:

RUN cat /run/secrets/npmrc > /app/npmrc

凭据仍可能进入层或构建产物。BuildKit 不会阻止应用显式泄露 secret。

同理,SSH mount 适用于需要通过 SSH 访问私有 Git 仓库的构建步骤,但它要求 builder 能获得对应 SSH agent 或密钥,且网络访问路径必须可用。CI 中应把 secret、SSH、网络权限和缓存来源一起纳入信任边界,而不能仅因为“没有写进 Dockerfile”就认为整个构建完全无敏感信息风险。


十一、故障诊断:从最终配置到 BuildKit 日志

1. 目标没有被执行

现象:

no targets specified

或执行成功但什么都没构建。

检查:

docker buildx bake --print

确认是否存在:

group "default" {
  targets = ["..."]
}

也可以显式指定 target:

docker buildx bake api

如果配置文件名称非默认名称,必须使用:

docker buildx bake -f path/to/file.hcl api

2. 平台不受支持

现象通常类似:

no match for platform in manifest

或 builder 报告不支持目标平台。

诊断:

docker buildx inspect --bootstrap

检查 builder 的 Platforms 列表。若没有目标平台,需要:

  • 使用支持该平台的原生节点;
  • 正确安装并启用 QEMU;
  • 创建包含多个节点的 Buildx builder;
  • 或暂时减少 platforms 集合。

不要通过强行写死 FROM --platform=linux/amd64 来“修复” arm64 构建;这只是掩盖 builder 能力或 Dockerfile 兼容性问题。

3. 缓存没有命中

先用:

docker buildx bake release --progress=plain

观察每一步是否显示缓存复用。然后检查:

  1. --print 中是否真的存在 cache-from
  2. CI 是否登录了包含缓存引用的注册表;
  3. cache ref 是否被错误地当成镜像 tag 使用;
  4. Dockerfile 的 COPY 顺序是否导致过早失效;
  5. 构建参数、平台或基础镜像是否发生变化;
  6. 远程缓存是否被其他并行任务覆盖;
  7. cache-to 是否执行到了导出阶段。

缓存 miss 不一定是故障。基础镜像 digest 变化、依赖文件变化或目标平台变化,都可能使命中条件不成立。真正需要诊断的是“本应相同的输入为何不相同”。

4. 构建成功但镜像不存在

如果没有 --push,也没有 type=registrytype=docker 等有效输出,结果可能只停留在 BuildKit builder 中。检查目标的 output

docker buildx bake release --print

发布到注册表时,使用:

docker buildx bake release --push

验证:

docker buildx imagetools inspect ghcr.io/acme/app:1.4.0

输出中应能看到多个平台的 manifest。若只有一个平台,说明矩阵目标没有被合并,或某个平台推送失败。

5. 多个目标的 tag 相互覆盖

矩阵或多目标配置中,如果多个目标使用相同 tag:

tags = ["ghcr.io/acme/app:dev"]

它们可能同时尝试更新同一注册表引用。最终标签指向哪个 manifest,不应依赖构建完成顺序。解决方式是:

  • 为每个变体使用唯一 tag;
  • 先分别推送不可变平台或变体引用;
  • 再显式创建统一 manifest;
  • 发布阶段使用版本号或 digest,而不是让并发任务争抢可变标签。

十二、常见误解和真实边界

误解一:Bake 会自动按 Dockerfile stage 顺序构建全部镜像

不会。Bake 只构建被选中的 target。Dockerfile 中未被选中且不被依赖的 stage 不会因为“写在那里”就自动构建。

误解二:两个 target 共用 Dockerfile,就一定完全共享缓存

不一定。缓存键与平台、参数、输入内容、前置图和指令有关。共享文本文件只是共享缓存的必要条件之一,不是充分条件。

误解三:矩阵会自动生成多架构 manifest

不会。矩阵生成多个独立 target。platforms = ["linux/amd64", "linux/arm64"] 才是单个 target 的多平台构建声明。拆分后的平台镜像需要额外合并。

误解四:--platform 只影响最终镜像标签

不会。它会影响基础镜像解析、构建步骤的执行平台、编译目标、缓存键和最终 manifest。错误的平台声明可能直到部署到另一种 CPU 架构时才暴露。

误解五:远程缓存等同于远程制品

不会。cache ref 保存的是供 BuildKit 复用的构建缓存元数据和内容;它不是面向部署的镜像标签,也不应作为生产发布物的替代品。

误解六:Linux 容器包含一个完整 Linux 内核

不会。Linux 容器通常共享运行它的 Linux 内核,只隔离用户空间、进程、网络和文件系统视图。Buildx 可以构建 Linux 用户空间镜像,但镜像能否运行仍取决于目标节点的内核、架构和容器运行时能力。


十三、如何选择 Bake 的建模方式

可以按以下因果关系选择配置:

  • 只有一个镜像、一次构建:直接使用 docker buildx build 更简单;
  • 多个 Dockerfile stage 生成多个镜像:使用多个 Bake target;
  • 多个目标有公共 context、平台或缓存:使用 inherits
  • 需要组合多个变体或版本:使用 matrix
  • 同一标签要发布多个 CPU 架构:优先使用一个 target 的 platforms
  • CI 需要按场景选择构建集合:使用多个 group
  • 需要审查实际传给 BuildKit 的参数:使用 --print
  • 需要远程缓存:在 target 中明确写 cache-fromcache-to
  • 需要发布可拉取镜像:明确使用 registry output 或 --push

一个可维护的 Bake 文件,最终应能回答四个问题:

  1. 这个目标选择了哪个 Dockerfile stage?
  2. 它会为哪些平台构建?
  3. 它从哪里读取缓存、向哪里写入缓存?
  4. 构建结果会输出到哪里,使用什么不可歧义的标签?

只要这四个问题无法从 docker-bake.hcldocker buildx bake --print 中直接回答,CI 中就仍然存在隐藏状态。Bake 的核心不是减少命令行字符,而是把多目标构建从隐式脚本调用,转化为可检查的目标图、平台集合、缓存边界和输出契约。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。