曾经的流水线在每个环境都执行一次 npm install && npm run build。同一提交在预发正常,生产却因为依赖范围解析到新版本而失败。团队第一反应是“代码明明一样”,但真正部署的字节并不一样。
2022 年我们把发布模型改成 build once, deploy many: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 不同,是产物传输问题;如果相同但行为不同,看环境配置和外部依赖;不会再把“重新构建一次试试”当成诊断手段。
不可变产物看起来是流水线细节,实际上建立了软件供应链的事实链。只有测试过的字节就是上线的字节,回滚才真正指向一个已知结果。