标签: tc netem

  • 深入混沌工程陷阱排查:无差别 tc 丢包注入引发的 Kubelet 脑裂驱逐风暴与爆炸半径管控实战

    排查过程中,我们遭遇了一次由于混沌工程(Chaos Engineering)实施不当引发的生产级雪崩。原本只是一次常规的 GameDay 演练,目的是向支付网关注入 100% 网络丢包以验证断路器(Circuit Breaker)的降级 SLO,最终却导致该节点被标记为 NotReady,数百个核心 Pod 被强行驱逐,跨可用区的网络流量剧烈抖动。最终结论很明确:在对配置了 hostNetwork: true 的目标 Pod 注入网络故障时,未做端口或目标 IP 级别的精细过滤,直接在物理网卡上挂载了全局的 tc netem 丢包规则,阻断了 Kubelet 与 API Server 的心跳,导致控制平面触发了无差别的脑裂驱逐(Eviction)。

    混沌工程的核心原则是“控制爆炸半径”,而不是在没有消防措施的情况下往机房里扔燃烧弹。这种因为对底层网络命名空间(Network Namespace)和 K8S 节点状态机理解不透彻造成的事故,属于典型的低级失误。

    案发现场:从演练降级到节点失联

    某次核心链路故障演练期间,监控大盘显示支付网关的 99 线延迟如预期般飙升,断路器指标(hystrix_circuit_breaker_open)也正常触发。但演练进行到第 60 秒时,事态失去控制:

    1. Prometheus 疯狂涌出 KubeNodeNotReady 告警。

    2. 该节点上的数十个与演练完全无关的微服务(包括本地缓存 Redis 和日志采集 DaemonSet)连接数全部归零。

    3. K8S 控制平面开始调度新 Pod 到其他节点,引发瞬间的镜像拉取风暴和 CPU 满载(Load Average 瞬间飙升到 80+)。

    登录目标节点,系统自身其实毫无压力,CPU、内存、磁盘 IO 全在低位。但查看 Kubelet 日志,满屏都是触目惊心的超时报错:

    E0914 10:23:45.123456    1234 kubelet_node_status.go:456] Error updating node status, will retry: error getting node "node-pay-01": Get "https://10.0.0.1:6443/api/v1/nodes/node-pay-01": dial tcp 10.0.0.1:6443: i/o timeout
    E0914 10:23:55.124567    1234 kubelet_node_status.go:456] Error updating node status, will retry: error getting node "node-pay-01": Get "https://10.0.0.1:6443/api/v1/nodes/node-pay-01": dial tcp 10.0.0.1:6443: i/o timeout
    

    Kubelet 彻底哑火了。

    底层机制:tc netem 与 hostNetwork 的致命交叉

    为什么对一个“支付网关 Pod”注入网络故障,会干掉整个宿主机的网络?

    排查混沌注入工具(此处使用的是基于 eBPF/tc 封装的底层执行器)的注入记录,并直接在问题节点执行 tc qdisc show 查看流量控制规则:

    $ tc qdisc show dev eth0
    qdisc netem 8001: root refcnt 9 loss 100%
    

    破案了。工具在物理网卡 eth0 的 root 队列规则(qdisc)上,直接挂载了 netem loss 100%。

    在 Kubernetes 环境中,绝大多数 Pod 运行在独立的 Network Namespace 中(由 CNI 分配独立 IP 和 veth pair),对这些 Pod 注入网络故障,tc 规则只会作用于 Pod 对应的 veth 接口或内部的 eth0 上。 然而,由于支付网关需要极高的网络吞吐,运维团队为该 Deployment 配置了 hostNetwork: true。这意味着该 Pod 共享宿主机的 Root Network Namespace。

    当混沌引擎按照常规逻辑查找该 Pod 的网络接口并实施注入时,它拿到的是宿主机的 eth0。更要命的是,注入配置里没有指定任何 Filter(过滤器),直接进行了全局 100% 丢包。

    来看看 K8S 节点状态机的默认时间线:

    1. Kubelet 每 10 秒向 API Server 发送一次心跳(node-status-update-frequency)。

    2. 当 API Server 连续 40 秒(node-monitor-grace-period)收不到心跳,kube-controller-manager 就会将 Node 标记为 NotReady。

    3. 如果节点持续 NotReady 达到 300 秒(Pod 默认的 tolerationSeconds),控制平面将触发驱逐逻辑。由于演练中某些关键有状态 Pod 的容忍度被调整为了 60 秒,驱逐几乎在节点失联后立刻发生。

    拨乱反正:爆炸半径的精细管控

    混沌工程不是盲目的破坏。既然我们要对 hostNetwork 下的特定进程注入故障,就必须在内核网络栈层面实现精确制导。

    1. 废弃无差别的 root qdisc

    切忌直接在 root 节点挂载 netem,必须结合 htb (Hierarchical Token Bucket) 或 prio 建立层级队列,并使用 u32 或 cgroup 过滤器匹配特定流量。

    如果要针对支付网关(监听在 8080 端口,目标下行调用 3306 数据库)进行丢包注入,正确的底层 tc 规则构建路径应该是:

    # 1. 在 eth0 根节点创建 prio 队列(分为 3 个频段,默认走 band 2)
    tc qdisc add dev eth0 root handle 1: prio
    
    # 2. 在 band 1 (handle 1:1) 上挂载 netem 丢包规则
    tc qdisc add dev eth0 parent 1:1 handle 10: netem loss 100%
    
    # 3. 使用 u32 过滤器,仅将发往 3306 端口的流量导向丢包队列 (classid 1:1)
    tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
        match ip dport 3306 0xffff flowid 1:1
    
    # 4. (关键) 显式放行 Kubelet 与 API Server 的通信端口 6443,导向正常队列 (classid 1:2)
    tc filter add dev eth0 protocol ip parent 1:0 prio 0 u32 \
        match ip dport 6443 0xffff flowid 1:2
    

    2. 使用支持白名单的现代混沌工具

    如果在 Kubernetes 中使用 Chaos Mesh 或 Litmus,必须在 YAML 中显式配置目标及绕过规则。现代混沌工具提供了网络级别的高级选择器。例如 Chaos Mesh 的 NetworkChaos:

    apiVersion: chaos-mesh.org/v1alpha1
    kind: NetworkChaos
    metadata:
      name: payment-loss-injection
    spec:
      action: loss
      mode: one
      selector:
        labelSelectors:
          app: payment-gateway
      loss:
        loss: '100'
      # 必须限制方向,并在外部控制器中规避控制面端口
      direction: to
      target:
        selector:
          labelSelectors:
            app: mysql-cluster
    

    如果是针对 hostNetwork,必须开启特定的 BPF 注入模式或在系统级预设 K8S 控制平面的网段白名单(CIDR exclude)。

    避坑总结

    高可用系统的韧性验证,基础是敬畏系统。在生产环境进行故障注入,必须具备“一键终止(Abort Button)”的能力,但如果网络故障直接干掉了节点级别的通信,你的“一键终止”指令连下发到 Chaos Daemon 的通道都会被切断,这就演变成了不可控的灾难。做混沌实验前,先问自己三个问题:目标域到底在哪个 Namespace?底层注入手段会不会产生越权逃逸?如果控制通道断了,实验能不能自动回滚?

    同类问题速查排查清单

    1. 宿主机网络逃逸排查:检查目标业务 Pod 是否配置了 hostNetwork: true。如果是,常规的网络故障注入(如直接作用于网卡的 tc 规则)将污染整个宿主机网络环境。

    2. Kubelet 心跳阻断确认:当节点突然 NotReady,优先检查 API Server 审计日志与问题节点本地 Kubelet 日志,确认 i/o timeout 端口是否为 6443,判断是否为网络层拦截导致。

    3. 残留 tc 规则清理:如果注入工具异常退出导致规则未清理,手动执行 tc qdisc del dev root 恢复网络通信,切忌盲目重启节点。

    4. 混沌控制通道隔离:混沌代理(Chaos Daemon)所在的端口和控制平面(API Server 6443, etcd 2379, kubelet 10250)必须在注入系统的全局白名单(Whitelist/Bypass)中,确保任何情况下的实验可中止性。

  • 深入 Chaos Mesh 陷阱排查:TC netem 队列溢出引发的静默丢包与 GameDay 失控实战

    在进行高并发场景的混沌工程(Chaos Engineering)演练时,网络延迟注入常因 Linux 内核 tc netem 默认队列长度(1000)的硬限制,在超过 QPS * 延迟时间 的吞吐下演变为静默丢包风暴。这会导致预期内的延迟降级被放大为级联雪崩。破局点在于遵循排队论(Little’s Law)调整 qdisc 队列上限,或在高并发链路转向 L7 层故障注入,并建立基于底层的爆炸半径监控。

    故障现场:失控的 GameDay

    在某次支付核心链路的 GameDay 演练中,SRE 团队计划验证断路器(Circuit Breaker)在弱网环境下的 SLO 表现。 演练目标:向支付网关(Payment Gateway)所在的 Pod 注入固定 100ms 的网络延迟。 预期指标:接口 P99 延迟升高至 120ms 左右,部分请求触发熔断,降级链路生效,整体成功率保持在 99.9% 以上。 环境配置:Kubernetes v1.24.10, 操作系统 Alinux 3 (Kernel 5.10), Chaos Mesh v2.6.2。

    通过 Chaos Mesh 下发 NetworkChaos 资源后,监控面板瞬间亮起红灯:

    1. 支付网关的上游 Nginx 出现海量 504 Gateway Timeout 和 502 Bad Gateway。

    2. 支付网关自身的 P99 延迟飙升至 3~5 秒,远超预期的 120ms。

    3. 容器 CPU 出现毛刺,节点 Load Average 飙升。

    4. 业务成功率暴跌至 70%,被迫紧急触发 Chaos Mesh 的 Pause 机制,演练以严重事故(SEV)收场。

    抽丝剥茧:消失的报文与 TCP RTO 退避

    排查过程中,我们首先怀疑是底层网络插件(Cilium/Calico)与 Chaos Mesh 的 eBPF 探针产生了冲突。但在复现环境中,资源水位一切正常。

    为了拿到第一手现场,我们在复现期间通过 nsenter 进入了目标 Pod 的网络命名空间:

    # 获取目标 Pod 的 PID
    PID=$(crictl inspectp $(crictl pods --name payment-gateway-0 -q) | jq .info.pid)
    
    # 进入 Pod 的网络命名空间
    nsenter -t $PID -n
    
    # 检查网卡设备的 qdisc 规则与统计
    tc -s qdisc show dev eth0
    

    终端输出了令人头皮发麻的指标:

    qdisc netem 1: root refcnt 2 limit 1000 delay 100ms
     Sent 1450230 bytes 1230 pkt (dropped 84503, overlimits 0 requeues 0) 
     backlog 14000b 1000p requeues 0
    

    注意看 dropped 84503 和 backlog 14000b 1000p。 这意味着 tc netem 模块正在疯狂丢包。这不仅是“延迟”,这是毁灭性的“断网”。

    伴随着底层丢包,应用层的 TCP 连接开始进入漫长的超时重传(RTO 退避)机制。通过抓包(tcpdump)分析,发现大量的 TCP 报文触发了 200ms、400ms、800ms 的指数级重传退避,直接导致应用层的 P99 延迟被放大到秒级。大量积压的半连接和重传报文耗尽了应用的 worker 线程池,最终导致网关假死。

    为什么单纯的网络延迟注入会演变成毁灭性的丢包风暴?

    要理解这个陷阱,必须深入 Linux 内核的流量控制子系统(Traffic Control)和排队论。

    Chaos Mesh 的 NetworkChaos 底层依赖于 Linux Kernel 的 netem (Network Emulator) 调度器。当你执行延迟注入时,Chaos Mesh 的 chaos-daemon 实际上是在 Pod 的 veth 设备上执行了类似如下的命令:

    tc qdisc add dev eth0 root netem delay 100ms
    

    陷阱在于 netem 的默认队列长度限制(limit)。 在内核源码 net/sched/sch_netem.c 中,当一个数据包进入 netem 队列(netem_enqueue)时,内核会计算当前队列长度:

    static int netem_enqueue(struct sk_buff *skb, struct Qdisc *sch,
                 struct sk_buff **to_free)
    {
        struct netem_sched_data *q = qdisc_priv(sch);
        /* ... 延迟计算逻辑 ... */
    
        if (likely(sch->q.qlen < sch->limit)) {
            return qdisc_enqueue_tail(skb, sch);
        }
        // 队列满了,直接丢弃!
        return qdisc_drop(skb, sch, to_free);
    }
    

    默认情况下,netem 的 limit 被硬编码或初始化为 1000 个数据包。

    引入排队论中的利特尔法则(Little’s Law):L = λW

    • L:系统中的平均请求数(在这里是队列中积压的数据包数量)

    • λ:请求到达率(在此场景下为网卡的 PPS,即每秒数据包吞吐量)

    • W:请求在系统中停留的时间(在这里是我们注入的 100ms 延迟,即 0.1s)

    在我们的高并发支付网关场景中,日常吞吐量约为 15,000 QPS,算上 TCP ACK 和分片,实际 PPS 超过 20,000。 将这些数据代入公式: L = 20,000 pkt/s * 0.1 s = 2,000 pkt

    这意味着,为了仅仅维持住 100ms 的延迟而不丢包,netem 队列中时刻需要容纳至少 2000 个数据包。而默认的 limit 只有 1000。 结果显而易见:多出来的包,全部触发了 qdisc_drop。 一个本意是验证延迟 SLO 的 GameDay,因为底层的物理限制,演变成了一场规模庞大的丢包雪崩。

    架构级防御与 GameDay 设计最佳实践

    发现问题后,我们必须在 Chaos 工程体系中实施防御性设计,防止类似的基础设施参数截断导致测试失真。

    1. 显式调整 tc netem limit 参数

    对于确需在网络层(L3/L4)进行大流量延迟注入的场景,必须重写或调整 qdisc 限制。部分混沌工程工具可能未直接暴露 limit 参数,可以通过以下方式介入: 如果使用原生 tc,务必带上计算后的 limit:

    # 假设预估最大 PPS 为 50000,延迟 200ms (0.2s)
    # 理论队列深度 L = 50000 * 0.2 = 10000
    # 留出 20% 冗余,设定 limit 为 12000
    tc qdisc add dev eth0 root netem limit 12000 delay 200ms
    

    在 Chaos Mesh 中,若 CRD 不支持动态扩展限额,可利用 Direction: to 与 Target 选择器,将注入目标限制在特定的下游弱依赖 IP 或端口上,从而大幅度降低匹配该 qdisc 规则的真实 PPS(λ),规避队列溢出。

    2. 高并发链路转向 L7 故障注入

    对于 QPS 动辄破万的 RPC/HTTP 微服务网关,在 L3/L4 模拟延迟极易触碰内核栈瓶颈。最佳实践是利用 Service Mesh(如 Istio / Envoy)的 L7 Fault Injection。 L7 注入发生在用户态,通过挂起协程/线程来模拟延迟,不消耗内核 qdisc 队列,彻底消除了底层丢包的副作用。

    apiVersion: networking.istio.io/v1alpha3
    kind: VirtualService
    metadata:
      name: payment-route
    spec:
      hosts:
      - payment-service
      http:
      - fault:
          delay:
            percentage:
              value: 100.0
            fixedDelay: 0.1s
        route:
        - destination:
            host: payment-service
    

    3. 构建基于旁路指标的“硬刹车”机制

    在 GameDay 中,必须建设基于基础设施指标的自动化 Abort 机制。不能仅盯业务大盘。应将 Node Exporter 的 node_network_transmit_drop_total 或底层 tc 的 drop 速率接入 Prometheus 告警。一旦发现注入期间网卡 drop 速率陡增,混沌平台应通过 Webhook 自动中止演练。

    常见问题

    Q1:进行网络故障注入时,为什么有时会导致 Kubernetes Node 直接变成 NotReady 状态? 这是典型的“爆炸半径穿透”。如果在注入时没有严格指定网卡(如误将规则挂载到了宿主机的 eth0 或 cni0 桥接网上),或者未配置白名单,故障规则会拦截 Kubelet 与 API Server 的心跳通信(默认 10250 端口及 6443 端口)。Kubelet 无法上报状态,Node 就会被标记为 NotReady,随后触发惨烈的 Pod 全局大驱逐(Eviction Storm)。

    Q2:当混沌工程控制面(如 Chaos Controller)宕机,导致注入的故障无法恢复,应如何自救? 这是防御性演练必须考虑的极端场景。底座必须备有“一键逃生”脚本,通过 Ansible 或 K8s DaemonSet 绕过控制面,直接到底层 Node 节点清理网络命名空间内的规则。逃生核心命令为: for pid in $(crictl ps -q | xargs crictl inspectp | grep pid | awk '{print $2}'); do nsenter -t $pid -n tc qdisc del dev eth0 root; done

    Q3:网络丢包注入(Loss)和网络延迟注入(Delay)在内核层的消耗有什么区别? 延迟注入(Delay)需要内核将报文暂存在内存队列中直到时间到期,极度消耗队列长度(limit)和内存资源(backlog),受排队论直接影响;而丢包注入(Loss)是通过内部的随机数生成器(如 prandom_u32())判断命中后直接 kfree_skb 释放内存,对队列深度的压力极小。因此,高并发下注入 Delay 远比注入 Loss 容易引发次生灾害。