深入 ChaosBlade 陷阱排查:cgroup 状态逃逸引发的永久性 CPU Throttling 与 GameDay 瘫痪实战

近期在主导一次核心交易链路的 GameDay 时,遇到一起极具讽刺意味的故障:我们在对结算微服务注入 CPU 满载故障以验证 HPA(水平Pod扩容)和限流降级策略后,通过控制台停止了混沌实验。然而,目标微服务并未如期恢复,P99 延迟死死钉在 3000ms 以上,QPS 从日常的 5000 跌至不到 100,业务处于静默熔断状态。最终排查确认:这是由于 ChaosBlade Agent 在实验期间因资源竞争被 Kubelet Evict,导致 cgroup 恢复逻辑被跳过,目标 Pod 的 cpu.cfs_quota_us 被永久锁定在极低值,引发了灾难性的全局 CPU Throttling。

混沌工程的核心原则是“控制爆炸半径”和“可恢复性”,但如果故障注入工具本身的鲁棒性一塌糊涂,GameDay 就会演变成一场真正的灾难。今天把现场排查逻辑复盘出来,希望能让大家对底层资源隔离和混沌工具的原子性有更深的敬畏。

现场还原与排查逻辑

实验结束指令下发后,监控大盘并未如期恢复“全绿”。 第一反应是业务代码里有自旋锁没释放,或者 Go Runtime GC 挂起了。但登录到目标 Node 上查看,系统 Load Average 只有不到 2.0,极其空闲。

执行 top 并按 P 排序,发现目标 Go 进程的 CPU 占用率不到 1%,但处于 R (Running) 状态的时间极短。 拉取 Prometheus 监控,发现 go_goroutines 数量堆积到了 8 万多,说明请求进来了,但处理极慢。

排除了应用层死锁后,直奔底层资源隔离指标。执行以下 PromQL 检查容器 CPU 限流情况:

rate(container_cpu_cfs_throttled_periods_total{pod=~"settlement-svc-.*"}[1m]) 
/ 
rate(container_cpu_cfs_periods_total{pod=~"settlement-svc-.*"}[1m])

图表极其触目惊心:Throttling 比例高达 99.9%!这意味着容器几乎每个 CPU 调度周期都被内核硬生生掐断。

立刻切入宿主机,根据 Pod UID 定位到对应的 cgroup 目录,查看当前的 CFS 配额:

# 获取容器的 cgroup 路径
CGROUP_PATH=$(find /sys/fs/cgroup/cpu/kubepods.slice/ -name "*$(docker inspect -f '{{.Id}}' <container_id>)*")

# 查看当前配额
cat $CGROUP_PATH/cpu.cfs_quota_us
1000

cat $CGROUP_PATH/cpu.cfs_period_us
100000

结论非常荒谬:这个 Pod 原本是 Guaranteed QoS,配置了 requests.cpu=4, limits.cpu=4,其 cpu.cfs_quota_us 应该是 400000。现在居然变成了 1000(即 0.01 核)!难怪业务进程形同植物人。

底层原理解析:ChaosBlade 的致命缺陷

为什么停止了 ChaosBlade 实验,配额却没有恢复?

追踪 kubelet 和 chaosblade-tool 的日志,还原了事发现场:

  1. 注入阶段:ChaosBlade 为了模拟 CPU 饥饿/满载,并不是单纯地在容器内拉起一个 stress-ng 跑满 CPU(这无法限制宿主机上其他进程抢占)。它的部分高阶实现会直接入侵目标容器的 cgroup namespace,动态修改 cpu.cfs_quota_us 来限制应用的实际可用 CPU,或者在拉起满载进程的同时调整配额。

  2. 状态保存:在修改 cfs_quota_us 之前,ChaosBlade Agent 会将原始值(400000)保存在本地内存或一个临时状态文件中。

  3. 意外崩溃:在故障注入期间,由于整体 Node CPU 压力剧增,Kubelet 触发了资源保护机制。ChaosBlade 的 DaemonSet Pod 因为没有配置足够高的 PriorityClass(优先级过低),直接被 Kubelet 判定为牺牲品,执行了 Eviction(驱逐)。

  4. 逃逸与死锁:当操作人员在控制台点击“停止实验”时,控制端向集群下发恢复指令,但旧的 Agent 已经死了,新拉起的 Agent 内存中根本没有那个 Pod 的原始 cgroup 状态记录!恢复操作直接被跳过(或静默失败)。目标 Pod 的 cgroup 彻底成了无主孤魂,被永久锁定在 1000

这种非原子性的状态管理,是防御性编程的绝对反面教材。

修复与避坑指南

现场的临时止血很简单,手动把正确的配额写回 cgroup,或者直接删掉业务 Pod 让 K8S 重新调度重建:

echo 400000 > /sys/fs/cgroup/cpu/kubepods.slice/kubepod-pod<UID>.slice/docker-<ContainerID>.scope/cpu.cfs_quota_us

但从架构和 SRE 规范的角度,必须要建立以下护城河:

  1. 混沌组件必须配置最高优先级: Chaos Agent 等同于节点上的 Rootkit,其生命周期必须得到绝对保障。必须为其分配 system-node-critical 级别的 PriorityClass,并配置严苛的 Guaranteed 资源 QoS。绝不允许在实验中途被 Kubelet 驱逐。

  2. 无状态恢复与 eBPF 化: 抛弃那些通过直接篡改不可变基础设施状态(如原地修改 cgroup、原地修改 iptables 规则且不依赖 owner)来注入故障的低级工具。优秀的混沌工具应采用 eBPF(挂载点随进程生命周期绑定,进程死则注入自动失效)或 TC+cgroup-bpf 技术。如果一定要改文件,必须有基于独立 Watchdog 的兜底恢复机制(例如通过 Label 记录原始状态)。

  3. GameDay 旁路熔断监控: 实验脚本不能只看“业务指标是否下降”,必须引入“基础设施一致性校验”。在实验停止的自动化流水线中,增加一步对注入点(cgroup、网络 tc 队列)的物理清理确认。

同类问题排查清单

  1. CPU Throttling 突增排查:不要只看 Node CPU 使用率。应用变慢但 Load 正常时,第一步永远是 cat /sys/fs/cgroup/cpu/.../cpu.stat,重点关注 nr_throttledthrottled_time

  2. 混沌注入残留排查:网络类实验结束后延迟依然很高,检查 tc qdisc show dev eth0 是否残留 netem 规则;CPU 类检查 cfs_quota_us;IO 类检查 eBPF probe 或 FUSE 挂载点残留。

  3. Agent 生命保障检查:检查所有 DaemonSet 类型的运维组件(Chaos, Fluentd, node-exporter)的 PriorityClass,如果没有配置,在节点资源紧张时它们必然成为导致系统雪崩的定时炸弹。

  4. Cgroup 泄漏检测:定期运行脚本遍历 kubepods.slice 下的僵尸 cgroup 目录,K8S 曾有多个版本存在 Pod 销毁后 cgroup 目录不清理的 Bug,会导致内核内存碎片化及性能剧降。