「包传上去了」
一次发布事故的复盘记录里,最刺眼的一句话是:「包传上去了,以为就好了。」
这句话背后是一条断掉的流水线:构建产物没有签名校验,上传后没有 CDN 校验和比对,灰度没有观测窗口,回滚没有保留旧版本。每个环节单独看都没错,连起来却没有任何一处能回答「现在这个版本能不能给全量用户」。
交付流水线的核心不是自动化工具,而是阶段之间的依赖与检查。本节把它写成一张可运行的状态图。
六个阶段与它们的依赖
卷二涉及的发布阶段可以收成六个:
| 阶段 | 做什么 | 依赖 | 通过检查 |
|---|---|---|---|
| build | 打出资源包与安装包 | 无 | 产物哈希 |
| sign | 签名与完整性校验 | build | 签名验证 |
| upload | 上传 CDN 与商店 | sign | 校验和一致 |
| gray | 按比例放量 | upload | 灰度指标在预算内 |
| full | 全量发布 | gray | 版本收敛 |
| rollback | 回到上一可用版本 | upload | 旧版本仍可取 |
注意 rollback 只依赖 upload,不依赖 gray。这是一个刻意的设计:回滚能力必须在灰度之前就存在。如果等到灰度出问题才去准备旧版本,回滚就已经晚了。
把流水线写成代码
下面的模块做三件事:校验阶段定义是否有重复或未知依赖、给出拓扑顺序、根据已完成阶段计算下一步可执行什么。
// file: src/delivery/pipeline.ts
// tier: L1
// verify: node --test test/delivery/pipeline.test.ts
export type StageId = "build" | "sign" | "upload" | "gray" | "full" | "rollback";
export interface Stage {
readonly id: StageId;
readonly requires: readonly StageId[];
readonly check: string;
}
export const PIPELINE: readonly Stage[] = [
{ id: "build", requires: [], check: "artifacts hashed" },
{ id: "sign", requires: ["build"], check: "signature verified" },
{ id: "upload", requires: ["sign"], check: "cdn checksum matched" },
{ id: "gray", requires: ["upload"], check: "gray metrics within budget" },
{ id: "full", requires: ["gray"], check: "convergence reached" },
{ id: "rollback", requires: ["upload"], check: "previous version available" },
];
export interface PipelineReport {
readonly ready: readonly StageId[];
readonly blocked: readonly StageId[];
readonly unknown: readonly string[];
}
export function validatePipeline(stages: readonly Stage[]): readonly string[] {
const problems: string[] = [];
const ids = new Set<string>();
for (const stage of stages) {
if (ids.has(stage.id)) {
problems.push(`duplicate stage: ${stage.id}`);
}
ids.add(stage.id);
}
for (const stage of stages) {
for (const dep of stage.requires) {
if (!ids.has(dep)) {
problems.push(`unknown dependency: ${stage.id} -> ${dep}`);
}
}
}
return problems;
}
export function topoOrder(stages: readonly Stage[]): readonly StageId[] {
const byId = new Map(stages.map((stage) => [stage.id, stage]));
const visiting = new Set<StageId>();
const done = new Set<StageId>();
const order: StageId[] = [];
function visit(id: StageId): void {
if (done.has(id)) {
return;
}
if (visiting.has(id)) {
throw new Error(`cycle detected at ${id}`);
}
const stage = byId.get(id);
if (!stage) {
throw new Error(`unknown stage: ${id}`);
}
visiting.add(id);
for (const dep of stage.requires) {
visit(dep);
}
visiting.delete(id);
done.add(id);
order.push(id);
}
for (const stage of stages) {
visit(stage.id);
}
return order;
}
export function evaluatePipeline(
stages: readonly Stage[],
completed: readonly string[],
): PipelineReport {
const done = new Set(completed);
const known = new Set(stages.map((stage) => stage.id));
const unknown = completed.filter((id) => !known.has(id));
const ready: StageId[] = [];
const blocked: StageId[] = [];
for (const stage of stages) {
if (done.has(stage.id)) {
continue;
}
const satisfied = stage.requires.every((dep) => done.has(dep));
if (satisfied) {
ready.push(stage.id);
} else {
blocked.push(stage.id);
}
}
return { ready, blocked, unknown };
}
export function formatPipeline(stages: readonly Stage[]): string {
const header = "stage\trequires\tcheck";
const rows = stages.map(
(stage) => `${stage.id}\t${stage.requires.join(",") || "-"}\t${stage.check}`,
);
return [header, ...rows].join("\n");
}
validatePipeline 检查两类结构错误:重复阶段与未知依赖。topoOrder 用深度优先遍历给出一个合法顺序,并在遇到环时抛错。evaluatePipeline 不执行任何阶段,只回答「现在能做哪一步、还差什么」。
这种「只算状态、不执行动作」的写法很重要:流水线逻辑可以脱离 Jenkins、GitHub Actions 或自研平台单测,工具换了,判断规则不用重写。
还有一个细节:topoOrder 对同一层的阶段不保证稳定顺序。默认定义里 gray 与 rollback 都只依赖 upload,谁先谁后取决于遍历顺序。生产实现应显式排序,或者在展示时把二者标成并行分支,不要让运营误以为必须先灰度再准备回滚。
一次正常发布
用默认阶段跑一遍,从零开始的就绪状态是:
| 已完成 | ready | blocked |
|---|---|---|
| 无 | build | sign, upload, gray, full, rollback |
| build | sign | upload, gray, full, rollback |
| build, sign | upload | gray, full, rollback |
| build, sign, upload | gray, rollback | full |
| build, sign, upload, gray | full | — |
| 全部 | — | — |
第三行是最值得注意的:上传完成后,gray 与 rollback 同时就绪。这意味着团队在放量之前,必须已经确认旧版本可用、回滚路径可走。很多团队把回滚当成「出事再说」,而流水线在这里强制它成为并行选项。
灰度阶段的三个检查
gray 阶段不是一个「点一下」的动作,它至少有三次检查:
- 进入前:灰度包与全量包是否为同一产物?很多事故来自「灰度包和全量包不是一次构建」。
- 进行中:崩溃率、下载成功率、关键转化是否在预算内?指标要事先定,不能边看边改。
- 结束前:版本收敛是否达到阈值?灰度放量不等于用户升级,老版本可能长期滞留。
三次检查都通过,full 才就绪。任何一个失败,走 rollback 分支,而不是「再观察一下」。
这条规则听上去很硬,但它换来的是可预期:每次灰度失败都走同一条路径,复盘时不需要争论「当时为什么不回滚」。
双案例的流水线差异
消消与方舟共用一套交付平台,但门禁参数完全不同:
| 项目 | 消消 | 方舟 |
|---|---|---|
| 热更频率 | 双周一次 | 每周一次 |
| 单次热更体积 | 平均 12MB | 平均 45MB |
| 回滚时限 | 配置 30 分钟 | 战斗数值 15 分钟 |
| 灰度起点 | 5% | 2% |
| 全量条件 | 下载成功率 ≥97% | 下载成功率 ≥98% 且战报一致率 ≥99.5% |
| 旧版本保留 | 最近 3 个配置版本 | 最近 2 个数值版本 + 1 个整包 |
方舟的灰度起点更低、回滚时限更短,因为它的单场战斗更长、数值影响更大;消消的配置回滚可以放宽到 30 分钟,因为它的活动配置即使短暂异常,也不会造成不可逆的战斗结果。
同一套流水线,参数必须按产品风险重新定。 直接复制另一款产品的门禁参数,是交付事故的常见来源。
流水线之外的三件事
代码只能表达阶段与依赖,有三件事必须靠流程保证:
- 产物唯一:灰度包、全量包、回滚包必须来自同一次构建,用哈希关联。
- 变更可追溯:每个阶段记录谁触发、改了什么、依据哪次评审。
- 演练常态化:回滚不能只写在文档里,每季度至少演练一次,记录实际耗时。
这三件事没有一件能靠单测覆盖,但它们决定了流水线在真实事故中是否可用。门禁的价值不在于拦住多少版本,而在于出事时能不能按预期回退。
与相邻章节的关系
本节依赖第 10 章的热更政策边界、第 11 章的灰度与回滚。那两章分别给出内容红线与回滚策略,本节把它们组织成一条有依赖关系的流水线。
上一节 13.1 给出指标矩阵,本节把矩阵接进 gray 的进入与结束条件;下一节 13.3 把矩阵、流水线与附录 C 的清单合并成卷二评审门禁。
小结
- 发布流水线有六个阶段:build、sign、upload、gray、full、rollback。
- 回滚只依赖 upload,必须在灰度之前就具备,而不是出事后再准备。
- 流水线逻辑应与工具解耦:只算状态、不执行动作,才能单测。
- 灰度阶段有三次检查:产物一致、指标在预算内、版本收敛达标。
- 双案例的门禁参数不同,按产品风险重新定,不能直接复制。
- 产物唯一、变更可追溯、回滚演练,是代码之外必须保证的三件事。
- 门禁的价值在事故时体现,不在于平时拦了多少版本。
阅读导航:上一节:13.1 网络与资源指标矩阵 · 下一节:13.3 卷二评审与进入卷三 。
常见错误与边界
- 把上传当发布。 上传只是第三阶段,灰度与全量各有检查;上传成功不代表用户能用。
- 灰度包与全量包分开构建。 两次构建的产物可能不一致,事故时无法判断问题出在代码还是构建。
- 回滚没有旧版本。 只保留最新包,出事时只能「发新版本修复」,时限不可控。
- 灰度只看崩溃率。 下载成功率、关键转化、版本收敛同样重要,单一指标会漏掉静默失败。
- 门禁参数照抄。 消消与方舟的风险不同,阈值必须按产品重定。
- 流水线逻辑写死在 CI 配置里。 换工具就要重写,且无法单测;应把状态判定抽成独立模块。
- 没有产物哈希。 无法证明灰度包与全量包是同一产物,复盘时只能靠记忆。
- 回滚从不演练。 文档里的 15 分钟,第一次实战可能是 90 分钟。
- 把
full当终点。 全量之后还有版本收敛与旧版本清理,否则包体与 CDN 成本会持续累积。 - 失败后「再观察一下」。 灰度检查失败应直接走回滚分支,观察窗口不能替代判定。
- 忽略商店审核时间。 整包发布受审核影响,流水线排期必须把审核时长算进去。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。