我们把一个 Java 服务容器化上了 K8s,给它的 Pod 配了 2GB 内存上限,本以为足够宽裕。可上线后,这个容器三天两头地重启,查 Pod 状态,原因写得明明白白:OOMKilled,退出码 137。最让我费解的是,这台宿主机有 64GB 内存,空闲得很,我的应用按理也用不了多少——怎么会内存不够、被杀掉?进容器里用 top、free 看,显示的全是宿主机那 64GB 的内存,看起来富得流油,可容器就是被一次次以"内存超限"为由处决。
排查了好一阵,真相才浮出水面,而且经典得让人哭笑不得:我那个版本的 JVM,根本不知道自己跑在一个被限制了内存的容器里。它启动时,为了决定堆内存(Heap)该开多大,会去看"机器有多少内存",然后默认拿其中的四分之一作为最大堆。可它看到的"机器内存",是宿主机的 64GB——于是它兴高采烈地把最大堆设成了 16GB!而容器的内存上限只有 2GB。当 JVM 真的试图把堆涨到远超 2GB 时,容器的 cgroup 一看超了限额,二话不说就把整个进程杀掉了,这就是那个 OOMKilled 和退出码 137 的由来。
这就是容器化 Java 应用最经典的一个坑:JVM 与容器内存限制的"认知错位"——JVM 以为自己拥有整台宿主机的内存,容器却只给了它一小块,两者的预期一旦对不上,OOMKilled 就成了家常便饭。这篇文章,就从这次"宿主机内存富裕、容器却被 OOM 杀"的事故出发,把容器内存限制、JVM 的资源感知、以及退出码 137 背后的机制,一次讲透。
先摆几个关于容器内存的想当然
动手复盘前,先把我自己曾经深信、后来被 OOMKilled 教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "宿主机内存够,容器就不会内存不足" | 容器有自己的内存上限, 超了照样被 cgroup 杀掉 |
| "JVM 会自动适配容器分到的内存" | 老版本 JVM 看的是宿主机内存, 堆开得远超容器上限 |
| "容器里 free/top 看到的就是我能用的" | 老内核里它们显示的是宿主机内存, 极具误导性 |
| "堆设成容器上限那么大就稳了" | JVM 还有堆外内存, 堆顶满容器上限必然被杀 |
| "OOMKilled 就是我代码内存泄漏" | 也可能纯粹是堆配置 > 容器上限, 和泄漏无关 |
这些念头的共同病根,是没意识到容器给应用营造的是一个"资源受限的隔离环境",而老版本的 JVM 对这个环境是"无知"的——它仍然用面向物理机的老眼光去看世界。要看清这次事故,得先理解容器的内存限制到底是怎么回事,以及那个 137 是什么意思。
第一件事:容器内存限制与退出码 137 的真相
容器的资源隔离,底层靠的是 Linux 的 cgroup(control group)机制。当你给容器设一个内存上限(比如 K8s 里的 limits.memory: 2Gi),本质就是把这个容器的进程放进一个 cgroup,并告诉内核"这一组进程,内存最多用 2GB"。一旦这组进程的实际内存占用触碰到这个上限,内核的 OOM Killer 就会被触发,把容器里的主进程强制杀死——这就是 OOMKilled。
那个退出码 137 也大有来头,它不是随机数:在 Unix 约定里,被信号杀死的进程,退出码是 128 + 信号编号。OOM Killer 杀进程用的是 SIGKILL(信号编号 9),128 + 9 = 137——所以看到退出码 137,几乎可以条件反射地想到"被 SIGKILL 强杀了,而最常见的原因就是内存超限"。下面这张图,把这次事故的因果链画出来:
看懂这张图,事故的根就清楚了:不是宿主机内存不够,而是 JVM 拿着"宿主机 64GB"这个错误前提,给自己规划了一个 2GB 容器根本装不下的堆。容器给应用的是一个被 cgroup 限定了边界的小世界,而老 JVM 却拿着面向整台物理机的旧地图在里面横冲直撞,撞破边界就被 SIGKILL 处决。接下来,我们就看怎么让 JVM 用上正确的"地图"。
第二件事:让 JVM 感知容器——UseContainerSupport
根治这个问题的核心,是让 JVM看容器的内存上限,而不是宿主机的。好消息是,JVM 早就支持这件事了:从 JDK 8u191 和 JDK 10 起,引入了容器感知能力,由参数 -XX:+UseContainerSupport 控制,而且它默认就是开启的。开启后,JVM 会去读 cgroup 的内存限制,把那个值当作"机器内存",据此计算堆大小——于是它终于能看到容器真正分给它的 2GB,而不是宿主机的 64GB。
# 第一要务:用足够新的 JDK(8u191+ / 11+ / 17+), 它默认开启容器感知
# UseContainerSupport 默认 on, 一般无需显式写, 但可确认它在
java -XX:+PrintFlagsFinal -version | grep UseContainerSupport
# 输出 UseContainerSupport = true 即表示 JVM 会读容器的内存上限
# 验证:在 2GB 限制的容器里, 看 JVM 认定的最大堆是多少
java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
# 新 JDK 下, 这个值会基于容器的 2GB 而非宿主机来算
所以这个坑最直接的解法,往往就一句话:升级你的 JDK。我那次事故,根因就是用了一个太老的 JDK 8 小版本(8u191 之前),它压根没有容器感知能力。换成新版本后,JVM 立刻"看懂"了自己身处 2GB 的容器,堆也不再失控。容器化时代,JDK 版本本身就是一项基础设施配置,别让一个过时的小版本,在底层埋下这种隐患。
第三件事:用百分比控制堆,别再写死 -Xmx
JVM 能感知容器后,下一步是合理地设定堆大小。这里有个观念要更新:在容器里,别再用写死的 -Xmx2g 这种绝对值,改用按比例分配的 -XX:MaxRAMPercentage。因为容器的内存上限可能随部署调整(今天 2G、明天调到 4G),写死的 -Xmx 不会跟着变,而百分比能自动按容器实际上限的比例来算堆,更灵活、更不易出错。
# 反例:写死绝对值, 容器内存上限一调整, 这个值就对不上了
java -Xmx2g -jar app.jar
# 若容器只给 2G, 堆又设 2G, 堆外内存一占用, 立刻超限被杀
# 正解:用百分比, 让堆随容器上限按比例伸缩
java -XX:MaxRAMPercentage=70.0 -jar app.jar
# 容器 2G → 堆约 1.4G; 容器调到 4G → 堆自动变约 2.8G, 无需改参数
# 留 30% 给堆外内存(线程栈/元空间/直接内存等), 关键!
这里最关键、也最容易被忽略的一点是:堆(Heap)不是 JVM 内存的全部,你绝不能把堆设成容器上限那么大。JVM 除了堆,还要用不少堆外内存:线程栈、元空间(Metaspace)、JIT 编译的代码缓存、NIO 的直接内存、GC 自己的开销……这些加起来可能占几百兆。如果你把 -Xmx 或 MaxRAMPercentage 设满容器上限,那么"堆 + 堆外"必然超过上限,照样被 OOMKilled。所以经验法则是:给堆留出余量,通常设容器上限的 50%~75%,把剩下的让给堆外内存。具体比例要结合应用的线程数、直接内存使用量来调。
第四件事:堆外内存才是"隐形大户",别只盯着堆
很多人调容器内存,眼里只有堆,结果把堆压得很小却还是被 OOMKilled——因为堆外内存(off-heap)被忽略了。容器的内存上限,管的是这个进程占用的全部物理内存,而 JVM 进程的内存 = 堆 + 一大堆堆外内存。把这本账算清楚,才能配对参数。
# JVM 进程总内存 ≈ 堆 + 以下这些堆外部分:
# 1. Metaspace 元空间:存类的元数据, 类多就大
java -XX:MaxMetaspaceSize=256m ...
# 2. 线程栈:每个线程约 1MB, 线程多了很可观(500 线程 ≈ 500MB!)
java -Xss512k ... # 单线程栈大小
# 3. 直接内存(Direct Buffer):NIO/Netty 等大量使用
java -XX:MaxDirectMemorySize=256m ...
# 4. 还有 JIT 代码缓存、GC 结构、本地库等
# 用 NativeMemoryTracking 看 JVM 各部分到底吃了多少内存
java -XX:NativeMemoryTracking=summary ...
# 运行中执行: jcmd <pid> VM.native_memory summary
这本账里最容易爆雷的是线程栈:每个线程默认占约 1MB 栈空间,如果你的应用线程池配得很大、或者有线程泄漏,几百上千个线程就是几百兆到上 G 的内存,全在堆外、全算进容器上限。我见过不少"堆明明没满却 OOMKilled"的案例,真凶就是失控的线程数。所以排查容器 OOM,堆只是嫌疑人之一,元空间、线程栈、直接内存这些堆外大户,同样要逐个查清。用 NativeMemoryTracking 把这本账拉出来,真凶往往就藏在你没留意的那一项里。
第五件事:CPU 也会被容器限制,顺带说说 request/limit
和内存同理,CPU 也会被容器限制,老 JVM 同样会误判。JVM 很多默认值(GC 线程数、并行流的线程池、各种内部线程池大小)都依据"可用 CPU 核数"来定,而老版本会看到宿主机的全部核数(比如 64 核),于是开出一大堆 GC 线程、塞满线程池——可容器实际只分给它 1 核,结果是大量线程在 1 个核上疯狂争抢、上下文切换,性能不升反降。容器感知能力同样会修正这一点,让 JVM 看到容器分配的 CPU。
# K8s 里 requests 和 limits 的区别, 直接影响调度与被杀
resources:
requests: # 调度依据:保证至少有这么多(用于排 Pod 到哪个节点)
memory: "1Gi"
cpu: "500m"
limits: # 硬上限:超内存上限 → OOMKilled; 超 CPU → 被限流(throttle)
memory: "2Gi"
cpu: "2"
# 关键区别:内存超 limit 是"杀"(OOMKilled), CPU 超 limit 是"限速"(throttle)
# 内存 limit 要给足余量, 因为它一超就是致命的进程被杀
这里有个重要区别要记牢:内存超过 limit 是"致命"的——直接 OOMKilled 杀进程;而 CPU 超过 limit 是"温和"的——只是被限流变慢(throttle),不会杀进程。正因为内存超限的后果是进程猝死,所以内存 limit 的设置要格外留余量,JVM 的堆+堆外总和必须稳稳低于它。理解了 request(调度保证)和 limit(硬上限)的分工,以及内存和 CPU 在"超限处置"上的本质差异,你才能给容器配出既不浪费、又不会动辄被杀的资源参数。
第六件事:OOMKilled 到底是谁的锅——容器杀还是 JVM 自己 OOM
排查时要分清一个极易混淆的点:容器内存相关的"死法"其实有两种,得对症下药。一种是容器 OOMKilled(本文主角):进程总内存触到 cgroup 上限,被内核 SIGKILL,退出码 137,Pod 状态写 OOMKilled——这通常是"配置问题"(堆+堆外 > 容器上限)。另一种是 JVM 自己抛 OutOfMemoryError:堆满了、JVM 自己撑不住抛异常,有 Java 异常栈——这通常是"代码问题"(真的内存泄漏或堆设太小)。
# 区分两种死法:
# A. 容器 OOMKilled —— 看 Pod 状态和退出码
kubectl describe pod <pod> | grep -A3 "Last State"
# Reason: OOMKilled, Exit Code: 137 → cgroup 杀的, 多半是堆+堆外超容器上限
# B. JVM 自己 OOM —— 看应用日志有没有 Java 异常栈
# java.lang.OutOfMemoryError: Java heap space → 堆内泄漏或堆设太小
# 让 JVM OOM 时自动 dump, 便于事后分析
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump ...
这两者方向不同:OOMKilled 优先查"参数配置"(JDK 版本、堆百分比、堆外预留、容器上限是否合理);OutOfMemoryError 优先查"代码"(用堆 dump 找泄漏的对象)。当然两者也会交织——比如堆外内存(线程、直接内存)泄漏,会表现为 OOMKilled 而非 OutOfMemoryError,因为堆外的问题 JVM 自己未必察觉。分清"是容器把我杀了"还是"是 JVM 自己崩了",是高效排查容器内存问题的第一步。到这儿,这次事故的方方面面就齐了。我把排查思路收成一张决策图:
把这套理解建立起来,容器化 Java 应用的 OOM 问题就能被系统地定位和根治。最后,拧成几条可直接照做的铁律:
- 用足够新的 JDK(8u191+ / 11+),让 JVM 默认感知容器的内存与 CPU 上限。
- 容器里别写死
-Xmx,用MaxRAMPercentage,让堆随容器上限按比例伸缩。 - 堆绝不能设满容器上限,留 25%~50% 给堆外内存(线程栈、元空间、直接内存)。
- 堆外内存是隐形大户,尤其警惕线程数失控, 用 NativeMemoryTracking 把账查清。
- 分清 OOMKilled(137)与 OutOfMemoryError,前者多为配置问题, 后者多为代码泄漏。
- 内存 limit 要留足余量,因为内存超限是直接杀进程, 而非像 CPU 那样只限速。
- 把 JDK 版本和 JVM 参数当标准镜像配置,别靠每个应用临时拍脑袋调。
一张容器 OOM 排查速查表
把常见的成因、信号和对策汇成一张表,容器频繁重启时对照着查。
| 成因 | 典型信号 | 对策 |
|---|---|---|
| JDK 太老不感知容器 | 堆按宿主机内存算, 远超容器上限 | 升级到 8u191+ / 11+ |
| 堆设满容器上限 | 堆+堆外超限, 137 OOMKilled | MaxRAMPercentage 留余量 |
| 线程数失控 | 堆不大却 OOMKilled, 线程上千 | 限制线程池, 查线程泄漏 |
| 直接内存/Netty 泄漏 | 堆外持续增长被杀 | 设 MaxDirectMemorySize, 查泄漏 |
| 元空间膨胀 | 动态类多(如大量代理) | 设 MaxMetaspaceSize |
| 真实堆内泄漏 | 日志有 OutOfMemoryError + 栈 | 堆 dump 找泄漏对象 |
| 内存 limit 设太小 | 正常负载也频繁被杀 | 压测定容量, 调大 limit |
治本之外:用监控看清内存的真实曲线
把参数调对之后,我做的最有价值的一件事,是给容器内存接上监控。因为光靠"被杀了再去查",永远是被动的;而一条连续的内存使用曲线,能让你在被杀之前就看出端倪。我重点盯三条线:容器实际内存占用、JVM 堆使用量、以及两者之间的差值(约等于堆外内存)。
这三条线放在一起,能精准地告诉你问题出在哪。如果容器内存稳步上涨、最终撞上限被杀,而堆却平稳——那几乎可以断定是堆外内存(很可能是线程或直接内存)在泄漏。如果堆本身就在锯齿状上涨、GC 后也降不下来——那是堆内泄漏。如果内存平时很稳、只在某种特定请求下飙升——那是某个操作的内存使用没控制好。有了曲线,"OOMKilled"就从一个突兀的死亡事件,变成了一个有前因后果、可提前预警的可观测过程。
# 给容器设健康探针 + 监控, 让内存问题在被杀前就被发现
# Prometheus 重点采集这几个指标:
# container_memory_working_set_bytes 容器实际内存(cgroup 视角, 它决定生死)
# jvm_memory_used_bytes{area="heap"} JVM 堆使用量
# jvm_memory_used_bytes{area="nonheap"} 堆外
# jvm_threads_current 线程数(揪线程泄漏)
# 对 working_set 接近 limit 设告警, 在 OOMKilled 之前就介入
这里要特别点名 container_memory_working_set_bytes 这个指标——它才是 cgroup 真正用来判断"要不要杀你"的那个内存值,盯它准没错。把它和 JVM 的堆、堆外、线程数一起监控起来,再对"接近上限"设好告警,你就拥有了一套完整的"提前预警 + 事后定位"能力,而不必再等容器猝死、再从一堆退出码里倒推。
一个连带的坑:健康探针可能"误杀"内存紧张的容器
修好内存配置后,我还遇到一个连带的、容易和 OOM 混淆的现象:容器并非被 OOMKilled,而是被 K8s 的存活探针(liveness probe)判定为"不健康"而重启。当内存吃紧、GC 频繁且耗时拉长(Full GC 时应用会出现停顿)时,应用可能在那几秒里无法及时响应探针的健康检查请求,K8s 一看"探针超时、连续失败",就认为容器卡死了,把它重启——表面看也是"频繁重启",但 Pod 状态写的不是 OOMKilled,而是探针失败。
# 存活探针配置不当, 会在 GC 停顿/内存紧张时误杀容器
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 60 # 给足启动时间, 别让慢启动被误判为故障
timeoutSeconds: 3 # 探针超时别设太短, GC 停顿时容易超
failureThreshold: 3 # 连续失败几次才重启, 别一次失败就杀
periodSeconds: 10
# 关键:健康检查接口要"轻", 别在里面查 DB/调下游, 否则下游慢就误判
这个坑的启示是:容器频繁重启,未必都是 OOMKilled,要看清 Pod 状态到底写的是什么。如果是探针失败,排查方向就转向"为什么应用没及时响应探针"——可能是 GC 停顿(又绕回内存问题)、可能是健康接口本身太重(里面查了慢 DB)、也可能是探针参数太激进(超时太短、阈值太小)。健康探针是把双刃剑:配好了能自动剔除真故障,配激进了反而会把只是'暂时忙'的健康容器误杀。
这也提醒我们,容器化环境里的"重启",背后可能有好几种完全不同的原因——OOMKilled、探针失败、节点驱逐、主动滚动更新……每一种的排查方向都不一样。养成先看清楚"它到底是怎么死的"(退出码、Pod 事件、状态原因),再决定往哪个方向查的习惯,能让你在面对"容器又重启了"这句笼统的抱怨时,迅速地分门别类、对症下药,而不是一上来就埋头猜。准确的归因,永远是高效排查的前提。
写在最后
这次"宿主机内存富裕、容器却被 OOM 杀"的事故,给我最深的启示,是它暴露了一个时代变迁中的认知断层。JVM 这套基于"独占一台物理机"假设建立起来的资源管理逻辑——看机器有多少内存、有多少 CPU,然后据此规划自己——在物理机和虚拟机时代运转良好。可当应用被装进容器、被 cgroup 划出一小块隔离的天地时,这套老逻辑的前提就崩塌了:它眼中的"整台机器",其实早已不属于它一个人。技术的演进,常常不是某个组件坏了,而是它赖以成立的某个'隐含前提',在新的环境里悄悄失效了。
所以容器化绝不只是"把应用塞进一个镜像、能跑起来"那么简单,它要求我们重新审视应用与它所处环境之间的契约:它以为自己有多少资源?环境实际给了它多少?两者对得上吗?这次事故让我养成了一个习惯——每把一个应用搬进容器,都要专门问一句:它知道自己被关进笼子了吗?它会不会还拿着外面那个广阔世界的旧地图,在笼子里横冲直撞?无论是 JVM 的容器感知、还是上一篇的 DNS 缓存,本质上都是同一类问题:运行环境变了,而应用对环境的认知没跟上。理解了这层,你就能在每一次"上容器""上云"时,主动去校准应用与新环境的那份契约,而不必等它撞得头破血流、被一个个退出码叫醒。愿你我都能让自己的每一个应用,都清醒地知道自己身在何处、手里有多少资源,从容地活在它该活的那片天地里。
如果你也有跑在容器里的 Java 服务,不妨今天就花二十分钟做三件小事自查。第一,确认基础镜像里的 JDK 版本是不是 8u191 以上(理想是 11/17),太老的直接升级,这一步能解决一大半问题。第二,检查启动参数,把写死的 -Xmx 换成 -XX:MaxRAMPercentage,并确认它给堆外留了足够余量(别超过 75%)。第三,进容器跑一句 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize,亲眼确认 JVM 认定的最大堆,是基于容器上限而不是宿主机算出来的。这三步走下来,你就能在下一次流量高峰把容器拖垮之前,把这个潜伏的隐患拆除掉。
容器化给我们带来了部署的标准化、环境的一致性、弹性的伸缩,这些好处实实在在。但它也带来了一层新的契约——应用必须学会在一个被精确限定了资源的小世界里"知足而活"。这次 OOMKilled 的经历让我明白,搬上容器从来不是终点,而是一系列"重新对齐认知"工作的起点:内存对齐了吗?CPU 对齐了吗?健康检查、优雅停机、日志输出,样样都得在新环境里重新校准。真正的云原生,不是把老应用塞进一个新壳子,而是让应用从里到外都理解并尊重它所处的那个受限环境。愿你我在拥抱容器便利的同时,也带着这份对"环境契约"的敬畏,把每一个应用都安顿得既本分、又稳当。
—— 别看了 · 2026