56.2 故障演练

一句话

演练不是“跑一遍脚本”,而是在受控条件下检验人、流程、工具三件事;判定标准是“多久恢复”,不是“有没有报错”。

先破除四个误区

误区 现实
“演练 = 混沌实验” 混沌实验验证系统假设;演练验证人 + 流程 + 工具能否在时限内恢复。前者是工具,后者是组织活动
“演练 = 灾备切换” 灾备切换只是演练的一种。单点故障、依赖超时、消息积压、证书过期,都需要单独演
“演练给别人看” 表演式演练只走成功路径;真正有价值的是暴露“决策慢、权限缺、runbook 过期”
“演练完就结束了” 不把发现变成行动项、不复演,下一次还是同样的断点

演练和混沌实验的分工

维度 混沌实验(56.1 混沌工程) 故障演练
目的 验证“系统在故障下是否保持稳态” 验证“人/流程/工具能否在 RTO 内恢复”
频率 可持续、可自动化(每天/每周) 阶段性(季度/半年),有组织成本
参与人 SRE/研发为主 指挥、业务、客服、值班、管理层
判定 稳态指标是否成立 恢复时间、决策质量、通报是否到位
产出 系统缺陷、容量结论 流程断点、权限缺口、runbook 修订

一句话:混沌实验问“系统扛不扛得住”,演练问“人救不救得回来”。

五种演练,按成本从低到高

类型 做什么 成本 频率 适合检验
桌面推演 给定故障剧本,角色口头推演决策 1–2 小时 月度/季度 决策链、通报、职责是否清晰
单点故障注入 杀一个 Pod/断一个依赖 半天 季度 自愈、告警、runbook 是否有效
全链路故障演练 主库切换 + 应用重连 + 数据校验 1 天 半年 跨团队协作、RTO、数据一致性
灾备切换 切到备机房/备集群(见 36.4 两地三中心与多活) 1–3 天 半年/年 备份可用性、DNS/流量切换、冷/温/热备(见 36.3 冷备 / 温备 / 热备)
红蓝对抗 红队制造故障,蓝队不知情响应 1–2 天 年 真实响应能力、监控盲区、沟通

成本越高,越要先做低成本的:桌面推演几乎不花钱,却能最早暴露“谁拍板、谁通报、谁负责”的职责不清。

评分:用阶段目标,不用“感觉还行”

一次演练至少拆四段:发现 → 决策 → 执行 → 验证。每段有目标时长,实际超出就扣分:

结果 判据 处理
PASS 实际 ≤ 目标 记录基线
WARN 目标 < 实际 ≤ 1.5 × 目标 分析原因,下一轮复演
FAIL 实际 > 1.5 × 目标 视为流程断点,必须改进并复演

总时长还要对照 RTO:总时长没超,但某段 FAIL,仍然算未通过——因为更坏的故障会把这段放大。

下面的程序把一次“主库故障切换演练”评分,并检查故障覆盖度是否过期:

package main

import "fmt"

type phase struct {
	name           string
	target, actual int // 秒
}

type drill struct {
	name   string
	rto    int // 恢复时间目标(秒)
	phases []phase
}

type result struct {
	phase  phase
	status string
	detail string
}

func evaluate(d drill) ([]result, bool) {
	var rs []result
	ok := true
	for _, p := range d.phases {
		r := result{phase: p}
		switch {
		case p.actual <= p.target:
			r.status, r.detail = "PASS", "在目标内"
		case p.actual <= p.target*3/2:
			r.status = "WARN"
			r.detail = fmt.Sprintf("超出 %d%%(未达 1.5 倍上限)", (p.actual-p.target)*100/p.target)
		default:
			r.status = "FAIL"
			r.detail = fmt.Sprintf("超出 %d%%,流程有断点", (p.actual-p.target)*100/p.target)
			ok = false
		}
		rs = append(rs, r)
	}
	return rs, ok
}

