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 雪崩与级联失败(待补)。

关联