凌晨三点的OOM惨案:论为什么不要在容器里动 oom_score_adj

凌晨3点15分,桌上的咖啡早就凉透了。Prometheus的报警把一块屏幕映得通红,K8s集群里一台核心Node节点状态直接变成了 NotReady
尝试直接SSH连上去,终端卡死在 ssh_exchange_identification。毫无响应。这种级别的挂死,连句柄都分配不出来,没办法,只能切进带外管理(IPMI),强行发送重置指令给服务器硬重启。
机器重新起飞后,我第一时间切进去捞 /var/log/messagesdmesg 的尸体。满屏的 Out of memory: Killed process。这在K8s环境里本是家常便饭,无非是哪个业务内存泄露,或者突发流量打爆了堆内存,触发了Cgroup的 memory.limit_in_bytes
但越往下看,我的眉头皱得越紧。
被OOM Killer干掉的并不是业务进程。先倒下的是 systemd-journald,紧接着是 sshd,最后连 kubeletcontainerd 都未能幸免。整个宿主机的基础设施被屠戮殆尽。而那个跑着祖传Java单体应用、堆外内存泄露把整机内存吃光的一个Pod,竟然稳如泰山地活到了宿主机彻底失联的前一秒。
这违背了最基本的操作系统常识和K8s资源管理逻辑。我顺藤摸瓜去查这个Deployment的YAML,看到了一段让我血压飙升的配置:

securityContext:
  privileged: true
lifecycle:
  postStart:
    exec:
      command: ["/bin/sh", "-c", "echo -1000 > /proc/1/oom_score_adj"]

不知道是哪个自作聪明的开发(或者半吊子运维),因为解决不了这个Java应用频繁的内存泄露,又不想总是半夜被Pod Restart的报警吵醒,居然想出了这种“绝妙”的偏门手段。申请了特权模式(privileged: true),然后在 postStart 钩子里,硬生生把容器主进程的 oom_score_adj 改成了 -1000
只要稍微翻过Linux内核 mm/oom_kill.c 的源码,就该知道 -1000 这个魔术数字意味着什么。
内核在发生全局内存耗尽(Global OOM)时,会调用 oom_badness() 函数给每个进程打分(oom_score)。基础分数基于进程的驻留集大小(RSS)、页表和交换缓存的使用量。占得越多,分数越高,越容易被杀。但在此基础上,内核提供了一个决定性的调节值:oom_score_adj
这个调节值的有效范围是 -10001000。当你把它设为 -1000 时,等于触碰了内核硬编码的底线宏 OOM_SCORE_ADJ_MIN。你就是在告诉内核的OOM Killer:“这块拥有免死金牌,哪怕系统崩溃也绝对不要杀它”。
在K8s的QoS(服务质量)体系里,kubelet 原本会精细地管理这些分数:
kubeletcontainerd 等核心系统守护进程,为了保命,分数通常被设定在 -999
Guaranteed 级别的Pod(Requests == Limits),分数是 -997
BestEffort 级别的Pod,分数直接拉满到 1000,一旦资源紧张,最先被献祭。
而这家伙,直接给一个破绽百出的业务进程赋了 -1000。它的优先级居然比宿主机底层的 kubelet 还要高。
最终的现场就是:当这个Java应用无休止地吞噬宿主机内存,耗尽了所有物理内存,触发系统级OOM时,内核的OOM Killer苏醒了。它提着刀巡视了一圈,发现罪魁祸首挂着 -1000 的免死金牌,动不了。于是只能含泪把旁边无辜的 kubeletsshd 等核心进程一个个砍死,以求释放哪怕几兆的内存。核心进程一死,节点处于假死状态,控制面将Node标记为 NotReady,进而引发网络分片和路由黑洞。
用破坏内核保护机制的代价去掩盖应用层拙劣的代码缺陷,这是架构演进中最愚蠢的妥协。
我已经把这个Pod的特权模式强行剥夺,剔除了所有 lifecycle 钩子,并加上了极其严格的 resources.limits。至于那个接下来必然会不断被 OOMKilled 状态重启的应用,让业务研发自己去挂载卷抓 Heap Dump 分析吧。
技术结论:永远不要在用户态的业务进程中使用 oom_score_adj = -1000。在云原生架构下,资源隔离的底线在于Cgroup机制和K8s QoS策略。特权容器的滥用加上对内核OOM打分机制的篡改,等同于将整台宿主机的生命周期交托给了一个随时会引爆的内存炸弹。系统运维的核心是控制爆炸半径,而不是给炸弹穿上防弹衣。