分类: 云原生

  • 深入 Chaos Mesh 陷阱排查:StressChaos 击穿 cgroup 触发全局 OOM 与 GameDay 节点雪崩实战

    本文直接给出结论:在进行混沌工程内存注入演练时,如果目标 Pod 未配置硬性 Memory Limit(处于 Burstable/BestEffort 状态),且注入工具(如 Chaos Mesh 的 stress-ng)以极快速度(如 >100MB/s)分配内存,将直接绕过 Kubelet 周期为 10s 的 cAdvisor 驱逐检测,触发宿主机内核全局 OOM(Global Out of Memory)。最终导致核心组件(如 containerd/calico)被 OOM Killer 绞杀,引发 Node NotReady 和不可控的 Pod 驱逐雪崩。解决方案是:强制落地 LimitRange,配置 Kubelet 严格的 enforce-node-allocatable 屏障,并控制注入速率。

    排查过程中,我们正在对核心交易链路进行一次常态化的 GameDay 演练。演练 SLO 是:单节点内某个无状态服务突发内存泄漏(OOMKilled)时,K8S ReplicaSet 能够在一分钟内完成自愈调度,且上游关流平滑,P99 延迟波动不超过 50ms。

    然而,演练按钮按下的第 15 秒,监控大盘直接被红色淹没。目标 Pod 并没有按照预期发生局部重启,而是其所在的整个宿主机节点直接陷入 NotReady 状态,该节点上的 40 多个无关业务 Pod 全部触发 Eviction 驱逐,大面积重调度瞬间击穿了我们的依赖服务,爆炸半径完全失控。

    我不喜欢在 PPT 里讲容灾,GameDay 就是最好的照妖镜。下面还原当时的排查现场与底层根因。

    故障现场:一个 StressChaos 干挂了整片 Node

    我们使用的混沌工程平台是 Chaos Mesh (v2.6.2),目标环境为 Kubernetes v1.24,宿主机内核为 CentOS 7 的 5.4.x。当时下发的 StressChaos 配置如下:

    apiVersion: chaos-mesh.org/v1alpha1
    kind: StressChaos
    metadata:
      name: memory-leak-injection
      namespace: chaos-testing
    spec:
      mode: one
      selector:
        labelSelectors:
          app: payment-gateway
      stressors:
        memory:
          workers: 4
          size: '8GB'
          oomScoreAdj: -500 # 企图保护注入进程自身
      duration: '5m'
    

    直观上看,这个注入的逻辑很简单:通过 chaos-daemon 侵入目标 Pod 的 cgroup namespace,启动 4 个 stress-ng worker,吃掉 8GB 内存。

    但在目标节点的 /var/log/messages 中,我们看到了极其惨烈的内核日志:

    kernel: [123456.789] stress-ng-vm invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=-500
    kernel: [123456.790] CPU: 12 PID: 45678 Comm: stress-ng-vm Tainted: G        W         5.4.203-1.el7.elrepo.x86_64
    ...
    kernel: [123456.812] Out of memory: Killed process 3845 (containerd) total-vm:4589312kB, anon-vm:894320kB, file-vm:0kB, shmem-vm:0kB, UID:0 pgtables:2312kB oom_score_adj:-999
    kernel: [123456.815] oom_reaper: reaped process 3845 (containerd), now anon-vm:0kB, file-vm:0kB, shmem-vm:0kB
    

    内核的 OOM Killer 被唤醒,它没有干掉我们的目标业务进程,也没有干掉 stress-ng,而是把节点上的 containerd 给绞杀了。CRI 运行时一挂,Kubelet 随之陷入 PLEG (Pod Lifecycle Event Generator) timeout 死循环,节点状态直接变为 NotReady

    为什么 100MB/s 的内存注入会击穿 cgroup 防线?

    这里有两个核心悖论:

    1. Kubernetes 的 Kubelet 有 evictionHard 机制(默认 memory.available<100Mi),为什么没提前驱逐 Pod?

    2. 为什么 cgroup 机制没有把目标 Pod 限制住(Cgroup OOM),而是触发了全局内存耗尽(Global OOM)?

    Kubelet 驱逐的"盲区时间"

    Kubelet 的驱逐机制属于用户态行为。它依赖内置的 cAdvisor 组件来周期性地抓取容器资源的利用率,这个指标采集的默认周期(Housekeeping Interval)大约是 10 到 15 秒。 在我们的演练中,stress-ng 通过 mmap() 配合多线程快速触发 Page Fault 分配物理内存,分配速率轻松超过 1GB/s。 当系统剩余可用内存从 2GB 掉到 100MB 以下时,只需不到 2 秒。在这 2 秒内,cAdvisor 根本还没来得及完成下一次指标轮询,Kubelet 完全不知道节点内存已经枯竭,自然无法发起 Eviction 驱逐。

    Cgroup OOM 与 Global OOM 的区别

    如果 Pod 配置了完整的 resources.limits.memory(属于 Guaranteed QoS),cgroup 目录底下的 memory.limit_in_bytes 会有一个明确的上限值。当 stress-ng 吃满这个值时,内核会触发基于该 cgroup 的 OOM(Cgroup OOM),这只会杀死该 Pod 内的进程,对宿主机无害。

    但排查发现,目标 Pod 恰好是一个历史遗留服务,开发只配了 requests,没有配 limits(QoS 为 Burstable)。 这意味着该容器对应的 cgroup 没有任何硬性内存上限(memory.limit_in_bytes = 9223372036854771712,即无限大)。 因此,注入进程直接把整台物理机的物理内存 + Swap 吃光了,最终导致内核触发全局级别的 OOM(Global OOM)

    内核 OOM Killer 的无差别绞杀逻辑

    触发 Global OOM 后,Linux 内核必须牺牲某个进程来拯救系统。这个死刑判决基于 oom_score(范围 0 ~ 1000),分数越高的进程越先被杀。其计算公式大致为: oom_score = (进程消耗的物理内存百分比 * 10) + oom_score_adj

    我们看看当时的算分情况:

    1. 目标 Java 进程:消耗内存不大,默认 oom_score_adj

    2. stress-ng 注入进程:消耗了绝大部分内存,本应分数最高。但我们在 YAML 里手贱配置了 oomScoreAdj: -500(为了防止混沌测试进程刚启动就被杀)。这导致它的最终得分大幅降低。

    3. kubelet:官方默认配置了 oom_score_adj = -999,可以说是拿到了"免死金牌"。

    4. containerd / calico-node:在这个版本的集群环境中,部署脚本遗漏了对这些关键系统守护进程配置 oom_score_adj 的下调操作(默认是 0)。

    于是荒诞的一幕出现了:stress-ng 吃光了内存但因为有护身符逃过一劫;containerd 虽然占用不多,但相比有着 -999 护身符的 kubelet 来说,成了分最高的"软柿子",直接被 OOM Killer 刀了。

    防御性 GameDay 的架构加固方案

    不要把演练变成灾难,我们需要在底座和混沌工程平台两端加上硬性约束(Guardrails)。

    1. 补齐 Kubelet 节点级别的资源硬防线 (Cgroup 层)

    仅仅靠用户态的 Kubelet 驱逐是不够的,必须利用 Kubelet 的 Node Allocatable 特性,在内核 cgroup 层面画好红线。

    在 Kubelet 参数中增加以下配置:

    --enforce-node-allocatable=pods
    --system-reserved=cpu=2,memory=4Gi,ephemeral-storage=10Gi
    --kube-reserved=cpu=1,memory=2Gi,ephemeral-storage=5Gi
    

    原理解析:开启 enforce-node-allocatable=pods 后,Kubelet 会在宿主机上创建一个顶级的 kubepods cgroup,并将所有的 Pod 都挂载在这个 cgroup 之下。该 cgroup 的最大内存限制被硬编码为 Node Capacity - kube-reserved - system-reserved。 这样,哪怕所有 Pod 都没有配 Limit,它们联合起来能消耗的最大内存也会被死死按在 kubepods 的 cgroup 隔离带里。一旦越界,内核只会触发 kubepods 这个 cgroup 层级的 OOM,绝对不会影响到处于 system.slice 下的 containerdsshd

    2. 收拢 Chaos Mesh 爆炸半径:限制注入速率与打分

    在执行 Memory StressChaos 时,严禁使用过低的 oomScoreAdj,同时必须限制 worker 的内存分配速率。可以利用 stress-ng 的底层参数,不要让其"毕其功于一役"地申请内存。

    修改后的安全演练 YAML:

    apiVersion: chaos-mesh.org/v1alpha1
    kind: StressChaos
    metadata:
      name: safe-memory-leak
    spec:
      mode: one
      selector:
        labelSelectors:
          app: payment-gateway
      stressors:
        memory:
          workers: 1
          size: '2GB'
          # 移除 oomScoreAdj,让 stress-ng 接受正常的 OOM 制裁
          options:
            - '--vm-keep'    # 持续保持内存占用而不是反复分配/释放
            - '--vm-bytes' 
            - '2G'
    

    3. 拦截违规负载:强制引入 LimitRange 与 Mutating Webhook

    运维必须兜底。为了防止开发再次上线只有 requests 没有 limits 的“定时炸弹”,在所有 namespace 强制下发 LimitRange,对没有配置 limits 的 Pod 进行自动补全:

    apiVersion: v1
    kind: LimitRange
    metadata:
      name: enforce-limits
    spec:
      limits:
      - default:
          memory: "2Gi" # 强制加上 Limit,将容器锁定在 Cgroup OOM 范畴
        defaultRequest:
          memory: "512Mi"
        type: Container
    

    常见问题 (FAQ)

    Q1:进行 CPU 混沌注入(CPU Stress)时,是否也会遇到这种节点雪崩的风险? 相对可控。CPU 是可压缩资源(Compressible Resource),而内存是不可压缩资源。即使 CPU 被 stress-ng 打满,内核通过 CFS (Completely Fair Scheduler) 仍能保证关键进程(如 kubelet,通常在启动时会拉高 nice 优先级或单独的 cpuset)获得时间片。但如果大量生成陷入内核态(D状态)的压测进程,耗尽了 PID 或者导致 Syscall 卡死,依然可能拖垮节点。

    Q2:如何确认节点是因为 Global OOM 崩溃,而不是因为 Kubelet 驱逐引起的重启? 直接登录现场宿主机(如果还能登入),或者查看采集的系统日志:执行 dmesg -T | grep -i oom-killercat /var/log/messages | grep "Out of memory"。如果有大量针对非目标进程的 Killed process 日志,且没有 Kubelet Evicting Pod 的审计事件,那大概率就是发生了击穿 cgroup 的 Global OOM。

    Q3:在 Cgroup v2 环境下,内存混沌注入的行为会有变化吗? Cgroup v2 引入了更平滑的内存控制机制,比如 memory.high(软限流,触达后会大力压制进程运行并强制内存回收)和 memory.max(硬限流,等同于 v1 的 limit_in_bytes)。如果在 v2 集群下配合 Kubelet 的 MemoryQoS 特性,未配 Limit 的 Pod 在吃光内存前,会先触发 memory.high 的限流惩罚,这在一定程度上延缓了内存飙升速度,能给 cAdvisor 和 Kubelet 留出更多时间执行优雅驱逐(Eviction)。

  • 深入 K8S veth pair 丢包排查:高 PPS 触发的 SoftIRQ 单核瓶颈与 macvlan 卸载实战

    在 K8S 容器网络中,高并发(PPS > 30万)场景下 veth pair 极易因单队列架构触发宿主机单核 SoftIRQ (NET_RX) 100% 饱和,导致严重丢包与网络抖动。临时止血方案需在宿主机端开启 RPS(Receive Packet Steering)将软中断打散;而彻底解决该类 I/O 密集型业务瓶颈,应引入 macvlan 或 SR-IOV 进行网络栈卸载,直接旁路宿主机内核的复杂转发路径。

    故障现场:Redis 容器的神秘丢包与 99 线飙升

    近期排查了一起 K8S 集群内 Redis 响应毛刺问题。环境基础信息如下:

    • OS: Ubuntu 22.04 (Kernel 5.15.0-76-generic)

    • K8S 版本: v1.25.9

    • CNI: Calico v3.25.0 (BGP 路由模式)

    • 业务表现: 压测期间 Redis 实例的 QPS 达到 8 万时,p99 延迟从 2ms 突变至 150ms 以上,客户端频繁报 Read timed out

    首先登入 Redis 所在宿主机,直接通过 mpstat 查看中断分布:

    # 每秒输出所有 CPU 核状态
    mpstat -P ALL 1
    
    09:41:01 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
    09:41:02 AM  all    8.23    0.00    6.11    0.00    0.00   12.15    0.00    0.00    0.00   73.51
    09:41:02 AM    0    4.00    0.00    3.00    0.00    0.00    1.00    0.00    0.00    0.00   92.00
    ...
    09:41:02 AM   12    2.00    0.00   10.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
    

    如上所示,CPU 12 的 %soft 已经被彻底打满(100%)。进一步通过 /proc/softirqs 定位具体的中断类型:

    watch -d -n 1 "cat /proc/softirqs | grep NET_RX"
    

    确认是 NET_RX 软中断风暴。接着查看容器对应的宿主机端 veth 网卡(假设为 cali9a3b2c1)的丢包统计:

    # 确认网卡 rx_dropped 指标疯狂上涨
    ip -s link show cali9a3b2c1
    

    现象明确:宿主机单核处理软中断能力达到极限,导致网卡接收队列(Backlog)溢出,底层协议栈开始大面积丢弃数据包。

    为什么 veth pair 会成为高吞吐场景的性能毒药?

    要搞清楚这个问题,必须深入 veth pair 在内核中的数据流转机制。

    veth pair 是一对虚拟以太网设备。在 Calico 网络下,数据包从物理网卡(如 eth0)进入宿主机,经过内核路由判决后,发往对应的宿主机端 veth 设备(caliXXX),然后再进入容器的网络命名空间。

    对于物理网卡,现代网卡均支持多队列(RSS, Receive Side Scaling),可以通过 Hash 算法将不同数据流的硬件中断(HardIRQ)分发到多个 CPU 核上,进而触发多核并发处理 NET_RX 软中断。

    但 veth pair 是纯软件模拟的虚拟网卡,默认只有单队列(rx-0/tx-0)。 当数据包从物理网卡路由到 caliXXX 时,内核调用 dev_forward_skb,最终触发 netif_rxskb(套接字缓冲区)压入特定 CPU 的 softnet_data->input_pkt_queue 中。 由于 veth 没有硬件多队列支撑,所有发往该容器的数据包,其软中断处理逻辑通常只能由单核(通常是触发调用的源 CPU,或者被网卡中断绑定的固定 CPU)串行执行。当流量达到几十万 PPS 时,这个单 CPU 很快就会触及 100% 的瓶颈,导致后续包因为 Backlog 队满而被丢弃。

    实战破局:从软件调优到硬件卸载

    针对上述瓶颈,我们在实战中通常采用两个阶段的方案:快速止血与架构重构。

    第一阶段:软件层面开启 RPS 打散软中断

    RPS(Receive Packet Steering)是 RSS 的软件实现。它能在 netif_rx 接收到包后,利用四元组 Hash 软计算,将包投递到其他 CPU 的积压队列中,强制触发跨核的软中断处理。

    找到 Redis 对应的宿主机网卡 cali9a3b2c1,为其配置 RPS(假设宿主机为 16 核,我们将掩码设为 ffff,允许打散到所有核):

    # 将 16 进制掩码写入对应接收队列的 rps_cpus 中
    echo ffff > /sys/class/net/cali9a3b2c1/queues/rx-0/rps_cpus
    
    # 同步调大内核层面的 backlog 队列深度,防止缓冲击穿
    sysctl -w net.core.netdev_max_backlog=10000
    

    开启后,再次观察 mpstat,CPU 12 的 %soft 迅速下降至 30% 左右,其他 CPU 的 %soft 开始均衡上升,Redis 响应延迟立刻恢复到 2ms 的水平。

    注意: 这种方案有代价。RPS 带来了额外的 CPU 周期消耗(计算 Hash、跨核 Cache Miss),整体 CPU 负载(Load Average)会显著升高。这是典型的“空间换时间”策略。

    第二阶段:引入 macvlan / SR-IOV 卸载网络栈

    对于此类极致 I/O 的业务,经过多次踩坑,最终的防线必须是绕过复杂的宿主机网络栈。通过 Multus CNI 引入 macvlanSR-IOV,是当前主流的解法。

    macvlan 桥接模式为例,它的底层原理是直接在宿主机物理网卡(eth0)上虚拟出一个具有独立 MAC 地址的子接口。数据包到达物理网卡后,底层驱动通过匹配 MAC 地址,直接将包送入容器的 Network Namespace,彻底跳过了宿主机内核的路由查找、Netfilter (iptables/IPVS) 过滤以及 veth pair 的设备中转。 且 macvlan 继承了物理主网卡的 RSS 特性,天然支持多核并发接收。

    在 K8S 中配置 Multus 与 Macvlan 混合网络示例 (NetworkAttachmentDefinition):

    apiVersion: "k8s.cni.cncf.io/v1"
    kind: NetworkAttachmentDefinition
    metadata:
      name: macvlan-conf
      namespace: default
    spec:
      config: '{
          "cniVersion": "0.3.1",
          "type": "macvlan",
          "master": "eth0",
          "mode": "bridge",
          "ipam": {
            "type": "host-local",
            "subnet": "192.168.100.0/24",
            "rangeStart": "192.168.100.100",
            "rangeEnd": "192.168.100.200",
            "routes": [
              { "dst": "0.0.0.0/0" }
            ],
            "gateway": "192.168.100.1"
          }
        }'
    

    随后在 Redis Pod 中声明注解:

    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: macvlan-conf
    

    改造后,Redis Pod 获得了直通物理网络的 eth1 网卡,单机压测极限 PPS 提升了近 3 倍,且宿主机的 CPU sys/soft 占用极低。

    常见问题 (FAQ)

    Q1:为什么使用 macvlan (bridge 模式) 后,宿主机反而 ping 不通该容器了? 这是 macvlan 驱动设计的经典防线。macvlan 拦截了进出物理网卡的流量,但根据 802.1q 规范,从物理网卡发出的包默认不会回流到自己。宿主机发送的报文直接从底层网卡出去了,无法通过 MAC 匹配路由回该网卡上的 macvlan 子接口。 解法: 在宿主机上再创建一个同网段的 macvlan 接口(例如叫 macvlan-host),将宿主机对该网段的路由指向 macvlan-host,利用 bridge 模式下的内部交换机制实现通信。

    Q2:SR-IOV 与 macvlan 相比优势在哪里,什么时候必须上 SR-IOV? macvlan 仍经过宿主机的物理网卡驱动和内核协议栈底层;而 SR-IOV(Single Root I/O Virtualization)是 PCIe 硬件级别的虚拟化。它通过 PF(Physical Function)虚拟出多个 VF(Virtual Function),VF 直接映射给容器。 如果是搞 DPDK 等用户态网络协议栈,或者极低延迟(微秒级)的 HFT (高频交易) 场景,必须用 SR-IOV 彻底 Bypass 内核。普通的高性能 Redis/MySQL,macvlan 已经足够。

    Q3:开启了 RPS,但有些网卡的 rps_cpus 修改后提示 “Permission denied” 或无效? 如果是针对容器内的 veth 设备修改,受限于 NetNS 权限,需在宿主机端的对端网卡(如 Calico 的 calixxx、Flannel 的 vethxxx)操作。另外,务必确保宿主机系统服务(如 irqbalance)不要与你手动的 RPS 掩码逻辑发生冲突,排查过程中发现两者打架是常态,针对极端优化的节点,通常建议关闭 irqbalance 并手动绑核。