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

Docker CI 构建流水线:缓存、并行、扫描、签名、推送和晋级

Docker CI 构建流水线的产物不是“某次构建产生的一个标签”,而是一个可以被验证、定位和晋级的不可变镜像摘要(digest)。缓存负责减少重复计算,并行负责缩短关键路径,扫描负责发现已知风险,签名负责证明产物的发布主体,推送负责把产物交给镜像仓库,晋级负责让同一个已验证产物进入更高环境。

这几个动作有严格的因果顺序:

flowchart LR
    A[提交代码] --> B[解析 Dockerfile/Compose/Bake]
    B --> C[并行构建多个目标]
    C --> D[推送候选镜像与证明]
    D --> E[按 digest 扫描]
    E --> F{测试与策略通过?}
    F -- 否 --> X[失败并保留诊断信息]
    F -- 是 --> G[签名 digest]
    G --> H[按 digest 晋级环境标签]
    H --> I[部署系统按标签或 digest 拉取]
    I --> J[部署侧验证签名与证明]

如果先把镜像推送到 latest,再扫描或测试,失败时就可能已经改变了生产系统看到的标签。如果签名的是标签而不是摘要,标签被覆盖后,签名所代表的对象就不再明确。因此,流水线应当围绕候选标签、镜像摘要和证明附件组织状态。

本文以 Linux 容器为边界,示例假定使用现代 Docker Engine、BuildKit、Buildx 和 Compose 规范。Linux 容器共享宿主机 Linux 内核,并不是包含完整内核的虚拟机;跨架构构建时还会涉及交叉编译或 QEMU 模拟,这些因素会影响构建速度、测试结果和安全边界。


一、先确定交付对象:标签不是版本,digest 才是不可变引用

镜像引用通常写成:

registry.example.com/team/api:build-7f31c2a

它由仓库、名称和标签组成。标签是一个可移动的命名指针,仓库管理员或流水线可以让它从一个镜像改指向另一个镜像。

镜像摘要则类似:

registry.example.com/team/api@sha256:abc123...

摘要是镜像清单(manifest)或多平台镜像索引(image index)内容的密码学哈希。对于同一个仓库中的同一个摘要,内容应当保持不变;如果内容改变,摘要也必须改变。

因此:

  • :build-7f31c2a 适合作为候选产物的可读标签;
  • @sha256:... 适合作为测试、扫描、签名和部署的精确对象;
  • :staging:production:latest 适合作为环境入口,但不应成为审计记录中唯一的版本标识。

一个正确的晋级过程不是重新构建:

代码提交
  -> 构建 api:build-7f31c2a
  -> 得到 api@sha256:D
  -> 扫描 api@sha256:D
  -> 签名 api@sha256:D
  -> production 标签指向 api@sha256:D

而不是:

代码提交
  -> 构建候选镜像
  -> 生产环境重新执行一次 docker build

后者可能因为基础镜像、网络依赖、构建参数或工具版本变化,产生与测试过的镜像不同的二进制内容。

多平台镜像中的 digest

如果构建:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --push \
  --tag registry.example.com/team/api:build-7f31c2a \
  .

仓库中通常会有一个多平台镜像索引。索引列出不同平台的子 manifest:

index digest: sha256:INDEX
├── linux/amd64 manifest: sha256:AMD64
└── linux/arm64 manifest: sha256:ARM64

用户使用标签拉取时,Docker 会根据本机平台选择对应子 manifest。扫描和签名时必须明确对象范围:

  • 签名索引摘要,表示整个多平台发布对象;
  • 扫描索引及其各平台子镜像,避免只扫描了某一个平台;
  • 部署时记录实际平台对应的摘要,尤其是在集群包含多个 CPU 架构时。

docker inspect 对本地镜像显示的是本地镜像对象,不一定能完整表达远程多平台索引。远程发布对象应使用 docker buildx imagetools inspect 或仓库 API 查询。


二、BuildKit 构建模型:Dockerfile 不是简单的逐行脚本

BuildKit 是 Docker 的现代构建后端。它会把 Dockerfile 解析成由状态、文件输入和命令组成的构建图,而不是机械地从第一行执行到最后一行。

一个简化的 Dockerfile:

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS deps

WORKDIR /src

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt

FROM python:3.12-slim AS runtime

WORKDIR /app
COPY --from=deps /install /usr/local
COPY . .

CMD ["python", "app.py"]

这个文件形成了两个主要阶段:

基础镜像
   |
   +--> deps: COPY requirements.txt -> pip install
   |
   +--> runtime: COPY deps 结果 -> COPY 源代码 -> CMD

