本文直接给出结论:在进行混沌工程内存注入演练时,如果目标 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 防线?
这里有两个核心悖论:
-
Kubernetes 的 Kubelet 有
evictionHard机制(默认memory.available<100Mi),为什么没提前驱逐 Pod? -
为什么 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
我们看看当时的算分情况:
-
目标 Java 进程:消耗内存不大,默认
oom_score_adj。 -
stress-ng 注入进程:消耗了绝大部分内存,本应分数最高。但我们在 YAML 里手贱配置了
oomScoreAdj: -500(为了防止混沌测试进程刚启动就被杀)。这导致它的最终得分大幅降低。 -
kubelet:官方默认配置了
oom_score_adj = -999,可以说是拿到了"免死金牌"。 -
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 下的 containerd 和 sshd。
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-killer 或 cat /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)。