func main() {
	d := drill{
		name: "主库故障切换演练", rto: 1800,
		phases: []phase{
			{"发现(告警到认领)", 120, 90},
			{"决策(认领到决定切换)", 300, 500},
			{"执行(切换 + 应用重连)", 600, 540},
			{"验证(核心链路 + 数据校验)", 300, 360},
		},
	}
	rs, ok := evaluate(d)
	fmt.Println("== 演练评分:" + d.name + " ==")
	fmt.Printf("%-26s %8s %8s %-6s %s\n", "阶段", "目标(s)", "实际(s)", "结果", "说明")
	total, target := 0, 0
	for _, r := range rs {
		fmt.Printf("%-26s %8d %8d %-6s %s\n", r.phase.name, r.phase.target, r.phase.actual, r.status, r.detail)
		total += r.phase.actual
		target += r.phase.target
	}
	fmt.Printf("\n总耗时 %ds / RTO %ds(各阶段目标合计 %ds)\n", total, d.rto, target)
	if total <= d.rto && ok {
		fmt.Println("结论:通过")
	} else if total <= d.rto {
		fmt.Println("结论:未通过——总时长没超,但流程有 FAIL 阶段,问题会在更坏的情况下放大")
	} else {
		fmt.Println("结论:未通过——已超过 RTO")
	}

	// 覆盖率:同一类故障太久没演,等于没覆盖
	type item struct {
		failure  string
		lastDays int // 距上次演练天数;-1 = 从未
	}
	items := []item{
		{"主库故障", 12}, {"机房断电", 220}, {"缓存全挂", 95},
		{"消息积压", 400}, {"证书过期", -1},
	}
	fmt.Println("\n== 演练覆盖度(超过 180 天视为过期)==")
	fresh := 0
	for _, it := range items {
		status := "ok"
		switch {
		case it.lastDays < 0:
			status = "从未演练"
		case it.lastDays > 180:
			status = "已过期"
		default:
			fresh++
		}
		last := fmt.Sprintf("%d 天前", it.lastDays)
		if it.lastDays < 0 {
			last = "—"
		}
		fmt.Printf("%-10s 上次:%-8s %s\n", it.failure, last, status)
	}
	fmt.Printf("有效覆盖 %d/%d(%d%%)\n", fresh, len(items), fresh*100/len(items))
}

本机运行结果:

== 演练评分:主库故障切换演练 ==
阶段                            目标(s)    实际(s) 结果     说明
发现(告警到认领)                       120       90 PASS   在目标内
决策(认领到决定切换)                     300      500 FAIL   超出 66%,流程有断点
执行(切换 + 应用重连)                   600      540 PASS   在目标内
验证(核心链路 + 数据校验)                 300      360 WARN   超出 20%(未达 1.5 倍上限)

总耗时 1490s / RTO 1800s(各阶段目标合计 1320s)
结论:未通过——总时长没超,但流程有 FAIL 阶段,问题会在更坏的情况下放大

== 演练覆盖度(超过 180 天视为过期)==
主库故障       上次:12 天前    ok
机房断电       上次:220 天前   已过期
缓存全挂       上次:95 天前    ok
消息积压       上次:400 天前   已过期
证书过期       上次:—        从未演练
有效覆盖 2/5(40%)

两个结论:

  1. 总时长 1490s < RTO 1800s,但演练仍判“未通过”:决策阶段 500s 超了 66%,说明“谁有权拍板切换”没定清楚。更坏的故障(比如主库恢复失败)会把这段放大成小时级。
  2. 覆盖度 40%:机房断电、消息积压半年没演,证书过期从没演过。覆盖度不是“演练次数”,是“最近 180 天内演过哪些故障模式”。

组织一次演练:角色与护栏

角色 职责 没有会怎样
指挥 决定开始/中止、控制节奏 现场失控,没人敢喊停
注入 执行故障注入、记录时间 故障注入不可控,无法归因
观察员 独立记录现象与延迟 事后只有“感觉”,没有数据
记录员 维护时间线、决策与通报 复盘无材料
业务/客服 判断用户影响、准备话术 用户先于公司知道
安全员 守住护栏与回滚开关 小故障变事故

护栏清单:

  • 明确的中止条件(错误率、P99、资损阈值)与一票否决权(指挥/安全员)。
  • 预置回滚脚本并演练过;回滚不需要临时申请权限。
  • 避开大促、财务结账、重大发布窗口。
  • 提前通知业务/客服/上下游;对外不承诺“演练期间无影响”。
  • 演练前确认值班表与升级路径可用(见 56.3 on-call (值班 / 升级路径 / runbook));演练中真出事故,按 56.5 事故响应 处理,不要“为了演练硬扛”。
  • 演练期间只做演练相关变更,避免叠加变量。

演练之后:把发现变成行动项