runtime 依赖 deps,但若 Dockerfile 中还有另一个独立阶段,BuildKit 可以并行计算它们。构建阶段只有在依赖的输入已经可用时才会继续。

2.1 Layer、cache mount 和构建结果不是同一个缓存

需要区分三种东西。

Layer

Layer 是镜像文件系统变化的结果。典型的 RUNCOPYADD 会产生或参与产生镜像层。最终镜像由多个层按顺序叠加得到。

Layer 缓存命中通常意味着:

当前 Dockerfile 操作
+ 父状态
+ 相关输入
+ 平台和构建参数

与已有缓存记录完全匹配,于是可以直接复用之前的结果。

Cache mount

RUN --mount=type=cache 是给构建命令使用的持久化目录。例如:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

这里的 pip 下载缓存不会自动成为最终镜像层。它的作用是让下一次 pip install 少下载一些包。

cache mount 与 layer cache 的差别是:

项目 Layer cache Cache mount
是否进入最终镜像 通常会影响镜像层 不会直接进入镜像
是否要求命令完全命中 通常要求构建缓存键命中 即使命令重新执行,也可能复用目录内容
典型内容 已执行命令的文件系统结果 包下载缓存、编译缓存
主要收益 跳过整个构建步骤 降低步骤重新执行的成本
一致性含义 影响镜像结果 通常只是性能优化

如果 requirements.txt 改变,RUN pip install 的 layer cache 可能失效;但是 pip cache mount 仍然可以保留已经下载的包。

cache mount 不是一个可靠的依赖仓库。构建不能假定它一定存在,也不能把它当成正确性的来源。正确性应当来自锁定文件、包索引和校验机制;cache mount 只是加速手段。

Remote cache

CI 节点通常是短生命周期的,构建结束后本地 BuildKit 缓存可能被删除。因此需要把缓存导出到远程位置,例如 OCI registry:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/cache/api:main \
  --cache-to type=registry,ref=registry.example.com/cache/api:main,mode=max \
  --tag registry.example.com/team/api:build-7f31c2a \
  --push \
  .

cache-from 是导入缓存,cache-to 是导出缓存。mode=max 通常会导出更多中间阶段缓存,适合多阶段构建;但它也会产生更多缓存数据和仓库存储开销。具体可用的缓存后端取决于 BuildKit 和 CI 环境,例如 registry、GitHub Actions、local 等,不同后端的权限、生命周期和容量限制不同。

缓存不是构建输入的替代品。相同代码在不同的:

  • Dockerfile 前端版本;
  • BuildKit 版本;
  • 基础镜像;
  • 平台;
  • 构建参数;
  • 网络依赖状态;

下,可能得到不同的缓存命中结果。


三、缓存失效的形式化条件与诊断

可以把 BuildKit 的每一个构建操作抽象成节点 NiN_i。它的缓存键可以近似写成:

Ki=H(Oi,Pi,Ii,Ai,Ei,Li)K_i = H( O_i,\, P_i,\, I_i,\, A_i,\, E_i,\, L_i )

其中:

  • OiO_i:操作本身,例如 RUNCOPY
  • PiP_i:父节点产生的文件系统状态;
  • IiI_i:该操作使用的输入文件;
  • AiA_i:平台、构建参数等显式参数;
  • EiE_i:影响构建行为的环境或前端信息;
  • LiL_i:Dockerfile 前端和 BuildKit 相关元数据;
  • HH:将这些输入映射为缓存键的哈希过程。

缓存命中需要找到相同的 KiK_i。因此,缓存命中不是“这一行文字看起来没变”,而是“这一操作依赖的输入状态没有变化”。

3.1 完整算例:为什么只改源代码不应重装依赖

假设 Dockerfile 采用如下顺序:

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt

COPY src/ ./src/

第一次构建:

N1 FROM python:3.12-slim       -> miss
N2 COPY requirements.txt       -> miss
N3 RUN pip install              -> miss
N4 COPY src/                   -> miss

第二次只修改 src/app.py

N1 FROM python:3.12-slim       -> hit
N2 COPY requirements.txt       -> hit
N3 RUN pip install              -> hit
N4 COPY src/                   -> miss

原因是 N3 的输入没有包含 src/。如果把所有文件提前复制:

COPY . .
RUN pip install -r requirements.txt

那么修改任意源文件都会改变 COPY . . 的结果,进而使后续安装步骤重新执行。

这不是“把依赖安装放前面”的经验口诀,而是依赖图的直接结果:应当让变化频率不同的输入进入不同节点。

3.2 RUN 缓存不会检查任意外部状态

例如:

