WEBKT

Pod 频繁异常重启?死磕 K8s OOMKilled(Exit Code 137)底层机制与排查终极指南

3 0 0 0

大半夜被告警电话叫醒,登上系统一看,某个核心微服务的 Pod 状态变成了 CrashLoopBackOff。用 kubectl describe 一看,历史容器的 Terminated 原因赫然写着:OOMKilled,退出码是标志性的 137

对于运维和开发来说,K8s 中的 OOMKilled 就像一个幽灵,经常无预警地出现,又无声无息地把容器干掉。

很多同学遇到这个问题,第一反应就是“内存给少了,加内存!”。但往往加了内存之后,过几天它又爆了。本文不聊虚的,直接从 Linux 内核和 Cgroups 的底层逻辑出发,带你拆解 K8s OOMKilled 的触发真相,并给出一套可以直接套用的精准排查与防范方法。


一、 OOMKilled 的底层逻辑:是谁动了杀心?

在 K8s 中,OOM(Out Of Memory)杀手其实有两个层级:内核级(System OOM)容器级(Cgroup OOM)

1. 内核级 OOM

当整台宿主机的物理内存彻底耗尽时,Linux 内核的 OOM Killer 机制会被激活。它会通过一套算法给所有进程打分(oom_score),分数最高的进程会被直接 Kill 掉以保护操作系统不至于崩溃。

在 K8s 节点上,这个打分机制会参考 Pod 的 QoS(服务质量)等级。K8s 会向 /proc/[pid]/oom_score_adj 写入不同的分值:

  • Guaranteed(Request 和 Limit 完全一致):oom_score_adj 通常为 -997。极难被系统杀掉。
  • Burstable(Request 小于 Limit):根据内存请求占节点的比例计算,分值一般在 2999 之间。
  • BestEffort(未设置 Request 和 Limit):oom_score_adj 直接拉满到 1000。宿主机一旦内存紧张,最先死的就是它。

2. 容器级 OOM(最常见)

这是我们最常遇到的情况。Pod 的内存并没有超出物理机上限,但超出了你在 YAML 里给它设置的 limits.memory

这个限制是由 Linux Cgroups(Control Groups) 技术实现的。当容器内进程申请的内存超过了 memory.limit_in_bytes,Cgroup 就会直接向容器内的 1 号进程或相关子进程发送 SIGKILL 信号(Signal 9)。
在 K8s 的世界里,SIGKILL 的退出码是 137(128 + 信号值 9 = 137)。


二、 避坑:你真的看懂监控里的“内存”了吗?

在排查 OOMKilled 之前,必须先纠正一个在 Prometheus/Grafana 监控中极易踩坑的盲区:到底哪个指标代表了容器真正的内存使用量?

很多人会习惯性地去看 container_memory_rss。但是,K8s 判断是否 OOM 的指标是 Working Set Memory(工作集内存)

在 Prometheus 中,对应的指标是:

container_memory_working_set_bytes{container!=""}
  • RSS(Resident Set Size):进程实际占用的物理内存(不包括 Page Cache)。
  • Working Set:工作集内存。它的计算方式大致是:极度活跃的内存 + RSS + 缓存(Active Anon + Active File + Inactive Anon)

Linux 内核在内存紧张时会尝试释放 Inactive File(不活跃的缓存文件)。但那些无法被释放的活跃 Cache(比如正在高频读写的日志文件、mmap 映射的文件),都会算在 Working Set 里面。

结论:如果你的服务高频读写大文件,即使你的代码里没有内存泄露,container_memory_working_set_bytes 也会一路飙升,直到撞上 Limit 导致 OOMKilled。


三、 精准排查四步法:如何定位“真凶”?

当 Pod 发生 OOMKilled 时,我们如何快速定位到具体的代码或配置问题?跟着这四步走:

第一步:确认 OOM 事件与时间点

首先,抓取异常 Pod 的详细状态:

kubectl describe pod <pod-name> -n <namespace>

在输出的 Last State 中,确认:

  • Reason: OOMKilled
  • Exit Code: 137
  • Finished: [精确到秒的时间点]

第二步:追查宿主机内核日志

有时候容器死得太快,应用日志根本来不及写盘。这时候需要去宿主机(或者通过 DaemonSet 收集的系统日志)里找内核的哭诉:

# 登录 Pod 所在的 Node 执行
dmesg -T | grep -i -E 'oom|kill'
# 或者查看系统日志
journalctl -k | grep -i -E 'oom|kill'

你会看到类似这样的系统日志:

