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%)
两个结论:
- 总时长 1490s < RTO 1800s,但演练仍判“未通过”:决策阶段 500s 超了 66%,说明“谁有权拍板切换”没定清楚。更坏的故障(比如主库恢复失败)会把这段放大成小时级。
- 覆盖度 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 演练中“安全员”的一票否决权为什么必要?
▸ 答案:演练的注入是真实的,业务受损也是真实的。指挥容易陷入“再观察一下”的沉没成本,安全员的独立否决权提供外部约束。没有它,中止条件就只是文档里的一行字。一票否决权要写进演练计划,并在演练前明确告知所有参与者。
关联
- 前置:56.1 混沌工程、36.4 两地三中心与多活、36.3 冷备 / 温备 / 热备
- 并列:56.3 on-call (值班 / 升级路径 / runbook)
- 下游:56.5 事故响应、56.6 无指责复盘、36.7 演练与验证
- 待补:82.6 复盘方法论