Pod 频繁异常重启?死磕 K8s OOMKilled(Exit Code 137)底层机制与排查终极指南
大半夜被告警电话叫醒,登上系统一看,某个核心微服务的 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):根据内存请求占节点的比例计算,分值一般在
2到999之间。 - 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: OOMKilledExit Code: 137Finished: [精确到秒的时间点]
第二步:追查宿主机内核日志
有时候容器死得太快,应用日志根本来不及写盘。这时候需要去宿主机(或者通过 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:
通过火焰图(Flame Graph)可以直接肉眼定位到是哪个函数在疯狂申请内存且没有释放。kubectl port-forward <pod-name> 6060:6060 curl -s http://localhost:6060/debug/pprof/heap > heap.out go tool pprof -http=:8080 heap.out
四、 避坑总结:如何防止 OOMKilled 再次发生?
- 拒绝“裸奔”:绝对不要部署任何没有设置
resources.requests.memory和resources.limits.memory的生产环境 Pod。否则一有风吹草动,它们最先被牺牲。 - 合理设置 Request 与 Limit 的比值:建议内存的 Request 和 Limit 设为一致(即 QoS Class 为 Guaranteed)。因为内存是不可压缩资源,一旦超售(Overcommit)过大,节点压力高时会导致大面积 Pod 被杀。
- 监控预警:不要等挂了才发现。在 Prometheus 中配置告警规则:
# 容器内存使用率超过 Limit 的 85% 时告警 sum(container_memory_working_set_bytes) by (pod, container) / sum(container_spec_memory_limit_bytes) by (pod, container) > 0.85 - 注意宿主机本地缓存:使用
emptyDir且未限制大小时,写入文件会占用宿主机的内存(如果是medium: Memory模式)或者占用系统缓存,同样会推高 Working Set 导致 OOM。
排查 OOM 是一门细致活,从指标定义、系统内核到应用运行机制,每一个层级都隐藏着细节。下次遇到 137 退出码,别再盲目推高 Limit 了,抓一份 Profile 读一下系统日志,才是治本之道。