发现类型 行动 验收
发现慢 改告警阈值/窗口,或补症状告警 下次演练发现时间达标
决策慢 明确授权、预写决策树、减少审批 决策阶段 ≤ 目标
执行慢 一键脚本、预授权、减少手工步骤 执行阶段 ≤ 目标
数据不一致 修切换逻辑 + 校验脚本 校验通过且可重复
runbook 过期 更新命令、链接、联系人 新人照 runbook 能完成

行动项要进 backlog,有 owner、截止时间、验证方式;下次演练必须复演同一场景,否则无法证明改进有效。复盘方法见 56.6 无指责复盘,通用的复盘方法论见 82.6 复盘方法论(待补);演练与验证的通用方法见 36.7 演练与验证。

生产落地:指名 → 可抄 → 可验 → 可退

指名:故障注入用 Chaos Mesh(网络/ Pod / 压力/IO);演练编排与记录用 Chaos Mesh Workflow 或团队现有的演练平台;沟通用事故群 + 状态页模板。灾备切换演练按 36.4 两地三中心与多活 的切换手册执行。

可抄(本机无集群,以下命令未验证):

# 1) 演练计划:把注入、观察、回滚写成三步,贴在事故群里
#    T+0   注入:主库只读 60s
#    T+60  观察:错误率、P99、切换状态
#    T+300 回滚:恢复主库写(或确认已切换)
kubectl apply -f drill/mysql-readonly.yaml
kubectl -n chaos-testing get networkchaos,podchaos

# 2) 记录四段时间戳(发现/决策/执行/验证),演练后直接算分
# 3) 回滚:删除注入 CR,并确认指标恢复
kubectl delete -f drill/mysql-readonly.yaml

可验:本节评分器真跑过,输出如上;它验证的是“阶段目标 + 覆盖度过期”两条判定,不依赖集群。演练至少验证:四段时间戳齐全、回滚在目标内完成、核心指标恢复、行动项有 owner/截止/验证。

可退:演练失控时,指挥/安全员有权立即删除注入 CR、切回旧路径、停止演练;回滚脚本要独立于演练脚本,避免“回滚也依赖刚才被破坏的东西”。演练失败不可怕,没有能力中止才可怕。

反模式

反模式 后果
只演成功路径 真实故障里的“决策卡住/权限缺失”永远暴露不了
没有业务方参与 用户影响判断失真,客服话术缺失
演练期间叠加变更 无法归因,结论不可信
没有中止条件与一票否决 小故障被放大成真事故
只记录“恢复了吗”,不记时间 无法量化 RTO,也无法比较改进
行动项没有 owner/截止 下次演练同样断点
演完不复演 无法证明改进有效
把演练安排在大促/结账期 风险叠加,业务受损

自检

Q1 混沌实验和故障演练,最核心的区别是什么?

▸ 答案:混沌实验验证“系统在故障下是否保持稳态”,判定靠指标假设;故障演练验证“人、流程、工具能否在时限内恢复”,判定靠恢复时间与决策质量。前者可以自动化、持续跑;后者需要组织角色、沟通与复盘。两者互补:实验发现系统缺陷,演练发现流程缺陷。

Q2 为什么总时长没超 RTO,演练仍可能判“未通过”?

▸ 答案:因为总时长会掩盖单阶段断点。比如决策用了 500s(超目标 66%),但执行很快,总时长仍可能落在 RTO 内;更坏的故障会把决策慢放大成小时级。所以评分要按阶段看,任何一段 FAIL 都视为未通过,并要求复演。

Q3 演练覆盖度为什么按“最近 180 天”算,而不是按“总次数”?

▸ 答案:系统在变(依赖升级、架构调整、人员轮换),半年前的结论会过期。总次数只说明“做过”,不说明“现在还能做”。按时间窗统计能逼团队轮换场景,避免“主库切换演了十次,机房断电从没演过”。

Q4 一次演练最少要记录哪些数据?

▸ 答案:四段时间戳(发现、决策、执行、验证)、每个阶段的决策与通报、回滚耗时、核心指标恢复时间、暴露的问题与行动项。没有时间戳就无法算 RTO,没有决策记录就无法判断是“工具慢”还是“人慢”。

Q5 演练中“安全员”的一票否决权为什么必要?

▸ 答案:演练的注入是真实的,业务受损也是真实的。指挥容易陷入“再观察一下”的沉没成本,安全员的独立否决权提供外部约束。没有它,中止条件就只是文档里的一行字。一票否决权要写进演练计划,并在演练前明确告知所有参与者。

关联