标签: SRE实战

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

  • 深入 Chaos Mesh 陷阱排查:IOChaos FUSE 阻塞引发的 Kubelet D状态死锁与 GameDay 爆炸半径失控实战

    结论先行:在某次验证 I/O 降级 SLO 的 GameDay 实战中,使用 Chaos Mesh IOChaos (v2.5.1) 注入磁盘延迟。由于注入器 FUSE 进程 (toda) OOM,遗留死挂载点,导致 Kubelet 采集 Volume 指标时触发 stat 系统调用陷入 D 状态(Uninterruptible Sleep)。最终 PLEG 超时,节点 NotReady。破局方案:执行 umount -l 强卸载死挂载点;长期规范需在 GameDay 平台接入 Prometheus 异常熔断机制,并优先采用基于 eBPF 的 I/O 注入方案。

    GameDay 现场:被击穿的爆炸半径

    近期在落地高可用容灾演练时,SRE 团队设计了一个针对有状态服务(MySQL 集群)的 GameDay。核心 SLO 是:当单节点磁盘 I/O 出现严重毛刺(P99 延迟突破 500ms)时,上层业务的熔断降级机制应在 30 秒内生效,保障整体 API 成功率不低于 99.9%。

    我们通过 Chaos Mesh 下发了 IOChaos,期望在目标 Pod 的数据目录注入 300ms 的延迟:

    apiVersion: chaos-mesh.org/v1alpha1
    kind: IoChaos
    metadata:
      name: mysql-io-delay
      namespace: db-cluster
    spec:
      action: latency
      mode: one
      selector:
        labelSelectors:
          app: mysql
      volumePath: /var/lib/mysql
      delay: '300ms'
      duration: '5m'
    

    注入下发后前 2 分钟,业务指标按预期平滑降级。但到了第 3 分钟,监控大盘突然雪崩:

    1. 宿主机 Load Average 从日常的 4 飙升至 200+。

    2. 该 Node 上所有微服务的 QPS 跌零,网关大量 504 报错。

    3. K8s 控制面告警:该节点进入 NotReady 状态,触发大面积 Pod Eviction(驱逐)。

    一场本该局限在单个 MySQL Pod 内的故障注入,失控变成了整个 Node 的雪崩。

    现场排查:揪出 D 状态的幕后黑手

    登录宿主机(K8s v1.24.10, Kernel 5.4.x),系统响应极度卡顿,top 命令显示 CPU Sys 态占用并不高,但 iowait 异常。

    查看 Kubelet 日志,发现经典的 PLEG 死亡报错:

    E0915 14:22:31.451921   14212 kubelet.go:1982] "PLEG is not healthy:" ...
    W0915 14:23:01.452132   14212 node_status.go:421] "Error updating node status, will retry" err="timeout waiting for PLEG"
    

    PLEG (Pod Lifecycle Event Generator) 不健康,通常意味着 Kubelet 的某个关键 Sync 循环被彻底阻塞。由于 Load Average 极高,立刻怀疑存在大量 D 状态进程:

    # 抓取所有 D 状态进程
    ps -eo state,pid,cmd | awk '$1=="D"'
    

    输出令人头皮发麻,不仅几十个日常运维 Agent(如 Fluent-bit, Node-Exporter)处于 D 状态,连 kubelet 主进程赫然在列!

    查看 Kubelet 的内核调用栈,寻找阻塞点:

    cat /proc/$(pidof kubelet)/stack
    

    内核栈输出:

    [<0>] request_wait_answer+0x12e/0x210 [fuse]
    [<0>] fuse_simple_request+0x1a9/0x2e0 [fuse]
    [<0>] fuse_getattr+0x2cc/0x350 [fuse]
    [<0>] vfs_getattr_nosec+0x2a/0x40
    [<0>] vfs_getattr+0x26/0x30
    [<0>] vfs_statx+0x79/0xe0
    [<0>] __do_sys_newstat+0x3d/0x70
    [<0>] do_syscall_64+0x5c/0x1a0
    [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
    

    真相大白:Kubelet 死锁在了 fuse_getattr 上,也就是尝试去获取某个 FUSE(用户态文件系统)挂载点的信息时,被底层 FUSE 守护进程彻底 Block 了。

    紧急止血:通过 mount | grep fuse 找到对应挂载点,强制卸载。

    umount -f -l /var/lib/kubelet/pods/xxx/volumes/kubernetes.io~empty-dir/data
    systemctl restart kubelet
    

    节点随即恢复 Ready,Load Average 在 1 分钟内回落。

    为什么一个 Pod 的 IOChaos 会导致宿主机 Kubelet 死锁?

    这涉及 Chaos Mesh IOChaos 的底层实现原理机制与 Kubelet 监控机制的致命冲突。

    1. toda FUSE 劫持机制: 在 Chaos Mesh (v2.5.x 默认机制) 中,IOChaos 的底层是通过注入一个名为 toda 的组件来实现的。toda 基于 FUSE。当你指定 volumePath 时,Chaos-daemon 会将原始目录 move 走,然后用 toda 起一个 FUSE 挂载点在原位置。Pod 内所有的 I/O 请求都会穿透 VFS 发给 FUSE,再由 toda 进程在用户态增加延迟后,转发给底层真实文件系统。

    2. toda 进程的静默死亡: 排查系统日志发现 dmesg -T | grep toda: text Out of memory: Killed process 31415 (toda) total-vm:451232kB, anon-rss:102400kB... 在注入 300ms 延迟后,MySQL 大量 I/O 请求堆积,导致处理这些请求的 toda 进程内存暴涨,触及了宿主机 cgroup 限制,被内核 OOM Killer 强杀。

    3. Kubelet 踩雷: toda 进程死后,FUSE 挂载点 /var/lib/kubelet/pods/.../volumes/... 变成了一个死挂载点 (Orphaned Mount)。没有任何用户态进程来响应这个挂载点上的 VFS 请求。 而 Kubelet 内部集成的 cAdvisor 模块,会定期扫描宿主机上所有 Pod 的 Volume 目录(计算 ephemeral-storage 占用量)。当 Kubelet 发起 stat() 系统调用遍历到这个死挂载点时,由于 FUSE 的特性,内核会一直等待用户态的回复。结果就是:Kubelet 陷入无法被信号中断的 D 状态。 PLEG 线程因 Kubelet 整体卡死而超时,最终判定 Node NotReady。

    GameDay 防御性架构与改造实践

    为了避免演练变成灾难,我们在底层架构与演练平台上做了三项核心改造:

    1. 禁用 FUSE 注入,切换为 eBPF 模式

    FUSE 代理模式在生产环境的脆弱性太高。Chaos Mesh 实际上从较新版本开始提供了实验性的基于 eBPF 的 I/O 注入,或者可以直接使用 ChaosBlade 的基于内核态(SystemTap/eBPF)的 disk delay。 如果必须使用 FUSE,则必须对注入侧进程(toda)的资源进行独立调优,并且绝对禁止作用于 Kubelet 关键路径扫描的目录。

    2. Kubelet 免疫配置优化

    为了防御类似 NFS 夯死、FUSE 异常导致的 Kubelet 雪崩,可以通过配置 Kubelet 参数,降低统计频率或关闭部分非核心目录的扫描,避免单点阻塞拖垮主循环(尽管根本上还是内核层面的限制,但在 Kubernetes 1.25+ 中对 PLEG 的底层逻辑做了部分解耦)。

    3. 建设自动熔断的 GameDay 引擎

    真正的混沌工程绝不是“在生产环境乱搞”,而是高度受控的科学实验。我们在自研的 GameDay 平台中,强制要求配置停止条件 (Halt Conditions)。 通过 webhook 联动 Prometheus,如果在演练期间满足以下 PromQL 任意一条,演练引擎将立即调用 Chaos Mesh API 执行 Delete 操作并触发报警:

    # 熔断策略片段
    haltConditions:
      - name: "Node Load 异常熔断"
        query: "node_load1{instance='$NODE_IP'} > 50"
        duration: "30s"
      - name: "全局 5xx 错误率飙升"
        query: "sum(rate(istio_requests_total{response_code=~\"5.*\"}[1m])) / sum(rate(istio_requests_total[1m])) > 0.05"
        duration: "10s"
    

    常见问题

    Q: 如何快速定位宿主机上哪些进程卡在了哪个死挂载点? A: 首先通过 dmesg | grep "blocked for more than" 查看内核警告。然后执行 mount | grep fuse 列出嫌疑挂载点。如果直接使用 df -h 卡住,可以使用 cat /proc/mounts(读取内存,不会触发 stat)来排查死挂载的具体路径。

    Q: Chaos Mesh 的 NetworkChaos 会有类似的爆炸半径扩散问题吗? A: NetworkChaos 底层原理是操作网络命名空间中的 TC (Traffic Control) 和 iptables。只要 Pod 是独立的 Network Namespace(没有使用 hostNetwork: true),网络注入通常被严格限制在 Pod 级别,极少出现穿透到宿主机导致 Node NotReady 的情况,其安全性远高于基于挂载的 IOChaos。

    Q: 遇到 D 状态进程,除了重启物理机,还有没有优雅的恢复办法? A: D 状态是内核层面的等待,常规的 kill -9 无效。如果是 NFS/Ceph/FUSE 这类网络或代理文件系统导致的 D 状态,强行卸载挂载点(umount -f -l <路径>)通常是唯一解。如果是本地物理磁盘硬件损坏导致的 D 状态,基本只能等待 I/O 超时或硬件复位。

  • 深入 SRE 告警治理:告别资源阈值风暴,基于多窗口 SLO 燃烧率与 Alertmanager 抑制实战

    生产环境绝大多数告警风暴源于粗放的“资源阈值”触发器。要真正给 On-Call 工程师减负,必须抛弃 CPU/内存使用率等原因导向告警,转向基于用户体验的 SLO(服务级别目标)现象导向告警。本文直接给出基于 Prometheus 的多窗口多燃烧率(Multi-Window Burn Rate)实现方案,结合 Alertmanager 路由抑制,彻底过滤瞬态抖动噪音。

    现场还原:被“阈值告警”淹没的真正故障

    近期排查过一个典型案例:某个核心交易链路出现 504 Gateway Timeout 雪崩。但在故障发生时的前 5 分钟内,On-Call 工程师的 Slack 和邮箱瞬间涌入 400 多条告警。

    其中 95% 的告警长这样:

    [FIRING] K8sNodeCpuHigh
    Severity: warning
    Summary: Node 10.x.x.x CPU usage is > 85%
    Description: CPU usage is at 92% for more than 3m.
    

    工程师的注意力完全被 Kubernetes 节点的 CPU 和 Pod 的重启告警吸引,试图去扩容 Node。但底层根因其实是:DB 连接池因慢查询耗尽,导致上游网关堆积请求,线程阻塞打满 CPU。高 CPU 只是结果,而非原因。 真正有价值的告警——“支付接口 P99 延迟突破 2s”被淹没在无穷无尽的资源告警噪音中。

    这种传统的告警配置策略(如 CPU > 80% 告警),在现代微服务和云原生架构中,除了消耗 SRE 的精力,毫无价值。

    为什么我们必须彻底抛弃静态资源利用率告警?

    传统的监控思路是自底向上的(Bottom-Up):监控机器 -> 监控 OS -> 监控 DB -> 监控应用。但在 K8S 集群中,Pod 随时在漂移,HPA(Horizontal Pod Autoscaler)会根据负载自动扩缩容。一个节点 CPU 跑到 90% 完全是资源利用率高的健康表现,只要服务的 RT(响应时间)和错误率达标,用户根本不关心你的 CPU 是 10% 还是 99%。

    防御性运维的核心思想是面向症状告警(Symptom-based Alerting)。 我们需要围绕 SLI(服务级别指示器)来构建监控体系,通常只关注四个黄金信号:延迟、流量、错误、饱和度。当且仅当错误预算(Error Budget)被快速消耗时,才触发 P1 级别 On-Call 呼叫。

    SLO 燃烧率告警核心架构与 PromQL 落地实战

    基于 Google SRE 实践,我们采用多时间窗口多燃烧率(Multi-Window, Multi-Burn-Rate)模型。

    假设我们的 SLO 是:API 过去 30 天的可用性达到 99.9%。 这意味着 30 天(730 小时)内的错误预算(Error Budget)为 0.1%。

    如果我们在 1 小时内消耗了整个月 2% 的错误预算,燃烧率(Burn Rate)计算如下: (2% / 100%) / (1h / 730h) ≈ 14.6(通常工程上取 14.4)。

    为了防止低频抖动触发告警(Flapping),我们引入双窗口:长窗口(1h)用于触发,短窗口(5m)用于确认当前故障仍在持续。只有当两个窗口的燃烧率同时超标时,才发出告警。

    1. 预计算 Recording Rules (Prometheus 2.45+)

    直接在告警规则中跑高基数(High Cardinality)的原始指标聚合会导致 Prometheus 评估超时。必须先使用 Recording Rules 将 SLI 降维。

    groups:
      - name: slo_sli_recordings
        interval: 1m
        rules:
          # 计算过去 5 分钟的错误率 SLI
          - record: job:request_error_rate5m
            expr: |
              sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
              /
              sum by (job) (rate(http_requests_total[5m]))
    
          # 计算过去 1 小时的错误率 SLI
          - record: job:request_error_rate1h
            expr: |
              sum by (job) (rate(http_requests_total{status=~"5.."}[1h]))
              /
              sum by (job) (rate(http_requests_total[1h]))
    

    2. 多窗口燃烧率告警规则

    在上述预计算指标的基础上,配置 14.4 燃烧率告警(严重告警,即刻 Page On-Call):

    groups:
      - name: slo_burn_rate_alerts
        rules:
          - alert: API_HighErrorBurnRate_Page
            # 条件:1小时的燃烧率 > 14.4 且 5分钟的燃烧率 > 14.4
            # SLO=99.9%, Budget=0.1% (0.001)
            # 14.4 * 0.001 = 0.0144 (即 1.44% 的绝对错误率阈值)
            expr: |
              (
                job:request_error_rate1h > 0.0144
                and
                job:request_error_rate5m > 0.0144
              )
            labels:
              severity: critical
              pager: "true"
            annotations:
              summary: "API 错误预算极速消耗 (Burn Rate > 14.4)"
              description: "服务 {{ $labels.job }} 在过去1小时内消耗了 2% 的月度错误预算,请立即介入排查。"
    

    通过这种多窗口机制,若只是 1 分钟的网络抖动,5m 窗口会很快回落,告警自动解除,On-Call 工程师根本不会被打扰;而如果是持续的底层熔断,1h 窗口和 5m 窗口同时达标,立刻触发电话告警。

    Alertmanager 高级减噪机制:Inhibit 与 Grouping

    即使有了 SLO 告警,在机房级网络割接或交换机故障时,仍会产生“服务级 SLO 全部崩塌”的并发告警。此时必须利用 Alertmanager (v0.26+) 的 group_by 和 inhibit_rules 机制。

    1. 分组折叠 (Grouping)

    不要让每个容器的报错发一条消息,按服务或集群聚合:

    route:
      receiver: 'slack-oncall'
      group_by: ['job', 'cluster']
      group_wait: 30s      # 等待30秒收集同类告警
      group_interval: 5m   # 每5分钟发送一批新告警
      repeat_interval: 4h  # 未解决告警4小时后才重发
    

    2. 拓扑抑制 (Inhibition)

    底层基础组件宕机时,静默其上层所有应用的告警。例如:所在宿主机 NodeDown,则直接抑制该宿主机上所有 Pod 触发的 SLO 告警。

    inhibit_rules:
      - source_matchers:
          - alertname = "NodeDown"
          - severity = "critical"
        target_matchers:
          - severity =~ "warning|critical|info"
        # 只要 target 告警的 instance/node 标签和 source 匹配,就将其丢弃
        equal: ['node', 'cluster']
    

    通过抑制链设计:DatacenterDown -> 抑制 ClusterDown -> 抑制 NodeDown -> 抑制 AppSLOAlert,在灾难性故障现场,On-Call 工程师只会收到唯一一条最顶层的根因告警。

    常见问题

    Q:既然抛弃了静态资源告警,数据库磁盘满了或者证书过期这类问题怎么监控? A:不要陷入极端。基于症状的 SLO 告警针对的是用户请求链路。对于确定性的、必然导致宕机且有充足时间提前干预的“饱和度/容量指标”(如磁盘使用率 > 85%、TLS 证书 7 天后过期),依然需要配置静态阈值告警,但这部分告警级别通常设为 Warning,走工单或 IM 推送,白天处理即可,绝不能 Page 深夜的 On-Call。

    Q:对于流量极低的服务(比如每分钟只有几个请求),SLO 燃烧率计算会剧烈抖动,如何解决? A:低频服务的指标在计算 rate() 时极易出现“分母为0”或“1个错误=100%错误率”的噪音。解决方案是在 PromQL 中加入绝对流量过滤条件,例如 and sum by (job) (rate(http_requests_total[5m])) > 10,确保样本量具备统计学意义时才评估错误率。

    Q:如何定义异步消息队列(如 Kafka/RocketMQ 消费端)的 SLI? A:异步服务的核心用户体验不是“同步响应时间”,而是“消息堆积延迟”。SLI 可以定义为:过去 5 分钟内,99% 的消息从发送到被消费的端到端延迟(End-to-End Latency)小于 5 秒,或者更直白地以 Consumer Group 的 Lag 积压绝对值作为 SLI 指标,结合消费速率评估剩余处理时间(Time-to-critical)。

  • 深入混沌工程内核:从 TC/eBPF 故障注入到 SLO 自动化验证实战

    混沌工程绝不是毫无章法的“拔网线”。本文直接拆解基于 Chaos Mesh (v2.6.2) 的底层故障注入原理(Linux tc 与 eBPF 机制),并给出一套将故障注入与 Prometheus SLO 报警集成的自动化 GameDay 验证闭环方案。记住:没有可观测性度量和自动恢复兜底的故障注入,纯粹是在搞破坏。

    为什么你的故障注入总是不及预期?深入 TC 与 eBPF 机制

    很多研发拿着现成的 YAML 一把梭,看到 Pod 报错就以为混沌实验成功了。但在真实的排查场景中,如果不清楚底层到底“烂”在哪个系统调用或网络栈层级,你根本无法验证微服务的超时重试和熔断机制是否真正生效。

    1. 网络延迟注入:Netem 与 Namespace 的戏法

    当你下发一个针对某个 Pod 的网络延迟(NetworkChaos)时,控制面并不会去修改交换机配置。底层的 chaos-daemon 会通过 Kubelet 拿到目标容器的 PID,然后利用 nsenter 钻进该容器的网络命名空间(Network Namespace),利用 Linux 内核自带的 Traffic Control (tc) 和 netem 模块进行流量整形。

    某次验证超时熔断时,发现注入 200ms 延迟后应用依然秒回。直接登录 Node,钻入目标 Pod 命名空间查看真实流控规则:

    # 获取目标 Pod 容器的主进程 PID
    PID=$(crictl inspect <container_id> | jq .info.pid)
    
    # 进入容器的网络命名空间查看 tc 规则
    nsenter -t $PID -n tc -s qdisc show dev eth0
    

    正常被注入延迟的网卡,你能看到类似如下的输出:

    qdisc netem 1: root refcnt 2 limit 1000 delay 200.0ms  10.0ms 25%
     Sent 10234 bytes 81 pkt (dropped 0, overlimits 0 requeues 0)
     backlog 0b 0p requeues 0
    

    如果输出是 qdisc pfifo_fast 0:,说明 tc 规则根本没打上。通常是因为 CNI 插件(如 Cilium 的某些 BPF 模式)绕过了宿主机的 veth pair,或者内核没有加载 sch_netem 模块(modprobe sch_netem 可解)。

    2. 磁盘 IO 故障:eBPF 对 VFS 的精准拦截

    早期的 IO 故障注入靠在容器里跑 dd 把磁盘带宽打满,这种做法极度粗暴,且容易引发宿主机的 IO 风暴,波及同节点其他核心 Pod(典型的爆炸半径失控)。

    现代混沌工程(如 Chaos Mesh 的 IOChaos)在内核态使用 eBPF 实现精准注入。要求宿主机内核至少在 4.17+(推荐 5.4+ 以获得稳定的 BPF 特性)。其原理是将一段 BPF 字节码挂载到内核的 VFS(虚拟文件系统)层面上,例如通过 kprobe 拦截 vfs_read 和 vfs_write 函数。

    当目标进程发起读写请求时,BPF 程序会被触发,强制在内核态 bpf_ktime_get_ns() 循环等待(制造延迟),或者直接修改系统调用返回值,返回 -EIO (Input/output error)(制造读写失败)。这种方式只针对特定 PID 和特定目录生效,彻底切断了对宿主机全局的干扰。

    SLO 验证闭环:用数据说话,拒绝肉眼盯盘

    GameDay(故障演练日)的核心不是制造恐慌,而是验证系统的容错边界是否符合 SLO(服务级别目标)。我们通常以 Error Budget(错误预算)消耗率为核心判定标准。

    在演练前,必须确保 Prometheus 中有定义严谨的 SLO 监控指标。例如,核心交易链路的 P99 延迟 SLO 定义为 200ms。

    # 记录规则:计算订单服务 P99 延迟
    record: job:request_latency:p99
    expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="order-svc"}[1m])) by (le))
    

    在 GameDay 流程中,自动化脚本的逻辑应该是:

    1. 持续轮询拉取当前 P99 延迟,确认 Baseline 正常(如 50ms)。

    2. 下发 NetworkChaos,注入 150ms 延迟。

    3. 观测 P99 延迟指标是否在 1 分钟内攀升至 200ms 左右。

    4. 核心断言:断言上游 API Gateway 的 5xx 错误率是否上升。如果上游配置了合理的 100ms 超时和重试熔断,上游应用应触发熔断策略,而不会被下游彻底拖死导致线程池耗尽(防止级联雪崩)。

    GameDay 实战剧本:千万别忘了防御性恢复

    这里给出一个验证数据库主备切换的真实网络隔离注入配置片段。注意其中的 duration 和 mode 参数,这是防御性编程在混沌工程中的体现。

    apiVersion: chaos-mesh.org/v1alpha1
    kind: NetworkChaos
    metadata:
      name: db-partition-gameday
      namespace: sre-chaos
    spec:
      action: partition
      mode: fixed
      value: "1" # 仅影响 1 个目标 Pod(爆炸半径控制)
      selector:
        namespaces:
          - production
        labelSelectors:
          "app": "mysql-cluster"
          "role": "master"
      direction: both
      target:
        selector:
          namespaces:
            - production
          labelSelectors:
            "app": "order-service"
      # 极其重要:强制 60 秒后自动恢复。严禁在没有自动恢复时间的配置下执行演练!
      duration: "60s" 
    

    排查心法:演练过程中如果发现系统挂了且无法自愈,第一反应是直接删除 Chaos 资源(kubectl delete networkchaos db-partition-gameday -n sre-chaos)。如果 chaos-controller-manager 组件本身在这个时候假死卡住了,立刻在宿主机执行兜底恢复脚本: find /proc -maxdepth 1 -regex '/proc/[0-9]+' -exec nsenter -t {} -n tc qdisc del dev eth0 root 2>/dev/null \; (强制清理节点上所有的 tc 限制,简单粗暴但救命)。

    常见问题

    Q1: 生产环境做混沌实验,如果控制面(Controller)挂了,故障一直存在怎么兜底? 控制面宕机会导致 duration 到期后无法自动清理。成熟的落地方案必须在 Node 层面部署一层“看门狗(Watchdog)”。可以写一个 DaemonSet,每 10 秒去 APIServer 检查特定 Chaos 对象是否存在,如果 APIServer 超时无响应,或者 Chaos 对象已被标记删除但底层规则还在,DaemonSet 直接在本地执行 tc qdisc del 或 bpf-loader unload 强制清理底层规则,确保业务绝对存活。

    Q2: 使用 PodChaos 注入了 CPU 满载(Stress)故障,为什么进容器敲 top 命令看到的 CPU 使用率并没有飙升? 这是容器隔离性带来的经典视图问题。top 命令读取的是 /proc/stat,默认情况下容器内挂载的是宿主机的 /proc 系统(除非你使用了 lxcfs 这类用户态文件系统)。因此 top 看到的是整个宿主机的 CPU 状态。要确认容器是否被压满,应该在宿主机查看目标容器对应的 cgroup 统计指标:cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-pod.slice/cpuacct.usage_percpu。

    Q3: 注入 IO 故障后,为什么 Node 节点内核直接发生 Panic 重启了? eBPF 的能力虽然强大,但拦截诸如 vfs_read/write 属于非常底层的内核操作。在特定的 Linux 内核版本(尤其是一些云厂商魔改的 4.19.x 分支)中,bpf 钩子与系统现有的某些内核模块(如特定的存储驱动)会产生竞态条件。遇到内核 Panic,首先通过 kdump 捕获 vmcore,用 crash 工具查看堆栈调用树(Backtrace),通常能看到 bpf_prog_XXX 导致了空指针解引用。解决办法是:升级内核至稳定版(如 5.4.x),或改用相对高层的应用级注入方案。