[Thu Oct 24 14:23:10 2024] Memory cgroup out of memory: Killed process 31204 (java) total-vm:4324200kB, anon-rss:2097152kB, file-rss:0kB, shmem-rss:0kB

这里能清晰地看到是被哪个 Cgroup 限制杀掉的,以及当时进程的虚拟内存(total-vm)和物理内存(anon-rss)状态。

第三步:检查是否由于“内存分配不合理”导致

不同语言的 Runtime 在容器里的内存表现完全不同,最典型的是 Java (JVM)Go

1. JVM 容器的“内存越界”

如果你在 K8s 限制了 limits.memory: 2Gi,但在 JVM 启动参数里写了 -Xmx2g,那这个 Pod 必死无疑
因为 -Xmx 仅仅限制了 JVM 堆内存的大小。除了堆,JVM 还要消耗:

  • 元空间(Metaspace):通常 200MB+
  • 线程栈(Thread Stacks):每个线程默认 1MB,上百个线程就是 100MB+
  • 堆外内存(Direct Memory):网卡读写、Netty 等高频使用
  • JVM 自身运行代码及 GC 消耗

正确姿势
对于 Java 8u191+ 及 Java 11+,引入了容器感知,建议使用百分比配置:

java -XX:+UseContainerSupport -XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=75.0 -jar app.jar

预留 25%~30% 的非堆内存给容器其他开销。

2. Go 语言的“垃圾回收延迟”

Go 的垃圾回收(GC)是基于阈值的(默认 GOGC=100,即内存翻倍时触发 GC)。在容器中,Go 运行时默认无法感知 Cgroup 的内存限制,它会认为自己可以使用宿主机的所有内存。
当 Go 试图向系统申请更多内存,而此时还没达到 GC 触发阈值,却已经超出了 Cgroup Limit 时,就会被 K8s 一枪爆头。

正确姿势
自 Go 1.19 起,引入了 GOMEMLIMIT 环境变量。

env:
- name: GOMEMLIMIT
  value: "1800MiB" # 设为 Limit 的 90% 左右

设置这个值后,Go 运行时会在内存达到该阈值时,自动触发强制 GC,避免向系统申请过多内存而导致 OOM。

第四步:代码级内存泄漏排查(APM 与 Profiling)

如果配置没问题,内存依然呈现一条完美的 45 度上升斜线,那就是代码内存泄露了。

  • Java 诊断
    利用 jmap 导出 Heap Dump。由于容器可能随时挂掉,可以在 JVM 启动参数中加上:
    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/oom.hprof
    
    (注意:/dumps 需要挂载 emptyDir 或者是持久化卷,否则 Pod 重启后文件就丢了)。
  • Go 诊断
    在代码中集成 net/http/pprof。通过本地端口转发抓取 heap profile:
    kubectl port-forward <pod-name> 6060:6060
    curl -s http://localhost:6060/debug/pprof/heap > heap.out
    go tool pprof -http=:8080 heap.out
    
    通过火焰图(Flame Graph)可以直接肉眼定位到是哪个函数在疯狂申请内存且没有释放。

四、 避坑总结:如何防止 OOMKilled 再次发生?

  1. 拒绝“裸奔”:绝对不要部署任何没有设置 resources.requests.memoryresources.limits.memory 的生产环境 Pod。否则一有风吹草动,它们最先被牺牲。
  2. 合理设置 Request 与 Limit 的比值:建议内存的 Request 和 Limit 设为一致(即 QoS Class 为 Guaranteed)。因为内存是不可压缩资源,一旦超售(Overcommit)过大,节点压力高时会导致大面积 Pod 被杀。
  3. 监控预警:不要等挂了才发现。在 Prometheus 中配置告警规则:
    # 容器内存使用率超过 Limit 的 85% 时告警
    sum(container_memory_working_set_bytes) by (pod, container) 
    / 
    sum(container_spec_memory_limit_bytes) by (pod, container) > 0.85
    
  4. 注意宿主机本地缓存:使用 emptyDir 且未限制大小时,写入文件会占用宿主机的内存(如果是 medium: Memory 模式)或者占用系统缓存,同样会推高 Working Set 导致 OOM。

排查 OOM 是一门细致活,从指标定义、系统内核到应用运行机制,每一个层级都隐藏着细节。下次遇到 137 退出码,别再盲目推高 Limit 了,抓一份 Profile 读一下系统日志,才是治本之道。

SRE铁哥 KubernetesOOMKilled容器排查

评论点评