曾经的流水线在每个环境都执行一次 npm install && npm run build。同一提交在预发正常,生产却因为依赖范围解析到新版本而失败。团队第一反应是“代码明明一样”,但真正部署的字节并不一样。

2022 年我们把发布模型改成 build once, deploy many:CI 在受控环境构建一次,后续环境只验证和部署同一 artifactId。

Git 提交经过 CI 生成不可变制品,再按 artifactId 部署和回滚的时序

图 1:环境晋级的是已经验证过的产物,不是一次重新执行构建的机会。

产物必须能回答自己从哪里来

除了压缩包,我们生成 manifest:

{
  "artifactId": "console-20220917-7f31a2c",
  "repository": "git@example/console",
  "gitSha": "7f31a2c",
  "lockfileSha256": "...",
  "builderImage": "node-builder@sha256:...",
  "artifactSha256": "...",
  "sbom": "console-20220917-7f31a2c.spdx.json"
}

Git tag 只能说明源代码位置,不能说明构建器、依赖锁和最终字节。manifest 把这些事实绑定在一起,并由 CI 身份签名。

部署阶段不再拥有编译权限

部署器只做四件事:按 ID 下载;验证签名与 hash;注入环境配置;切换运行版本。它没有修改产物或访问包管理器的权限。配置通过环境变量或独立配置文件提供,不把生产密钥烤进构建包。

verify-signature artifact.tar.gz artifact.sig
echo "$EXPECTED_SHA256  artifact.tar.gz" | sha256sum --check
deploy --artifact "$ARTIFACT_ID" --environment production

缓存加速不能改变结果

CI 缓存用于依赖下载和中间层,但 key 至少包含 lockfile hash、运行时版本和构建配置。缓存 miss 只影响耗时,不能影响依赖集合。流水线定期做无缓存构建,与缓存构建的产物 hash 对比,发现构建中隐藏的时间戳或非确定性输入。

缓存 key 组成 错误风险
包管理器下载 lockfile + registry 污染源或过期包
node_modules OS + Node + lockfile 原生模块 ABI 不匹配
编译中间层 工具链 + 源码 hash 环境变量未进入 key
Docker layer Dockerfile + context 使用可变基础镜像 tag

晋级和回滚都只移动指针

预发通过后,发布记录把同一 artifactId 标记为 production candidate。生产部署不触发新构建。回滚从最近稳定列表选择旧 ID,验证其数据库兼容范围后切换。

制品不能无限保留,但正在运行、处于回滚窗口或被审计冻结的产物不能删除。清理任务依据引用关系,而不是简单“保留最近十个文件”。

SBOM 让漏洞响应有范围

依赖漏洞出现时,我们需要知道哪些运行产物实际包含受影响版本,而不只是哪些仓库 package.json 写过它。每份制品生成 SBOM 并索引,安全团队可以从组件版本反查 artifactId、环境和负责人。

改造后一次发布失败变得更容易解释:如果 hash 不同,是产物传输问题;如果相同但行为不同,看环境配置和外部依赖;不会再把“重新构建一次试试”当成诊断手段。

不可变产物看起来是流水线细节,实际上建立了软件供应链的事实链。只有测试过的字节就是上线的字节,回滚才真正指向一个已知结果。