一次「全量发布后才发现」的回滚
某客户端在周五晚发布新版本,流水线全部绿灯:类型检查通过、单元测试通过、构建成功、制品已上传。灰度配置写的是 1% -> 5% -> 20% -> 50% -> 100%,但值班同学为了赶在周末前收尾,直接点了「全量」。
两小时后崩溃率从 0.12% 升到 1.8%,客服开始收到闪退反馈。回滚按钮点了,但回滚目标版本没有被记录,团队花了四十分钟才确认上一版是哪个制品。事后复盘发现,流水线并不缺步骤,缺的是每一步的可解释判定:为什么可以推进、为什么必须停、回滚到哪个版本。
流水线不是步骤列表,是门禁图
把阶段画成一条直线,会默认「前面的步骤过了,后面就能跑」。真实依赖更像一张图:构建依赖静态检查,部署依赖构建与安全扫描,而文档检查可以独立并行。每个阶段要回答两个问题:
| 问题 | 输入 | 不满足时 |
|---|---|---|
| 前置阶段是否通过 | 依赖阶段的最终状态 | 阻塞,不执行本阶段检查 |
| 本阶段必需检查是否通过 | 每项检查的 pass/fail/skip | 失败或阻塞,不进入下一阶段 |
「跳过」不能当成通过。一个被跳过的安全扫描,与一个没跑的安全扫描,在证据上是同一件事。门禁只接受明确的 pass;skip、缺失结果、未知前置阶段都应该阻塞,并给出具体原因。
灰度不是比例,是证据
灰度比例只是表象,真正决定是否推进的是观测窗口内的健康指标。一个可执行的判定需要三样输入:
| 输入 | 含义 | 缺失时的后果 |
|---|---|---|
| 观测样本数 | 这个窗口内有多少次启动或请求 | 少量样本触发误判 |
| 指标阈值 | 错误率、崩溃率、P95 的上限 | 没有统一口径,靠感觉推进 |
| 当前档位 | 已经放量到多少 | 跳档、重复推进或回滚到错误版本 |
推进规则可以压缩成四句话:指标越界立即回滚;样本不足保持不动;健康且未满量推进到下一档;健康且已满量标记完成。四句话都不依赖具体指标名,换成启动成功率或 ANR 率也能复用。
阈值比较要用「严格大于」:等于阈值视为健康,否则阈值本身会变成一个无法达到的数字。样本数则要用「小于」判定不足:刚好达到最小样本数时,应该允许下结论。
回滚必须记录目标版本
回滚不是「再发一次上一版」这么简单。制品可能被覆盖,渠道可能有时滞,配置也可能已经变化。回滚决策至少要记录:版本号、制品键、回滚时的灰度比例、触发原因与时间。没有制品键,回滚只能靠人找包;没有原因,复盘只能靠猜。
版本记录还要能按版本过滤并按时间排序。发布过程中事件是追加的,读取时不能假设写入顺序就是时间顺序;并发流水线、补录与时钟漂移都会打乱顺序。
把门禁、灰度与记录写成代码
下面的模块把流水线门禁、灰度计划校验、健康判定、推进决策与版本记录分开实现。每个函数只做一件事,因此可以单独测试,也可以被不同的发布系统复用。
// file: src/build/release-pipeline.ts
// tier: L1
// verify: node --test test/build/release-pipeline.test.ts
export type CheckStatus = "pass" | "fail" | "skip";
export type StageSpec = {
id: string;
requires: string[];
requiredChecks: string[];
};
export type CheckOutcome = {
stage: string;
check: string;
status: CheckStatus;
};
export type GateStatus = "passed" | "failed" | "blocked";
export type Gate = {
stage: string;
status: GateStatus;
blockers: string[];
};
export type PipelineReport = {
allowed: boolean;
gates: Gate[];
firstBlocker: string | null;
};
export type HealthMetric = "errorRate" | "crashRate" | "p95Ms";
export type HealthThresholds = Record<HealthMetric, number>;
export type HealthObservation = {
windowMs: number;
samples: number;
metrics: Record<HealthMetric, number>;
};
export type HealthVerdict = {
healthy: boolean;
reason: "healthy" | "breach" | "insufficient-samples";
breaches: string[];
};
export type RolloutAction = "promote" | "hold" | "rollback" | "complete";
export type RolloutDecision = {
action: RolloutAction;
targetPercent: number;
reason: string;
};
export type ReleaseEvent = {
version: string;
artifactKey: string;
percent: number;
action: RolloutAction;
at: number;
note?: string;
};
export const HEALTH_METRICS: HealthMetric[] = [
"errorRate",
"crashRate",
"p95Ms",
];
export function evaluatePipeline(
stages: StageSpec[],
outcomes: CheckOutcome[],
): PipelineReport {
const observed = new Map<string, CheckStatus>();
for (const outcome of outcomes) {
observed.set(`${outcome.stage}\u0000${outcome.check}`, outcome.status);
}
const gateByStage = new Map<string, Gate>();
const gates: Gate[] = [];
for (const stage of stages) {
const blockers: string[] = [];
for (const requirement of stage.requires) {
const gate = gateByStage.get(requirement);
if (!gate) {
blockers.push(`requires:${requirement}:unknown`);
} else if (gate.status !== "passed") {
blockers.push(`requires:${requirement}`);
}
}
for (const check of stage.requiredChecks) {
const status = observed.get(`${stage.id}\u0000${check}`);
if (status === undefined) blockers.push(`missing:${check}`);
else if (status === "fail") blockers.push(`fail:${check}`);
else if (status === "skip") blockers.push(`skip:${check}`);
}
let status: GateStatus = "passed";
if (blockers.length > 0) {
status = blockers.some((item) => item.startsWith("fail:"))
? "failed"
: "blocked";
}
const gate: Gate = { stage: stage.id, status, blockers };
gateByStage.set(stage.id, gate);
gates.push(gate);
}
const allowed = gates.every((gate) => gate.status === "passed");
const firstBlocker =
gates.find((gate) => gate.blockers.length > 0)?.blockers[0] ?? null;
return { allowed, gates, firstBlocker };
}
export function validatePlan(steps: number[]): string[] {
const errors: string[] = [];
if (steps.length === 0) errors.push("empty");
for (let i = 0; i < steps.length; i += 1) {
const step = steps[i];
if (!Number.isInteger(step) || step < 1 || step > 100) {
errors.push(`range:${i}`);
}
if (i > 0 && step <= steps[i - 1]) errors.push(`order:${i}`);
}
if (steps.length > 0 && steps[steps.length - 1] !== 100) {
errors.push("final");
}
return errors;
}
export function nextStep(steps: number[], currentPercent: number): number | null {
for (const step of steps) {
if (step > currentPercent) return step;
}
return null;
}
export function stepIndex(steps: number[], percent: number): number {
return steps.indexOf(percent);
}
export function evaluateHealth(
observation: HealthObservation,
thresholds: HealthThresholds,
minSamples: number = 100,
): HealthVerdict {
if (observation.samples < minSamples) {
return {
healthy: false,
reason: "insufficient-samples",
breaches: [],
};
}
const breaches: string[] = [];
for (const metric of HEALTH_METRICS) {
const value = observation.metrics[metric];
if (value > thresholds[metric]) {
breaches.push(`${metric}=${value}>${thresholds[metric]}`);
}
}
if (breaches.length > 0) {
return { healthy: false, reason: "breach", breaches };
}
return { healthy: true, reason: "healthy", breaches: [] };
}
export function decideRollout(input: {
plan: number[];
currentPercent: number;
observation: HealthObservation;
thresholds: HealthThresholds;
minSamples?: number;
}): RolloutDecision {
const {
plan,
currentPercent,
observation,
thresholds,
minSamples = 100,
} = input;
const index = stepIndex(plan, currentPercent);
if (index < 0) {
return {
action: "hold",
targetPercent: currentPercent,
reason: "unknown-step",
};
}
const planErrors = validatePlan(plan);
if (planErrors.length > 0) {
return {
action: "hold",
targetPercent: currentPercent,
reason: `invalid-plan:${planErrors.join(",")}`,
};
}
const verdict = evaluateHealth(observation, thresholds, minSamples);
if (verdict.reason === "breach") {
return {
action: "rollback",
targetPercent: 0,
reason: verdict.breaches.join(";"),
};
}
if (!verdict.healthy) {
return {
action: "hold",
targetPercent: currentPercent,
reason: verdict.reason,
};
}
if (currentPercent >= 100) {
return {
action: "complete",
targetPercent: 100,
reason: "healthy-at-full",
};
}
const target = plan[index + 1];
return {
action: "promote",
targetPercent: target,
reason: `healthy:${currentPercent}->${target}`,
};
}
export function recordRelease(
history: ReleaseEvent[],
event: ReleaseEvent,
): ReleaseEvent[] {
return [...history, { ...event }];
}
export function releaseTimeline(
history: ReleaseEvent[],
version: string,
): ReleaseEvent[] {
return history
.filter((event) => event.version === version)
.map((event) => ({ ...event }))
.sort((a, b) => a.at - b.at);
}
export function latestRelease(
history: ReleaseEvent[],
version: string,
): ReleaseEvent | undefined {
const timeline = releaseTimeline(history, version);
return timeline[timeline.length - 1];
}
evaluatePipeline 先算前置阶段,再算本阶段检查。前置阶段失败时,本阶段即使检查全过也只能是 blocked,而不是 passed;这个区分很重要,前者说明「没轮到」,后者说明「已经验证」。
decideRollout 把健康判定与档位推进放在一起,但把「未知档位」与「非法计划」单独处理。两者都不应该触发回滚:回滚是针对已发布版本的健康问题,配置错误应该在推进前被拦住。
recordRelease 返回新数组而不是原地追加,是为了让调用方可以保留旧快照。发布系统经常需要「决策前」与「决策后」两份记录做对比,原地修改会让审计日志失去意义。
测试与真实输出
在实验工程根目录执行:
node --test test/build/release-pipeline.test.ts
本机一次运行的真实输出末尾如下,耗时随机器变化:
✔ 非法计划时拒绝推进 (0.034042ms)
✔ 版本时间线按时间排序且只含目标版本 (0.084083ms)
✔ 追加记录不修改原数组 (0.053167ms)
✔ 最新记录取时间线末尾 (0.043458ms)
ℹ tests 26
ℹ suites 0
ℹ pass 26
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.875958
用例覆盖了门禁的四种状态、计划校验、阈值边界、四类推进决策与版本时间线。它们不测试真实流量、渠道时滞与人工审批,那些需要在发布系统里做集成验证。
判据与反例
判断一条发布流水线是否可信,看下面五条:
| 判据 | 通过的样子 | 反例 |
|---|---|---|
| 门禁可解释 | 阻断时给出阶段与检查名 | 只显示「发布失败」 |
| 跳过不等于通过 | skip 阻塞必需检查 | 跳过的扫描被记成绿色 |
| 推进有证据 | 样本数与阈值同时满足 | 1% 放量十分钟就全量 |
| 回滚有目标 | 记录版本与制品键 | 回滚时现找上一版安装包 |
| 记录可追溯 | 事件带时间、原因与比例 | 只在聊天群里说「回滚了」 |
第一个反例是「用平均错误率代替分档观测」。全量错误率 0.3% 看起来正常,但 1% 灰度组可能是 5%;平均值把问题稀释了。健康判定必须针对当前灰度组,而不是全站。
第二个反例是「样本不足时继续推进」。低流量版本在十分钟内可能只有几十次启动,一次崩溃就会让崩溃率看起来爆表;反过来,样本太少也可能掩盖真实问题。正确动作是保持当前档,延长观测窗口,而不是二选一。
第三个反例是「回滚后不清理灰度状态」。回滚到 0% 后,灰度标记仍然指向新版本,下一次发布可能把旧标记当成新证据。版本记录要能表达「已回滚」,而不只是「比例为 0」。
小结
发布流水线的核心不是自动化,而是可解释的决策。
- 阶段门禁要区分失败、阻塞与跳过,只接受明确的通过。
- 灰度推进由样本数与健康阈值共同决定,指标越界立即回滚。
- 回滚必须记录目标版本与制品键,版本事件要能按时间追溯。
先把「为什么放行」写清楚,再追求发布速度。否则速度只是把未知风险更快地送到用户面前。
阅读导航:上一节:6.2 产物治理与缓存 · 下一节:7.1 日志、指标与追踪的最小契约 。
常见错误与边界
- 把跳过当成通过。被跳过的安全扫描没有产生任何证据,不应放行。
- 前置阶段失败仍执行后续检查。后续结果没有意义,还会掩盖真正的阻断点。
- 只看全站平均指标。灰度组的问题会被大盘稀释,越放量越晚发现。
- 样本不足就下结论。少量请求下的崩溃率与延迟都不稳定。
- 用严格大于等于比较阈值。阈值本身变成不可达,团队会不断调高数字。
- 灰度计划允许跳档。1% 直接到 100% 等于没有灰度,只是延迟了全量时间。
- 回滚不记录制品键。制品被覆盖或渠道时滞后,无法确认回滚到哪一版。
- 版本记录原地修改。审计日志失去前后快照,复盘无法还原决策现场。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。