56.1 混沌工程
一句话
混沌工程不是“随机搞破坏”,而是先写稳态假设、再注入故障、用数字判定假设是否成立;它的价值在于把“我们以为不会挂”变成“已经验证过会怎么挂”。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “混沌 = 生产随机杀 Pod” | 没有假设、没有中止条件、没有爆炸半径的注入,是事故,不是实验 |
| “必须在生产做才有意义” | 先在预发/测试验证实验本身,再小比例进生产;生产验证的是“真实流量与真实依赖” |
| “混沌能发现所有问题” | 它只能验证你想到并注入的故障;想不到的故障,靠演练、复盘与假设驱动的探索补 |
| “实验被中止 = 系统不行” | 中止是护栏生效,说明保护阈值设对了;中止的实验不能算“通过”,也不能算“失败” |
五条原则
| 原则 | 含义 | 反例 |
|---|---|---|
| 稳态假设 | 先用可测指标定义“正常”,再假设“注入后仍正常” | “看看会不会挂” |
| 真实故障 | 延迟、错误、断连、资源耗尽、时钟偏移、依赖变慢 | 只杀 Pod,不模拟慢与错 |
| 生产验证 | 预发验证实验,生产小流量验证真实系统 | 只在本地 docker-compose 里玩 |
| 持续自动化 | 定时跑、进流水线,而不是一年一次运动式 | “安全月”做一次,之后忘了 |
| 最小爆炸半径 | 比例、时长、白名单、中止条件,四个都要有 | 全量注入 30 分钟 |
这五条来自混沌工程社区的实践总结;真正落地时,最难的不是注入,是“稳态指标”与“中止条件”——它们决定了实验是“可控验证”还是“赌一把”。
实验的七个要素
| 要素 | 要回答的问题 | 例子 |
|---|---|---|
| 假设 | 注入后,什么指标应该保持? | “结算错误率 ≤ 2%” |
| 稳态指标 | 用什么量“正常”? | 错误率、P99、成交率 |
| 注入方式 | 注什么故障? | 200 ms 延迟、5% 超时、断开依赖 |
| 范围 | 哪些实例/流量? | 1 个可用区的 order 服务,5% 流量 |
| 时长 | 持续多久? | 60 秒(首轮),逐步到 5 分钟 |
| 中止条件 | 什么情况下立即停? | 错误率 > 5% 或 P99 > 2s |
| 回滚 | 停了之后怎么恢复? | 删除实验 CR、恢复权重、清缓存 |
🔑 没有中止条件的实验,等于把“故障注入”交给运气。中止条件要在实验开始前写死,不能在实验中途“再观察一下”。
爆炸半径:先算,再做
爆炸半径 ≈ 注入比例 × 流量 × 时长。10,000 QPS 的服务:
| 注入比例 | 时长 | 受影响请求(上限) | 适用 |
|---|---|---|---|
| 1% | 60 s | 6,000 | 预发/首轮生产 |
| 5% | 60 s | 30,000 | 生产常规实验 |
| 5% | 300 s | 150,000 | 需要更强中止条件与值守 |
| 20% | 60 s | 120,000 | 只在大促前的压测环境 |
这个数字要和“业务能承受多少错误”对齐:如果一次实验可能产生 3 万个失败请求,先确认这些请求落在哪些用户、是否可补偿,再决定做不做。指标口径(错误率怎么算、P99 怎么取)见 50.4 度量(四黄金指标 / 直方图 / 基数爆炸);注入慢依赖时,下游要靠 35.6 熔断与降级 兜住,否则实验会变成真实的级联故障。
一个能跑的实验执行器:假设、采样、熔断
package main
import "fmt"
// experiment 一次混沌实验:先定义“稳态假设”,再注入故障,最后用数字判定假设是否成立。
type experiment struct {
name string
hypothesis string
injectAt int // 第几秒开始注入
duration int // 计划持续多少秒
blastPct float64 // 爆炸半径:多少比例的流量被注入
qps float64
successMax float64 // 假设允许的最大错误率
abortAt float64 // 超过它就立即中止(保护用户)
}
type sample struct {
sec int
err float64
}
// run 模拟一次实验:每秒采样错误率;超过 abortAt 就中止。
// pattern 返回该秒的错误率,便于把“真实故障曲线”换成任意场景。
func run(e experiment, pattern func(sec int) float64) ([]sample, bool, float64) {
var ss []sample
maxErr := 0.0
for sec := 0; sec <= e.duration; sec += 10 {
err := pattern(sec)
ss = append(ss, sample{sec, err})
if err > maxErr {
maxErr = err
}
if err > e.abortAt {
return ss, true, maxErr
}
}
return ss, false, maxErr
}
func report(e experiment, pattern func(int) float64) bool {
ss, aborted, maxErr := run(e, pattern)
fmt.Printf("== %s ==\n假设:%s\n", e.name, e.hypothesis)
fmt.Printf("%-5s %-8s %s\n", "T", "ERR", "NOTE")
for _, s := range ss {
note := ""
if s.sec == e.injectAt {
note = "← 注入开始"
}
fmt.Printf("%-5s %-8s %s\n", fmt.Sprintf("%ds", s.sec), fmt.Sprintf("%.2f%%", s.err), note)
}
// 影响面只算“错误率高于稳态”的时段,而不是整个实验时长。
baseline := ss[0].err
badSec := 0
for _, s := range ss {
if s.sec >= e.injectAt && s.err > baseline {
badSec += 10
}
}
affected := e.qps * e.blastPct * float64(badSec)
if aborted {
fmt.Printf("中止:错误率 %.2f%% > 保护阈值 %.2f%%;影响请求约 %.0f 个\n", maxErr, e.abortAt, affected)
fmt.Printf("结论:假设未验证(实验被中止,不能算通过)\n\n")
return false
}
ok := maxErr <= e.successMax
verdict := "成立"
if !ok {
verdict = "不成立"
}
fmt.Printf("结论:假设%s(峰值 %.2f%% vs 允许 %.2f%%);影响请求约 %.0f 个\n\n", verdict, maxErr, e.successMax, affected)
return ok
}
func main() {
a := experiment{
name: "A:5% 流量注入 200ms 延迟", hypothesis: "结算错误率不超过 2%",
injectAt: 20, duration: 60, blastPct: 0.05, qps: 10000,
successMax: 2.0, abortAt: 5.0,
}
// 注入后延迟上升,少量请求超时;50s 后恢复。
okA := report(a, func(sec int) float64 {
if sec < 20 {
return 0.20
}
if sec < 50 {
return 1.20
}
return 0.20
})
b := experiment{
name: "B:5% 流量断开依赖", hypothesis: "结算错误率不超过 2%",
injectAt: 20, duration: 60, blastPct: 0.05, qps: 10000,
successMax: 2.0, abortAt: 5.0,
}
okB := report(b, func(sec int) float64 {
if sec < 20 {
return 0.20
}
if sec < 30 {
return 8.00
}
return 3.00
})
fmt.Printf("假设成立:A=%v,B=%v\n", okA, okB)
}
本机运行结果:
== A:5% 流量注入 200ms 延迟 ==
假设:结算错误率不超过 2%
T ERR NOTE
0s 0.20%
10s 0.20%
20s 1.20% ← 注入开始
30s 1.20%
40s 1.20%
50s 0.20%
60s 0.20%
结论:假设成立(峰值 1.20% vs 允许 2.00%);影响请求约 15000 个
== B:5% 流量断开依赖 ==
假设:结算错误率不超过 2%
T ERR NOTE
0s 0.20%
10s 0.20%
20s 8.00% ← 注入开始
中止:错误率 8.00% > 保护阈值 5.00%;影响请求约 5000 个
结论:假设未验证(实验被中止,不能算通过)
假设成立:A=true,B=false
场景 B 的中止是设计目标:错误率一过 5% 就停,影响面从“计划 60 秒”压到“实际 10 秒”。如果 B 没有中止条件,它会继续注入 40 秒,受影响请求会从 5,000 涨到 20,000 以上。
工具与护栏
| 故障类型 | 工具 | 护栏 |
|---|---|---|
| 网络延迟/丢包/分区 | Chaos Mesh、LitmusChaos、tc netem | 按 namespace/label 限定;有 duration;有中止 |
| Pod 杀/容器杀 | Chaos Mesh PodChaos、Litmus | 排除有状态主节点;限定副本比例 |
| 依赖变慢/报错 | Toxiproxy、故障注入代理、Service Mesh fault injection | 只影响测试流量;可一键关闭 |
| 资源耗尽(CPU/内存/磁盘) | stress-ng、Chaos Mesh StressChaos | 设上限;监控节点;避免影响同节点其他服务 |
| 云资源故障 | AWS FIS、云厂商故障注入 | 限定资源标签;预置回滚 |
生产落地:指名 → 可抄 → 可验 → 可退
指名:K8s 生态优先用 Chaos Mesh(CRD 声明式、支持网络/ Pod / 压力/ IO / 时间偏移);托管集群可用云厂商的故障注入(AWS FIS 等);非 K8s 环境用 Toxiproxy 或 tc netem。实验编排与门户可用 LitmusChaos。
可抄(本机无集群,以下命令未验证):
# chaos/order-delay.yaml:只影响 prod 中 5% 的 order Pod,持续 5 分钟
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata: {name: order-delay, namespace: chaos-testing}
spec:
action: delay
mode: fixed-percent
value: "5"
selector:
namespaces: [prod]
labelSelectors: {app: order}
delay: {latency: "200ms", jitter: "50ms"}
duration: "5m"
kubectl apply -f chaos/order-delay.yaml
kubectl -n chaos-testing get networkchaos
kubectl -n chaos-testing describe networkchaos order-delay # 看 status/实验进度
kubectl delete -f chaos/order-delay.yaml # 立即停止
可验:本节执行器真跑过,输出如上;它验证的是“假设、中止、影响面”三条逻辑,不依赖集群。生产实验至少验三件事:实验前稳态指标与基线一致、注入后指标变化符合假设、删除 CR 后 5 分钟内指标回到基线。实验期间真出事故,按 56.5 事故响应 处理,不要把它当成“实验的一部分”。
可退:实验 CR 是声明式的,删除即停;但网络规则/压力进程可能有残留,删除后要确认节点负载与网络恢复。回滚顺序:停注入 → 确认指标恢复 → 再分析结果;不要在指标未恢复时继续下一个实验。
反模式
| 反模式 | 后果 |
|---|---|
| 没有假设,直接注入 | 结果无法判定,只能“感觉好像没事” |
| 没有中止条件 | 小故障被放大成事故 |
| 全量注入、无时间上限 | 爆炸半径失控,用户真实受损 |
| 只杀 Pod,不模拟慢与错 | 覆盖不到最常见的故障模式(延迟、超时、部分失败) |
| 在预发验证过就认为生产没问题 | 预发的流量、依赖、数据规模与生产不同 |
| 实验后不恢复就做下一个 | 故障叠加,无法归因 |
| 一年做一次“运动式”实验 | 系统变了,旧结论失效 |
| 把实验当惩罚团队的手段 | 团队隐藏问题,实验数据失真 |
自检
Q1 为什么混沌实验必须先写“稳态假设”,不能先注入再看?
▸ 答案:没有假设就没有判定标准。注入后再看指标,人会事后解释一切(“这点错误正常”“这个延迟可接受”),实验退化成演示。先写假设(可证伪的数值),才能在实验后给出“成立/不成立/被中止”的明确结论,也才能把结论沉淀成容量与架构决策。
Q2 实验被中止,算成功还是失败?
▸ 答案:都不算。中止说明保护阈值生效,这是护栏的成功;但假设没有被验证,不能记成“系统通过”。正确记录是“中止(abort)”,并分析:是故障太强、阈值太松,还是系统确实撑不住。下一次要么调小爆炸半径,要么先修系统。
Q3 为什么生产实验比预发实验更有价值?
▸ 答案:生产有真实流量分布、真实依赖拓扑、真实数据规模与真实网络。预发通常流量小、依赖是 mock/缩小版,很多故障(连接池耗尽、缓存击穿、跨 AZ 延迟、长尾)在预发不会出现。生产的代价是风险,所以要用小比例、短时长、强中止条件换“真实性”。
Q4 5% 流量、10,000 QPS、持续 60 秒,爆炸半径是多少?
▸ 答案:10,000 × 5% × 60 = 30,000 个请求(上限)。这个数字要和业务承受能力对齐:如果每个失败请求都可能产生客诉或资损,30,000 就太大;如果只是内部查询,可以接受。爆炸半径不是“技术参数”,是业务风险参数。
Q5 混沌工程不能发现什么问题?
▸ 答案:它只能验证“你想到并注入的故障”。想不到的故障(未知依赖、配置组合、人为操作、第三方变更)、需要长周期才暴露的问题(内存泄漏、磁盘写满、证书过期)、以及“故障之外的业务逻辑错误”,都发现不了。这些要靠演练(见 56.2 故障演练)、复盘(见 56.6 无指责复盘)与日常观测补;灾备切换类演练见 36.7 演练与验证,级联失败的典型案例见 82.2 雪崩与级联失败(待补)。
关联
- 前置:35.6 熔断与降级、50.4 度量(四黄金指标 / 直方图 / 基数爆炸)
- 并列:56.2 故障演练
- 下游:56.5 事故响应、56.6 无指责复盘、36.7 演练与验证
- 待补:82.2 雪崩与级联失败