RUN apt-get update && apt-get install -y curl

如果 Dockerfile 文本、父层和相关输入没有变化,构建缓存可能直接复用这个 RUN 的结果。它不会因为远程软件仓库今天发布了新版本,就自动认为这一步必须重新执行。

想让依赖更新成为显式输入,可以使用版本参数:

ARG CURL_VERSION=7.88.1-10+deb12u5

RUN apt-get update \
 && apt-get install -y --no-install-recommends "curl=${CURL_VERSION}" \
 && rm -rf /var/lib/apt/lists/*

更新 CURL_VERSION 会改变构建输入。但这也带来一个选择:如果基础镜像或包仓库不再提供该精确版本,构建会失败,而不是静默安装另一个版本。

3.3 诊断缓存问题

构建时先打开更详细的进度输出:

docker buildx build --progress=plain -t example/api:debug .

输出通常会显示每个步骤是 CACHED 还是重新执行。诊断时按以下顺序比较:

  1. Dockerfile 中该步骤的操作是否改变;
  2. 父阶段是否改变;
  3. COPYADD 的输入是否改变;
  4. ARG、平台和目标阶段是否改变;
  5. 基础镜像 digest 是否改变;
  6. 远程 cache 是否成功导入;
  7. cache mount 是否只是下载缓存,而不是 layer cache;
  8. 是否使用了不同的 builder 实例。

不要使用 --no-cache 作为第一诊断手段。它会抹去“是否命中”的证据。更有用的对比是:

docker buildx build --progress=plain --no-cache -t example/api:nocache .

把无缓存构建与正常构建逐步骤比较,确定真正昂贵的节点。


四、Dockerfile 中的可缓存结构、秘密和测试阶段

一个适合 CI 的多阶段 Dockerfile 通常把依赖、测试和运行时分开:

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS deps
WORKDIR /src

COPY requirements.txt requirements.lock ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install \
      --require-hashes \
      --prefix=/install \
      -r requirements.lock

FROM deps AS test
COPY src/ ./src/
COPY tests/ ./tests/
RUN python -m compileall src \
 && python -m pytest -q

FROM python:3.12-slim AS runtime
WORKDIR /app

COPY --from=deps /install /usr/local
COPY src/ ./src/

USER 65532:65532
CMD ["python", "-m", "src.app"]

这个例子有几个重要性质:

  • 依赖锁定在 requirements.lock 中;
  • 依赖安装层不包含频繁变化的源代码;
  • test 阶段可以在 CI 中单独构建;
  • runtime 阶段不包含测试代码和构建工具;
  • 运行时使用非 root 用户;
  • --require-hashes 让依赖文件中的哈希参与安装校验。

如果依赖安装需要私有仓库凭据,不要这样写:

ARG PIP_TOKEN
RUN pip install --index-url "https://${PIP_TOKEN}@packages.example.com/simple" ...

因为参数可能出现在构建历史、日志或缓存元数据中。应使用 BuildKit secret mount:

RUN --mount=type=secret,id=pip_config,target=/etc/pip.conf \
    pip install -r requirements.lock

构建命令:

docker buildx build \
  --secret id=pip_config,src="$HOME/.config/pip/pip.conf" \
  --target test \
  --load \
  --tag example/api:test \
  .

前置条件是本机存在 pip.conf,并且 builder 支持 BuildKit。--load 将结果加载到本地 Docker Engine,便于执行 docker run example/api:test;它通常只适合单平台或本地测试。多平台构建不能把多个平台镜像同时作为一个普通本地镜像加载,因此 CI 的多平台发布一般使用 --push


五、并行:BuildKit 的图并行、Bake 的目标并行和 CI 作业并行

“并行构建”有三个层次,不能混为一谈。

5.1 构建图内部并行

如果两个阶段没有依赖关系,BuildKit 可以同时处理它们。阶段之间的 COPY --from 或其他引用会建立依赖边。

FROM alpine AS tools
RUN apk add --no-cache jq

FROM node:22 AS frontend
COPY package*.json ./
RUN npm ci

FROM alpine AS final
COPY --from=tools /usr/bin/jq /usr/bin/jq
COPY --from=frontend /app/dist /app/dist

toolsfrontend 在逻辑上互不依赖,可以并行;final 必须等待二者完成。

5.2 多目标并行:Buildx Bake

多个服务或多个 Dockerfile 可以用 Bake 声明为目标集合。下面的 docker-bake.hcl 可以作为 CI 构建入口:

variable "REGISTRY" {
  default = "registry.example.com/team"
}

variable "TAG" {
  default = "local"
}

target "common" {
  platforms = ["linux/amd64", "linux/arm64"]
  output = ["type=registry"]
}

target "api" {
  inherits = ["common"]
  context = "./api"
  dockerfile = "./api/Dockerfile"
  tags = ["${REGISTRY}/api:${TAG}"]
  cache-from = [
    "type=registry,ref=${REGISTRY}/cache/api:main"
  ]
  cache-to = [
    "type=registry,ref=${REGISTRY}/cache/api:main,mode=max"
  ]
}

target "web" {
  inherits = ["common"]
  context = "./web"
  dockerfile = "./web/Dockerfile"
  tags = ["${REGISTRY}/web:${TAG}"]
  cache-from = [
    "type=registry,ref=${REGISTRY}/cache/web:main"
  ]
  cache-to = [
    "type=registry,ref=${REGISTRY}/cache/web:main,mode=max"
  ]
}

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

执行:

export REGISTRY=registry.example.com/team
export TAG=build-7f31c2a

docker buildx bake \
  --file docker-bake.hcl \
  --push

这会同时构建 apiweb 两个目标。--push 表示将构建结果输出到 registry,而不是仅保留在 builder 缓存中。

并行并不保证线性加速。两个目标如果同时下载相同的大型基础镜像,瓶颈可能转移到网络;多个任务若写入同一个远程 cache ref,还可能产生竞争。生产 CI 通常按分支或默认分支分别规划缓存引用,例如:

cache/api:main
cache/api:feature-123

并让 feature 构建同时读取:

cache-from:
  1. cache/api:feature-123
  2. cache/api:main

缓存导出通常由一个权威流水线负责,避免多个并发构建反复覆盖同一个缓存对象。

5.3 Compose 的并行边界

Compose 主要描述服务、网络、卷和本地运行关系。例如:

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: registry.example.com/team/api:dev

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    image: registry.example.com/team/web:dev

本地构建可以使用:

docker compose build --parallel

depends_on 描述的是 Compose 服务启动或生命周期关系,不等于 Dockerfile 构建阶段的依赖。例如:

services:
  web:
    build: ./web
    depends_on:
      - api

这通常表示启动 web 前先启动 api,不表示 web 的 Dockerfile 可以直接读取 api 的构建文件,也不表示 CI 应当先构建完 api 才能构建 web

在 CI 中,Compose 适合作为开发和集成测试的服务编排入口;Buildx Bake 更适合声明多镜像发布目标、平台、缓存和输出。两者可以使用相同的构建上下文,但不应把 Compose 的运行时依赖误当成发布流水线的构建依赖。


六、构建输出:本地测试和多平台发布是两条不同路径

BuildKit 的输出方式决定了构建结果在哪里:

输出方式 作用
--load 加载到本地 Docker Engine,适合单平台运行测试
--push 直接推送到镜像仓库,适合 CI 和多平台发布
--output type=oci 导出 OCI 布局或归档,适合离线处理
默认 cache-only 行为 只保留在 builder 中,不一定形成可拉取镜像

单平台测试:

docker buildx build \
  --target test \
  --load \
  --tag registry.example.com/team/api:test-7f31c2a \
  ./api

docker run --rm \
  registry.example.com/team/api:test-7f31c2a

多平台候选发布:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.example.com/team/api:build-7f31c2a \
  --push \
  --provenance=mode=max \
  --sbom=true \
  --cache-from type=registry,ref=registry.example.com/cache/api:main \
  --cache-to type=registry,ref=registry.example.com/cache/api:main,mode=max \
  ./api

这里的 --provenance--sbom 会请求生成构建证明和软件物料清单(SBOM)附件。实际是否能看到附件,还取决于 Buildx、BuildKit、镜像仓库和客户端对 OCI artifact/referrer 的支持。它们不是普通文件层,因此不会出现在 docker run 的容器文件系统中。


七、扫描:扫描的是具体产物,而不是“这次代码提交”

扫描可以分为几类:

  1. 镜像操作系统包扫描:例如 Debian、Alpine、Ubuntu 中已知漏洞;
  2. 应用依赖扫描:例如 Python、Node.js、Java、Go 依赖;
  3. 镜像配置扫描:root 用户、危险权限、暴露端口、健康检查等;
  4. 源码和 IaC 扫描:Dockerfile、Compose、Terraform、Kubernetes 配置;
  5. 运行时扫描:实际部署环境中的进程、文件和网络行为。

镜像漏洞扫描只能证明扫描器在某一数据库版本、某一时间点对某一镜像摘要的判断。它不能证明业务代码不存在逻辑漏洞,也不能证明镜像一定安全。

一个使用 Trivy 的候选镜像扫描示例:

REF=registry.example.com/team/api:build-7f31c2a
DIGEST=sha256:...

trivy image \
  --exit-code 1 \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  "${REF}@${DIGEST}"

前置条件是 CI 已登录 registry,并且 runner 安装了 Trivy。预期行为是:

  • 没有达到策略阈值时返回退出码 0
  • 发现 HIGHCRITICAL 且未被策略忽略时返回退出码 1
  • 找不到镜像或无法访问仓库时返回非零错误。

--ignore-unfixed 是策略选择,不是漏洞消失。它减少了“上游尚无修复版本”导致的阻断,但也降低了阻断范围。生产环境应记录扫描器版本、漏洞数据库版本、扫描对象 digest 和豁免原因。

Docker Scout 也可以作为 Docker 生态中的扫描工具,例如:

docker scout cves registry.example.com/team/api:build-7f31c2a

命令具体可用选项随 Docker Scout CLI 版本变化,CI 应固定工具版本并使用其机器可读输出,而不是解析人类可读表格。

扫描失败后的路径

当扫描失败时,不能简单地“清缓存重试”。应先判断失败类型:

  • 构建失败:镜像不存在,先检查 Dockerfile、builder 和输出;
  • 认证失败:确认 CI 登录的是拉取候选镜像所需的 registry;
  • 数据库下载失败:检查网络、代理和漏洞数据库缓存;
  • 真实漏洞阻断:升级基础镜像或锁定依赖后重新构建;
  • 误报或不可修复漏洞:通过带有过期时间和责任人的豁免流程处理;
  • 只扫描了 amd64:重新扫描多平台索引及所有目标平台。

扫描必须绑定 digest,否则在扫描开始和结束之间标签可能被重新推送,导致扫描结论无法对应最终发布内容。


八、SBOM、Provenance、Attestation 和 SLSA 的关系

这几个术语相关,但不是同义词。

8.1 SBOM

SBOM(Software Bill of Materials)描述软件由哪些组件组成,例如:

镜像
├── Debian base packages
├── Python interpreter
├── requests==...
└── 应用自身文件

SBOM 主要回答:

这个产物包含了什么?

它有助于漏洞匹配、许可证审计和供应链清点,但 SBOM 本身不等于安全证明。一个错误或不完整的 SBOM 仍然可能漏报组件。

8.2 Provenance

Provenance 是构建来源证明,描述产物如何生成,例如:

  • 使用了哪个源码仓库和提交;
  • 使用了哪个构建器;
  • 采用了什么构建参数;
  • 输入和输出是什么;
  • 构建时间和环境是什么。

它主要回答:

这个产物是由谁、用什么输入和过程生成的?

8.3 Attestation

Attestation 是附着在镜像对象旁边的可验证声明。SBOM 和 provenance 都可以作为 attestation。它们通常通过 OCI referrer 或相关 registry 机制与镜像摘要关联,而不是写进普通镜像层。

构建时显式请求:

docker buildx build \
  --provenance=mode=max \
  --sbom=true \
  --tag registry.example.com/team/api:build-7f31c2a \
  --push \
  ./api

mode=max 表示请求更完整的 provenance 信息。具体字段和默认行为可能随 Buildx/BuildKit 版本变化,因此应在 CI 中固定版本,并在仓库中实际检查生成的 attestation,而不是仅凭命令成功就认为证明已发布。

8.4 SLSA

SLSA 是软件供应链完整性框架,用于描述构建过程的可信程度、来源证明和防篡改要求。生成一份 provenance 并不自动意味着达到了某个 SLSA 等级。

是否满足某个 SLSA 要求,还取决于:

  • 构建是否在受控、隔离的环境中执行;
  • 构建器身份是否可验证;
  • 输入源码是否固定;
  • 依赖是否锁定;
  • provenance 是否不可伪造;
  • 发布和部署侧是否验证证明;
  • 是否具备防止构建过程被篡改的控制。

因此,--provenance=mode=max 是生成证明的构建能力,不是完整的 SLSA 合规结论。


九、推送:先推送候选对象,再固定 digest

一个适合 CI 的构建步骤可以使用 metadata 文件记录摘要:

set -eu

REF="registry.example.com/team/api:build-${GIT_SHA}"

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag "$REF" \
  --push \
  --provenance=mode=max \
  --sbom=true \
  --metadata-file build-metadata.json \
  --cache-from type=registry,ref=registry.example.com/cache/api:main \
  --cache-to type=registry,ref=registry.example.com/cache/api:main,mode=max \
  ./api

DIGEST="$(jq -r '."containerimage.digest"' build-metadata.json)"

test -n "$DIGEST"
test "$DIGEST" != "null"

printf '%s\n' "$DIGEST" > image-digest.txt
echo "built: ${REF}@${DIGEST}"

前置条件:

  • CI 安装了 docker buildxjq
  • 已执行 docker login registry.example.com
  • registry 允许推送镜像及相关证明;
  • GIT_SHA 来自受信任的 CI 环境,而不是用户可任意修改的输入。

containerimage.digest 是 Buildx metadata 中的构建结果摘要。CI 应把它作为后续 job 的显式输出,而不是后续步骤再次根据标签猜测对象。

如果 metadata 文件不存在、摘要为空或 registry 推送成功但无法查询该摘要,应停止流水线。继续扫描一个不确定的引用,会破坏审计链。


十、签名:签署 digest,不签署可移动标签

Cosign 的核心操作是对镜像摘要签名:

REF="registry.example.com/team/api:build-7f31c2a"
DIGEST="$(cat image-digest.txt)"

cosign sign --yes "${REF}@${DIGEST}"

验证:

cosign verify \
  --certificate-identity-regexp 'https://github.com/example/project/.+' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "${REF}@${DIGEST}"

上面的验证方式是假定采用 keyless 签名,并由 CI 的 OIDC 身份向 Sigstore 体系证明签名主体。另一种方式是使用受保护的签名密钥:

cosign sign \
  --key env://COSIGN_PRIVATE_KEY \
  "${REF}@${DIGEST}"

cosign verify \
  --key cosign.pub \
  "${REF}@${DIGEST}"

密钥模式需要解决私钥存储、轮换、访问审计和泄露后的撤销策略。keyless 模式减少了长期私钥管理,但仍然依赖 CI 身份、证书策略和透明日志服务。

签名通常作为 registry 中与镜像摘要关联的签名对象存在,不会改变原镜像的文件层。因此签名后原镜像 digest 通常不变;但签名附件本身可能有自己的引用和存储生命周期。仓库必须支持相应的 OCI referrer 或 Cosign 兼容存储行为,否则会出现“签名命令成功,但另一套客户端找不到签名”的运维问题。

签名只证明:

某个身份对某个摘要做出了签名。

它不自动证明:

  • 镜像没有漏洞;
  • 构建过程安全;
  • SBOM 完整;
  • 该身份有权发布到生产;
  • 镜像适合当前环境。

这些结论需要由扫描策略、provenance 验证和部署授权共同给出。


十一、晋级:移动标签,而不是重新构建镜像

假定候选镜像是:

registry.example.com/team/api:build-7f31c2a
registry.example.com/team/api@sha256:D

扫描和签名成功后,可以把生产标签指向同一个摘要:

SOURCE="registry.example.com/team/api@sha256:D"
TARGET="registry.example.com/team/api:production"

docker buildx imagetools create \
  --tag "$TARGET" \
  "$SOURCE"

随后验证:

docker buildx imagetools inspect "$TARGET"

应检查目标标签显示的 digest 是否仍然是 sha256:D。如果目标 registry、源 registry 或镜像类型不同,可能需要使用专门的镜像复制工具,例如 crane copy;复制完成后必须重新查询目标引用的 digest。

晋级的状态转换可以表示为:

stateDiagram-v2
    [*] --> Built: 候选镜像已推送
    Built --> Scanned: digest 扫描完成
    Scanned --> Rejected: 策略不通过
    Scanned --> Signed: 策略通过并签名
    Signed --> Promoted: 环境标签指向同一 digest
    Promoted --> Deployed: 部署系统拉取并验证
    Deployed --> RolledBack: 健康检查或业务验证失败
    RolledBack --> Promoted: 选择上一个已验证 digest

回滚也不应重新构建。应从发布记录中选择上一个已验证摘要:

docker buildx imagetools create \
  --tag registry.example.com/team/api:production \
  registry.example.com/team/api@sha256:PREVIOUS

如果平台要求“只允许签名镜像进入生产”,部署控制器或 admission policy 应验证目标摘要的签名和身份,而不是只检查 production 标签是否存在。

标签晋级的竞态

两个 CI 运行可能同时晋级:

流水线 A:扫描 D1 -> 签名 D1 -> production=D1
流水线 B:扫描 D2 -> 签名 D2 -> production=D2

如果 A 在 B 之后执行,生产标签最终可能回到旧提交 D1。解决方式不是依赖人的观察,而是为晋级增加并发控制:

  • 以环境为单位加互斥锁;
  • 记录“候选提交、digest、审批事件”;
  • 晋级前确认候选仍是允许发布的版本;
  • 使用 registry 或发布系统提供的条件更新能力;
  • 把生产部署版本记录为 digest,而不只记录标签。

十二、一个端到端的 CI 参考脚本

下面脚本展示单个服务从构建到晋级的关键路径。它不是某个 CI 平台的完整 YAML,但每一步都可以映射为独立 job,并通过 artifact 传递 image-digest.txt

#!/usr/bin/env bash
set -Eeuo pipefail

REGISTRY="registry.example.com/team"
IMAGE="${REGISTRY}/api"
GIT_SHA="${GIT_SHA:?GIT_SHA is required}"
CANDIDATE="${IMAGE}:build-${GIT_SHA}"
METADATA="$(mktemp)"

cleanup() {
  rm -f "$METADATA"
}
trap cleanup EXIT

echo "== 1. Build and push candidate =="
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag "$CANDIDATE" \
  --push \
  --provenance=mode=max \
  --sbom=true \
  --metadata-file "$METADATA" \
  --cache-from type=registry,ref="${REGISTRY}/cache/api:main" \
  --cache-to type=registry,ref="${REGISTRY}/cache/api:main,mode=max" \
  ./api

DIGEST="$(jq -r '."containerimage.digest"' "$METADATA")"
case "$DIGEST" in
  sha256:*) ;;
  *) echo "invalid digest: $DIGEST" >&2; exit 1 ;;
esac

IMAGE_REF="${IMAGE}@${DIGEST}"
echo "$DIGEST" > image-digest.txt
echo "candidate: $IMAGE_REF"

echo "== 2. Scan exact digest =="
trivy image \
  --exit-code 1 \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  "$IMAGE_REF"

echo "== 3. Sign exact digest =="
cosign sign --yes "$IMAGE_REF"

echo "== 4. Verify signature =="
cosign verify \
  --certificate-identity-regexp 'https://github.com/example/project/.+' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "$IMAGE_REF"

echo "== 5. Promote without rebuilding =="
docker buildx imagetools create \
  --tag "${IMAGE}:production" \
  "$IMAGE_REF"

echo "== 6. Verify promoted digest =="
docker buildx imagetools inspect "${IMAGE}:production"

脚本成立的前提是:

  1. docker login 已经完成;
  2. GIT_SHA 与当前源码提交一致;
  3. CI 身份拥有构建候选标签、写入缓存、写入签名附件和移动生产标签的权限;
  4. Trivy 和 Cosign 的版本已固定;
  5. registry 支持多平台镜像及所需的证明/签名存储;
  6. 生产晋级具有并发控制,避免多个流水线同时修改标签。

实际项目中通常会把第 1 步拆成 build job,把摘要和 provenance 元数据作为 artifact 传递给 scan job、approval job 和 promote job。拆分 job 后应注意:后续 job 必须重新拉取并使用记录的 digest,不能重新计算标签。


十三、失败路径和恢复方式

构建成功但推送失败

表现可能是:

denied: requested access to the resource is denied

应检查:

  • registry 地址是否正确;
  • 登录凭据是否有目标仓库写权限;
  • 多平台 manifest 是否允许写入;
  • 是否因为 tag 已存在且仓库禁止覆盖;
  • 是否是代理或 TLS 配置问题。

不要在推送失败后直接执行扫描或签名,因为远程对象可能并不存在。

cache 导入失败

如果远程缓存不可访问,BuildKit 通常可以退化为无缓存构建,但构建耗时会增加。若缓存导出失败是否让流水线失败,取决于缓存是否是发布必需品:

  • 缓存只是性能优化时,可记录警告并继续;
  • 若企业策略要求可复现的内部构建环境,缓存或依赖仓库不可用可能应当阻断。

无论采用哪种策略,都不能把缓存命中当作构建正确性的证明。

扫描器找不到漏洞数据库

这与“镜像无漏洞”不同。正确状态是“无法完成扫描”。流水线应区分:

scan passed
scan rejected
scan unavailable

scan unavailable 当成 scan passed 会把基础设施故障伪装成安全通过。

签名成功但验证失败

常见原因包括:

  • 签名时使用了候选标签,验证时标签已经移动;
  • 签名和验证使用的 registry 引用不同;
  • keyless 身份正则不匹配;
  • OIDC issuer 不一致;
  • registry 未正确保存签名附件;
  • 签的是单平台 digest,验证的是多平台索引 digest;
  • Cosign 或 registry 对 OCI referrer 的支持不一致。

诊断时先执行:

docker buildx imagetools inspect registry.example.com/team/api:build-7f31c2a

确认候选标签对应的 digest,再使用明确的:

repository@sha256:...

进行签名和验证。

晋级成功但部署拉取了旧镜像

如果部署系统只使用标签,节点上的本地镜像缓存或编排系统的拉取策略可能导致旧内容继续运行。更可靠的做法是:

  • 发布记录保存 digest;
  • 部署清单直接写 digest;
  • 或者标签更新后强制执行明确的 rollout;
  • 节点启动时验证实际拉取到的 digest;
  • 部署策略中设置镜像拉取和签名验证规则。

十四、生产取舍:缓存速度、证明完整性和发布权限

缓存越多不等于越安全

远程缓存可能包含中间阶段结果。如果 Dockerfile 在某一步错误地把秘密复制进文件系统,随后即使最终阶段没有复制该文件,中间缓存也可能扩大敏感数据暴露范围。

应当:

RUN --mount=type=secret,id=token \
    use-secret-without-copying-it

而不是:

COPY token /tmp/token
RUN use-token

同时限制:

  • cache registry 的读取和写入权限;
  • 缓存保留时间;
  • 分支之间的缓存可见范围;
  • 构建日志和 metadata 的访问权限。

扫描阈值不是固定真理

“阻断所有 HIGH”看起来简单,但实际策略还要考虑:

  • 漏洞是否可达;
  • 是否有可用修复;
  • 运行时是否安装了受影响组件;
  • 基础镜像包是否被发行版回补修复但版本号未直观变化;
  • 业务是否有临时豁免。

策略必须可审计:记录漏洞、判断、责任人、期限和替代控制,而不是在命令行中永久加入一个无解释的忽略规则。

签名权限应与晋级权限分离

构建 job 可以有推送候选镜像的权限,但不应自动拥有生产标签修改权限。较稳妥的权限边界是:

build job       -> 写候选标签、写缓存
scan job        -> 读取镜像、读取证明
approval job    -> 产生发布批准事件
promote job     -> 仅修改环境标签
deploy system   -> 验证签名和策略后部署

这样即使构建 job 被利用,攻击者也不能直接把任意镜像标记为生产版本。


十五、用 Compose 做集成验证,用 BuildKit 做确定性交付

Compose 的价值主要在于复现多容器运行环境。例如:

services:
  api:
    build:
      context: ./api
      target: runtime
    image: example/api:ci

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ci-password
      POSTGRES_DB: app

  test:
    image: example/api:ci
    depends_on:
      - api
      - db
    command: ["python", "-m", "pytest", "-q", "integration_tests"]

可以先构建运行时镜像:

docker compose build --parallel api
docker compose run --rm test
docker compose down --volumes

这里的 db 是集成测试依赖,不应被误认为生产镜像构建依赖。Compose 为测试提供网络和服务生命周期;BuildKit 则负责生成最终镜像。两种职责分开后,CI 可以:

  1. 用 Compose 启动依赖服务;
  2. 运行集成测试;
  3. 用 Buildx 构建多平台候选镜像;
  4. 扫描、签名并晋级同一个摘要。

如果集成测试只针对 linux/amd64,而生产还发布 linux/arm64,必须意识到测试覆盖并不等于覆盖所有平台。某些原生扩展、系统调用、基础镜像包和 CPU 行为在不同架构上会不同。必要时应为每个平台分别执行测试,或明确记录测试覆盖范围。


十六、最终检查:流水线究竟证明了什么

一个完整的 Docker CI 流水线至少应能回答以下问题:

  1. 这次发布对应哪个源码提交?
  2. 构建输出的多平台索引 digest 是什么?
  3. 各平台子 manifest digest 是什么?
  4. 使用了哪些基础镜像和依赖?
  5. 哪些步骤命中了 layer cache,哪些只复用了 cache mount?
  6. 使用了哪个 BuildKit builder 和版本?
  7. SBOM 和 provenance 是否附着到目标 digest?
  8. 扫描使用了哪个工具和漏洞数据库版本?
  9. 扫描的是标签还是明确 digest?
  10. 谁签署了该 digest?
  11. 生产标签是否与经过扫描和签名的 digest 相同?
  12. 部署系统是否验证签名、证明和环境策略?
  13. 失败时能否恢复到上一个已验证 digest?

其中最容易被忽略的是第 9 和第 11 项。只要扫描、签名和晋级之间仍然依赖可移动标签,流水线就没有形成可靠的产物链。缓存可以改变构建速度,并行可以改变执行时序,但它们不应改变发布对象;真正贯穿构建、扫描、签名、推送和晋级的主键,应当始终是镜像 digest。


系列导航与关联阅读

官方资料

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