标签: ChaosBlade

  • 深入 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,会导致内核内存碎片化及性能剧降。

  • 深入 ChaosBlade 陷阱排查:DNS 延迟注入引发的 UDP conntrack 溢出与节点雪崩实战

    在一次验证系统对 DNS 解析抖动容忍度的 GameDay 实战中,仅向 CoreDNS 注入 200ms 延迟,竟导致数十个业务节点接连失联。核心结论:高并发下微服务的无脑重试,配合 Linux UDP 默认 30 秒的 nf_conntrack_udp_timeout 状态保留,会瞬间撑爆 nf_conntrack 表。在进行网络层混沌工程时,必须提前调优内核连接跟踪参数或使用 raw 表豁免 DNS 流量,并设置基于系统底层指标的爆炸半径熔断器。

    1. 故障现场:一场失控的 GameDay

    近期的 SLO 验证计划中,我们需要验证核心交易链路在 DNS 存在长尾延迟(P99 200ms)时的系统表现,以确认超时控制和降级策略是否按预期生效。

    使用的故障注入工具是 ChaosBlade (v1.7.0),目标集群为 Kubernetes 1.22,宿主机内核为 5.4.x。通过以下命令对 CoreDNS Pod 所在节点注入网络延迟:

    blade create k8s network delay \
      --time 200 --offset 50 \
      --interface eth0 \
      --namespace kube-system \
      --names coredns-xxxx,coredns-yyyy
    

    预期现象:业务监控面板中,外部接口调用的 P99 延迟略微上升,部分请求触发 500ms 的断路器,系统平稳降级。 实际现象:注入 1 分钟后,监控大盘不仅没有展现预期的降级曲线,反而出现了严重的“断崖”。更致命的是,API Server 报错频繁,多个承载高并发业务的 Worker 节点状态变为 NotReady,节点上的所有 Pod 被判定为不可用,开始触发大规模漂移重调度的雪崩。

    2. 现场排查:谁拔了宿主机的网线?

    由于故障节点 SSH 已经无法连入(连接直接超时),通过带外管理(IPMI)登录物理机终端。

    执行 top 查看,CPU 使用率不到 30%,Load Average 在 4.0 左右(对于 64 核机器极低),内存也很充足。但网络就是不通,ping 网关全丢包。

    习惯性拉出内核环形缓冲区日志排查:

    dmesg -T | tail -n 20
    

    满屏的红色报错直击痛点:

    [Tue Oct 24 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    [Tue Oct 24 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    

    查看当前的连接跟踪数和系统上限:

    cat /proc/sys/net/netfilter/nf_conntrack_count
    # 输出: 262144
    cat /proc/sys/net/netfilter/nf_conntrack_max
    # 输出: 262144
    

    毫无疑问,宿主机的 nf_conntrack 表被打满了,导致 Netfilter 丢弃了所有新建连接的包(包括 Kubelet 与 API Server 的心跳,以及新的 SSH 握手请求)。

    到底是什么流量塞满了 conntrack 表?通过 conntrack 工具统计状态:

    conntrack -L | awk '{print $1}' | sort | uniq -c
    

    输出结果令人诧异:

          4 ICMP
       1024 tcp
     261116 udp
    

    超过 99% 的表项全被 UDP 占满。

    3. 为什么 200ms 的 DNS 延迟会击穿几十万的 conntrack 表?

    这涉及到 Linux 内核对 UDP 连接跟踪的处理机制,以及业务代码在面对网络抖动时的“疯狂”行为。

    底层原理:UDP 的伪状态机与超时机制

    UDP 本身是无连接的,但为了让 iptables/Netfilter 能够实现状态防火墙(例如允许外部响应包返回内部),内核强行给 UDP 设计了连接跟踪机制。

    当一个 UDP 包发出时,内核在 nf_conntrack 中建立一条状态,标记为 UNREPLIED。如果在默认时间内收到了对端的回包,状态变更为 ASSURED。 在 CentOS/Ubuntu 等主流发行版中,相关的内核参数默认值如下:

    • net.netfilter.nf_conntrack_udp_timeout = 30 (未收到回包时的保留时间,30秒)

    • net.netfilter.nf_conntrack_udp_timeout_stream = 120 (收到回包,被认定为流时的保留时间,120秒)

    业务雪崩推演

    1. 注入延迟:ChaosBlade 通过底层的 tc netem 让 CoreDNS 的响应延迟了 200ms。

    2. 业务超时:业务代码(Go net/http 或某些缺乏连接池管理的短连接客户端)设置的 DNS 查询超时可能极短(例如 100ms)。

    3. 疯狂重试:因为请求 DNS 超时,业务线程/协程被唤醒,立即发起下一次 DNS 解析。关键点来了:每次重试,客户端都会申请一个新的临时端口(Ephemeral Port)作为 UDP 源端口。

    4. 表项爆炸:由于源端口改变,Netfilter 将其视为一条全新的“连接”。旧的 DNS 请求由于没收到回包(还在 tc 的队列里排队),其在 conntrack 表中的 UNREPLIED 记录将死死挂住 30秒 才会过期回收。

    算一笔账:假设单节点有 5000 QPS 的请求,由于 DNS 延迟,导致业务每秒产生 3 次重试。 每秒新增 UDP conntrack 数 = 5000 * 3 = 15000。 经过 30 秒的积累,15000 * 30 = 450,000 条记录。 这就轻而易举地击穿了默认的 nf_conntrack_max (262144),最终导致宿主机网络瘫痪。

    4. 混沌工程防御性设计与底层加固

    发现原因后,解决问题并不难。但在混沌工程实践中,如何防止测试把系统“真搞挂”,才是考验架构师功底的地方。

    1. 系统底层加固:收紧 UDP conntrack 超时

    对于承载高并发微服务的宿主机,默认的 30s 容忍度过于宽泛。建议在 sysctl.conf 中激进地调低这个值,并适当抬高 max 上限。

    # 修改 /etc/sysctl.d/99-kubernetes-cri.conf
    net.netfilter.nf_conntrack_max = 1048576
    net.netfilter.nf_conntrack_udp_timeout = 10
    net.netfilter.nf_conntrack_udp_timeout_stream = 60
    

    执行 sysctl -p /etc/sysctl.d/99-kubernetes-cri.conf 立即生效。

    2. 根治方案:在 Netfilter 层面豁免 DNS 流量

    既然 DNS 是典型的极短生命周期 RPC,且集群内部互信,完全可以直接跳过 conntrack 跟踪。利用 iptables 的 raw 表(优先级高于 conntrack)注入 NOTRACK 规则。

    # 对发往 53 端口的 UDP 流量不进行状态跟踪
    iptables -t raw -I PREROUTING -p udp --dport 53 -j NOTRACK
    iptables -t raw -I OUTPUT -p udp --dport 53 -j NOTRACK
    # 相应的回包也不跟踪
    iptables -t raw -I PREROUTING -p udp --sport 53 -j NOTRACK
    iptables -t raw -I OUTPUT -p udp --sport 53 -j NOTRACK
    

    注:如果集群内使用了依赖于状态跟踪的复杂 NetworkPolicy,此操作可能引发冲突,需配合实际 CNI 插件(如 Calico/Cilium)的具体实现来权衡。

    3. GameDay 的熔断设计:爆炸半径控制

    混沌工程的第一原则是控制爆炸半径。我们在后续的演练体系中,强制引入了基于 Prometheus+Alertmanager+Webhook 的“自动停止机制”。

    编写一个看门狗脚本或部署专门的 DaemonSet,高频采集节点的 /proc/sys/net/netfilter/nf_conntrack_count。 当 (count / max) > 80% 时,触发最高级别告警,Webhook 直接调用 ChaosBlade API 强制销毁当前所有的混沌实验:

    # Webhook 触发的急救脚本片段
    blade status --type create | grep Success | awk '{print $4}' | xargs -I {} blade destroy {}
    

    常见问题

    Q1:为什么不直接使用 NodeLocal DNSCache 来解决这个问题? NodeLocal DNSCache 确实能极大缓解跨节点的 DNS 压力和 conntrack 表争抢(它将 DNS 请求拦截在本地并复用 TCP)。但如果针对 NodeLocal DNSCache 自身所在节点进行注入,或者上游 CoreDNS 完全不可用导致本地缓存穿透,业务依旧会疯狂重试击穿本地的 UDP conntrack 表。底层参数调优依然是最后一道防线。

    Q2:当节点已经 NotReady 且 SSH 无法连接时,如何快速恢复? 如果物理机有 OOB(如 IPMI、iLO),登录后直接调高上限:echo 1048576 > /proc/sys/net/netfilter/nf_conntrack_max 即可瞬间恢复网络通信。若无 OOB 且节点已“僵死”,只能通过 IaaS 控制台硬重启实例,此时务必确认 StatefulSet 挂载的存储卷是否正常卸载,以防脑裂。

    Q3:使用基于 eBPF 的故障注入(如 Chaos Mesh 的 BPF 模式)是否能避免此问题? 不能完全避免。虽然 eBPF 可以在 Socket 层或 TC 层更高效地丢包/延迟,避开部分 Netfilter 链的处理开销。但应用层(如 Go 的 HTTP Client)感知不到底层是被 eBPF 拦截还是真实网络延迟,它依然会发起重试。只要这些重试的 UDP 数据包依然经过宿主机的 Netfilter 协议栈,nf_conntrack 就会尽职尽责地去记录它们,最终依然会导致表满。