54.1 不可变制品
一句话
制品的身份是内容哈希(digest),不是 tag;“构建一次、处处晋升”保证测试过的字节就是上线的字节——重建一次,就得重新证明它对。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “同一份代码重建,产物一样” | 构建路径、工具链补丁版、依赖解析、时间戳都可能进产物。本机实测:同一目录强制重建两次,sha256 完全相同;换一个目录重建同一份源码,sha256 就变了(d2bff6… → c3a04c…) |
| “tag 就是版本” | tag 是可写的指针:同一个 1.2.0 可以被推到另一个 digest。回滚时你拿到的“1.2.0”,可能不是当时测试的那一个 |
| “不可变 = 不覆盖 tag” | 那只是一种纪律。真正的不可变是内容寻址:名字可以变,digest 不能变,旧 digest 永远取得到 |
| “不可变制品只指镜像” | 二进制、jar、前端 dist、Helm chart、SBOM、数据库迁移脚本都是制品。判据:会被部署,或被用来重建运行环境 |
不可变的三层含义,缺一层就漏
| 层 | 含义 | 破坏后的症状 |
|---|---|---|
| 内容不可变 | 同一个 digest 的字节永远不变 | 昨天能跑、今天同一个 digest 报错;缓存与回滚全部不可信 |
| 身份不可变 | digest = hash(内容),tag 只是别名 | “同名不同物”:日志里同一个版本号对应两种行为 |
| 晋升不可变 | 测试通过的 digest 原样进生产 | 测试报告全绿,上线仍挂——测的和上的不是一个东西 |
commit ──build──▶ 制品 sha256:634b… ──promote──▶ dev
├──promote──▶ staging ← 测试的就是它
└──promote──▶ prod ← 上线的也是它
tag 1.2.0 ──▶ sha256:634b… (tag 是别名,可以改指;digest 不能改)
被测试的对象必须是一个不可变的标识,否则“测试通过”这句话没有主语。用 tag 部署,等于用“最新那条新闻”当证据。
重建式 vs 晋升式:差的是“证明成本”
| 维度 | 每环境重建 | 构建一次、按 digest 晋升 |
|---|---|---|
| 环境之间的字节 | 理论上相同,实际常不同 | 完全相同 |
| 测试结论 | 只对“那一次构建”有效 | 对每个晋升到的环境都有效 |
| 回滚 | 用旧 commit 重建(可能失败),再冒烟 | 切回旧 digest,分钟级 |
| 审计 | 只知道 commit | commit + digest + SBOM + 签名 + 构建者 |
| 构建成本 | 环境数 × 发布次数 | 发布次数 |
| 主要失败模式 | 重建失败、重建出不同产物 | 制品被清理(保留策略要跟上) |
示例:3 个环境、每天 20 次发布、单次构建 8 分钟。
| 方式 | 每日构建机时 | 相对成本 |
|---|---|---|
| 每环境重建 | 3 × 20 × 8 = 480 分钟 | 100% |
| 构建一次 | 20 × 8 = 160 分钟 | 33% |
回滚同理:重建 8 分钟 + 冒烟 5 分钟 = 13 分钟;切回旧 digest 约 30 秒(示例值,取决于部署工具)。回滚速度不是运维水平,是制品策略的函数。 至于用滚动、蓝绿还是金丝雀把新 digest 换上去,见 55.1 发布策略。
本机实测:构建环境也是输入
同一份源码,用同一个 Go 1.27.1 编译:
| 构建 | 二进制 sha256(前 12 位) | 说明 |
|---|---|---|
目录 a 第一次(-trimpath) | d2bff68f9dc6 | — |
目录 a 强制重建(-a -trimpath) | d2bff68f9dc6 | 输入不变 → 字节不变 |
| 目录 b(同源码、同参数) | c3a04c16dd2b | 只换了构建目录,产物就变了(本机 GOPATH 模式,包导入路径随目录变) |
结论不是“Go 不可复现”,而是:可复现是有条件的——固定的工具链、固定的路径、固定的依赖、固定的构建参数。条件清单越长,越应该只构建一次。构建缓存要按“输入全集”做 key,正是同一个道理(见 52.1 构建与缓存);而 tag 怎么命名、什么时候升主版本,见 51.3 语义化版本与变更日志。
🔑 制品不可变不是为了“规范好看”。它让“测试通过”有一个明确的作用对象:那个 digest。
一个能跑的核对器:digest 晋升与同名 tag 检测
下面这段 Go 程序把三件事跑出来:内容哈希怎么算、同名 tag 怎么指向两个 digest、环境里的“测试过的字节”是否真的上线了。
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
)
// treeDigest:目录的内容哈希。只把「相对路径 + 长度 + 内容」算进去,
// 不算 mtime / 属主——它们不决定内容,却会让同一份内容算出不同身份。
func treeDigest(root string) string {
var files []string
filepath.Walk(root, func(p string, _ os.FileInfo, _ error) error {
files = append(files, p)
return nil
})
sort.Strings(files)
h := sha256.New()
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil { // 目录项不是普通文件(或没权限)就跳过
continue
}
rel, _ := filepath.Rel(root, f)
fmt.Fprintf(h, "%s\x00%d\x00", filepath.ToSlash(rel), len(b))
h.Write(b)
}
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
func build(dir string, override ...string) string {
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "order"), []byte("ELF 1.2.0"), 0o644)
os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("listen: 8080\n"), 0o644)
if len(override) > 0 { // 模拟“只改了一个配置”的重建
os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(override[0]), 0o644)
}
return treeDigest(dir)
}
func main() {
tmp, _ := os.MkdirTemp("", "artifact")
defer os.RemoveAll(tmp)
a := build(filepath.Join(tmp, "build-a"))
b := build(filepath.Join(tmp, "build-b"))
c := build(filepath.Join(tmp, "build-c"), "listen: 8081\n")
fmt.Println("① 同一份源码,三次构建")
fmt.Printf(" build-a %s\n build-b %s\n build-c %s(app.yaml 改了一行)\n", a, b, c)
fmt.Printf(" a == b ?%v;a == c ?%v\n\n", a == b, a == c)
// 仓库里同名 tag 被推了两次,指向两个 digest
fmt.Println("② tag 1.2.0 的推送历史")
for i, d := range []string{a, c} {
fmt.Printf(" push #%d → %s\n", i+1, d)
}
fmt.Println(" 同一个 tag,两个 digest:回滚时拿到哪个取决于时间\n")
// 环境核对:prod 声称来自同一 commit,但 digest 不是测试过的那一个
envs := map[string]string{"dev": a, "staging": a, "prod": c}
fmt.Println("③ 环境核对(基准 = staging 测试过的 digest)")
fmt.Printf(" %-8s %-20s %s\n", "ENV", "DIGEST", "VERDICT")
bad := 0
for _, env := range []string{"dev", "staging", "prod"} {
verdict := "ok"
if envs[env] != a {
verdict = "NOT THE TESTED BYTES"
bad++
}
fmt.Printf(" %-8s %-20s %s\n", env, envs[env][:19], verdict)
}
fmt.Printf("\n 需要重新测试的环境:%d(用 digest 晋升时恒为 0)\n", bad)
if bad > 0 {
os.Exit(1)
}
}
本机运行结果(go run main.go,退出码 1):
① 同一份源码,三次构建
build-a sha256:ea90f7db73081064e7b163f3092e51c900e3d6ed1134afb0cede79e818612e54
build-b sha256:ea90f7db73081064e7b163f3092e51c900e3d6ed1134afb0cede79e818612e54
build-c sha256:8721d3d1020458176f6af08f57c30791eea2af587440d665dbff47d7170b9551(app.yaml 改了一行)
a == b ?true;a == c ?false
② tag 1.2.0 的推送历史
push #1 → sha256:ea90f7db73081064e7b163f3092e51c900e3d6ed1134afb0cede79e818612e54
push #2 → sha256:8721d3d1020458176f6af08f57c30791eea2af587440d665dbff47d7170b9551
同一个 tag,两个 digest:回滚时拿到哪个取决于时间
③ 环境核对(基准 = staging 测试过的 digest)
ENV DIGEST VERDICT
dev sha256:ea90f7db7308 ok
staging sha256:ea90f7db7308 ok
prod sha256:8721d3d10204 NOT THE TESTED BYTES
需要重新测试的环境:1(用 digest 晋升时恒为 0)
注意 ②:仓库没有报错,tag 也没冲突,但 1.2.0 已经指向了另一个东西。tag 只能表达“最近一次”,不能表达“是哪一次”。
生产落地:指名 → 可抄 → 可验 → 可退
指名:构建与推送用 docker buildx(BuildKit),签名与验签用 cosign,制品搬运用 oras 或 crane,仓库用 Harbor 或云托管(ECR / ACR / Artifact Registry)。只用 docker tag 做版本管理不够,因为它不产出可验证的身份。
可抄(本机 Docker daemon 不可用、cosign 未安装,以下命令未验证):
# 构建一次:--provenance 记录“谁用什么构建的”,--sbom 产出依赖清单
docker buildx build \
--platform linux/amd64,linux/arm64 \
--provenance=true --sbom=true \
-t registry.example.com/prod/order:1.2.0 --push .
# 取真身份:digest(预期输出形如 Digest: sha256:...)
docker buildx imagetools inspect registry.example.com/prod/order:1.2.0 | grep -i digest
# keyless 签名:签名身份 = CI 的 OIDC 身份,不落长期私钥
cosign sign --yes registry.example.com/prod/order@sha256:<digest>
# 部署前验签:签名者必须是本仓库的 CI,否则拒绝
cosign verify registry.example.com/prod/order@sha256:<digest> \
--certificate-identity-regexp 'https://github.com/org/repo/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
可验:本节 Go 程序真跑过,输出如上,prod 的 digest 与基准不一致时退出码为 1——可以直接放进 CI 当门禁。镜像侧“header 里的 digest 是否等于字节的 sha256”,用 54.2 镜像与仓库 里的 check-digest.sh 实测。
可退:
- 部署清单只写 digest,回滚 = 把 digest 改回上一个(
kubectl rollout undo或 git revert,见 54.3 部署编排)。 - 仓库保留策略覆盖最大回滚窗口:所有“曾被部署过的 digest”至少留 30 天,而不是只留最近 7 天。
- 签名/验签先用
warn模式观察一周,再切enforce;切换前确认所有历史制品都能验过。 - 回滚的完整设计(保留多少个版本、回滚演练、数据怎么退)见 55.3 回滚设计,不要只做“制品回滚”就当作能回滚。
反模式
| 反模式 | 后果 |
|---|---|
| 每个环境各构建一次 | 测试报告与上线物脱钩;出问题无法用 digest 对齐 |
部署 latest 或可覆盖的业务 tag | 回滚目标不确定,同一版本号出现两种行为 |
| 用 commit sha 当制品身份 | 一次 commit 可构建出多个产物(平台、参数、工具链不同) |
| 制品只保留 7 天 | 第 8 天发现要回滚时,旧 digest 已被 GC |
| 构建不锁依赖、不锁基础镜像 | 同一 commit 今天构建出带新 CVE 的产物,SBOM 对不上 |
| 线上热修直接替换二进制 | 没有 digest、没有记录,下次部署被覆盖,事故无法复盘 |
| 签名只签 tag 不签 digest | tag 一改,签名就指向了别的东西 |
自检
Q1 团队每个环境都重新构建,测试报告全绿,为什么仍可能“上线就挂”?
▸ 答案:因为测试结论只对当次构建的字节成立。构建环境是输入的一部分(工具链版本、构建目录、依赖解析结果、参数),换环境重建可能产出不同字节。上线挂了不是“测试不准”,而是“测的和上的不是同一个东西”。判断一致性只能看 digest,不能看 commit 或版本号。
Q2 为什么 commit sha 不能当制品身份?
▸ 答案:commit 标识源码状态,digest 标识字节。同一个 commit 可以构建出多个不同产物:不同平台(amd64/arm64)、不同工具链、不同构建参数、不同时间。回滚需要的是“恢复到当时那份字节”,只有 digest 能唯一定位;commit 只能告诉你去哪份源码重新构建。
Q3 “不可变制品”和“不覆盖 tag”差在哪?
▸ 答案:不覆盖 tag 是人的纪律,靠评审和自觉;内容寻址是机制,靠哈希。机制下即使有人覆盖 tag 也无害:旧 digest 仍可拉取、仍能回滚,审计日志里记录的是 digest。纪律会失效,机制不会——所以正确做法是“tag 随便写,部署只认 digest”。
Q4 回滚时发现旧版本镜像已经被清理,根因通常是什么?
▸ 答案:保留策略按“时间”或“tag 数量”清理,而不是按“被部署过的 digest”保留。回滚能力是发布能力的上限:如果最大回滚窗口是 30 天(含节假日 hotfix),制品保留就必须 ≥ 30 天,并且要排除“正在被 Deployment 引用”的 digest。另一种相反的事故是保留规则/GC 跑错,把仍在被 Deployment 引用的 digest 删了:此时不扩容没事,一扩容就拉不到镜像。
Q5 哪些东西应该被当作“制品”管理?
▸ 答案:判据是“会被部署,或被用来重建运行环境”。包括:服务二进制/镜像、前端静态包、Helm chart、数据库迁移脚本、SBOM、签名与来源证明。配置不算制品(它按环境注入,见 54.5 配置注入),但配置的 schema 应该随制品走。