华为云CCE-Java容器OOMKilled排查

举报
yd_221506059 发表于 2026/10/23 20:03:04 2026/10/23
【摘要】 华为云 CCE 上 Java 容器反复 OOMKilled:堆只用了 60%,内存却被判定超限Pod 每天重启几次,kubectl describe 显示 OOMKilled、退出码 137。但 JVM 监控里堆内存常年只在 60% 上下,GC 也正常。这篇文章把"容器内存"和"JVM 堆"这两笔账彻底分开算,解释为什么 2 GB 的容器里配 1.5 GB 堆一定会被杀,以及一个连老手都容...

华为云 CCE 上 Java 容器反复 OOMKilled:堆只用了 60%,内存却被判定超限

Pod 每天重启几次,kubectl describe 显示 OOMKilled、退出码 137。但 JVM 监控里堆内存常年只在 60% 上下,GC 也正常。这篇文章把"容器内存"和"JVM 堆"这两笔账彻底分开算,解释为什么 2 GB 的容器里配 1.5 GB 堆一定会被杀,以及一个连老手都容易忽略的坑:容器里 availableProcessors() 返回的是宿主机核数。

一、现象:重启,但没有 OOM 异常

排查起点是一个很矛盾的组合:

观察项 现象
Pod 状态 每天重启 2~5 次,RESTARTS 持续增长
终止原因 Last State: Terminated, Reason: OOMKilled, Exit Code: 137
JVM 堆监控 峰值 60%~65%,远未到上限
应用日志 没有任何 OutOfMemoryError
GC 日志 正常,Full GC 后能回收
容器内存限制 2 GiB,JVM 参数 -Xmx1536m

两个关键判断:

  1. 没有 OutOfMemoryError,说明不是堆溢出。 堆溢出是 JVM 自己抛异常,进程可能还活着;而 137 是被外部杀掉,JVM 根本来不及写日志。
  2. OOMKilled 是内核 cgroup 判定的,不是 JVM 判定的。判据是整个容器的内存用量超过了 cgroup 限制,而不是堆用量。

所以问题的本质是:除了堆,容器里还有别的东西在吃内存,加起来超过了 2 GiB。

二、先分清两种 OOM

这两个概念混在一起排查,方向会完全错掉:

类型 谁判定 表现 排查方向
JVM 堆 OOM JVM 抛 java.lang.OutOfMemoryError: Java heap space 堆大小、内存泄漏、对象膨胀
容器 OOMKilled 内核 cgroup 进程被 SIGKILL,退出码 137,无 JVM 日志 堆 + 非堆 + 堆外的总量

看到 OutOfMemoryError 去查堆;看到 137 且无异常日志,去查容器总内存。

三、JVM 眼里的内存:容器支持的历史包袱

JVM 早期版本读的是 /proc/meminfo,那是宿主机的内存,不是容器的限制。这会导致一个经典事故:

宿主机 64 GB,容器限制 2 GB
老版本 JVM 看到 64 GB → 默认堆取 1/4 = 16 GB
容器一启动就申请 16 GB → 立即 OOMKilled,甚至起不来

现在主流 JDK 都默认开启容器感知(-XX:+UseContainerSupport 默认开启),并且:

  • -XX:MaxRAMPercentage 默认 25%
  • -XX:InitialRAMPercentage 默认 1.56%

25% 这个默认值是很多团队困惑的来源:2 GiB 的容器,默认堆上限只有 512 MB。有些团队发现堆不够用,就直接把 -Xmx 拉到 1536m 甚至 1800m,结果反而更容易被 OOMKilled——因为剩下的空间装不下非堆部分。

验证 JVM 到底看到了多少内存,这一条必须实际跑一次,别猜:

# 在容器内执行,看 MaxHeapSize 是否与预期一致
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -Ei 'MaxHeapSize|MaxRAMPercentage'

四、容器内存的完整账本

把 2 GiB 拆开看,堆只是其中一块:

组成 典型占用 说明
Java 堆 由 -Xmx 决定 业务对象
Metaspace 100~300 MB 默认无上限,类越多越大
Code Cache 50~240 MB JIT 编译产物
线程栈 线程数 × 1 MB -Xss 默认约 1 MB
Direct Buffer 可达堆大小 NIO/Netty 直接内存,默认上限等于 -Xmx
GC 自身结构 堆的 5%~15% 卡表、标记位图等
JVM 自身 + 本地库 50~150 MB 解释器、符号表、JNI
glibc malloc arena 数十 MB 多线程下可能膨胀

结论:堆最多只能占到容器限制的 60%~75%。 2 GiB 容器配 1536m 堆(75%)已经是上限边缘,再叠上一个 300 MB 的 Metaspace 和 Netty 的直接内存,越界是必然的。

五、三个最常见的真凶

真凶 1:Metaspace 默认无上限

-XX:MaxMetaspaceSize 默认是不限制(受限于本地内存)。这在容器里非常危险:类加载持续增长时,Metaspace 会一直吃内存,直到容器被判定超限。

什么场景会持续增长?动态代理(CGLIB)、脚本引擎反复生成类、热部署、大量反射。Spring 项目 + 动态代理 + 频繁发版,Metaspace 涨到几百 MB 很常见。

真凶 2:Direct Buffer 悄悄占满堆外

NIO 的直接内存(ByteBuffer.allocateDirect)在堆外,JVM 堆监控完全看不到。而它的默认上限是 -XX:MaxDirectMemorySize,不显式指定时约等于 -Xmx。

微服务里用 Netty、gRPC、或者自己做文件/网络传输,直接内存很容易吃掉几百 MB 甚至上 GB。堆看起来很闲,容器却快满了,这是最典型的一种。

真凶 3:容器里 availableProcessors() 返回宿主机核数

这个坑非常隐蔽,而且会连锁引爆内存:

宿主机 64 核,容器 CPU limit = 2 核
旧版 JVM:availableProcessors() = 64
线程池按 64 核配置 → 核心线程数几百个
每个线程栈约 1 MB → 光线程栈就吃掉几百 MB

更糟的是,JVM 的 GC 线程数、ForkJoinPool 并行度也都按这个数字算,进一步放大内存和 CPU 开销。

验证方法:

// 打印 JVM 认为的核数
System.out.println(Runtime.getRuntime().availableProcessors());

如果这个数字远大于容器的 CPU limit,就要显式指定:

-XX:ActiveProcessorCount=2

六、排查:从 cgroup 读到 JVM 内部

1. 确认是不是 cgroup 杀的

# 容器内查看 OOM 计数,非 0 说明被 cgroup 杀过
cat /sys/fs/cgroup/memory.events        # cgroup v2
# oom 3
# oom_kill 3        ← 就是它

cat /sys/fs/cgroup/memory/memory.failcnt  # cgroup v1

同时看当前用量与上限:

# cgroup v2
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max

# cgroup v1
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/memory.limit_in_bytes

注意:memory.current 包含页缓存(page cache)。容器大量读文件时,这个数字会接近上限,但页缓存是可回收的,不一定导致 OOM。所以判断时要看 memory.stat 里的 anon(匿名页,真正不可回收的部分):

grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat

anon 逼近 memory.max,才是真正的危险信号。

2. 打开 NMT,看 JVM 内部的内存分布

这是定位"堆外到底吃了多少"的最直接手段:

-XX:NativeMemoryTracking=summary
jcmd 1 VM.native_memory summary

输出会按 Java Heap / Class / Thread / Code / GC / Internal / Other 分类列出。哪一项超预期,问题就在哪。 生产环境建议常开 summary 级别(开销约 5%),detail 级别开销较大,只在排查时临时开。

3. 用 jcmd 快速看各区域

jcmd 1 GC.heap_info        # 堆使用情况
jcmd 1 VM.metaspace        # Metaspace 明细
jcmd 1 Thread.print | grep -c '"'   # 线程总数

线程数乘以 1 MB 就是线程栈的粗略占用。如果线程数上千,光这一项就是 1 GB。

七、修复:把账算平

1. 堆按 60%~70% 配,而不是 75%+

2 GiB 容器,推荐 1200m~1300m 堆,留 700~800 MB 给非堆。

-XX:MaxRAMPercentage=60
-XX:InitialRAMPercentage=60

用百分比而不是 -Xmx 绝对值,好处是改容器规格时堆自动跟着变,不用同时改两处。

2. 显式限制每一块非堆内存

# Metaspace 上限,按实际用量留 30% 余量
-XX:MaxMetaspaceSize=256m

# 直接内存上限,按实际使用设,不要留默认(等于堆大小)
-XX:MaxDirectMemorySize=256m

# 线程栈,按业务并发下调
-Xss512k

# 容器内核数对齐 CPU limit
-XX:ActiveProcessorCount=2

3. 用 G1 并显式设好 GC 相关

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

HeapDumpOnOutOfMemoryError 在容器场景下尤其重要:如果哪天真的堆溢出,能在 Pod 被杀前留下现场。注意 dump 目录要挂到持久卷,否则 Pod 重启后 dump 就没了。

4. 资源限制要给得合理

resources:
  requests:
    memory: 2Gi
    cpu: "2"
  limits:
    memory: 2Gi
    cpu: "2"

两个要点:

  • requests 与 limits 的 memory 设成相同值。如果 requests 远小于 limits,调度器会把内存需求小的 Pod 堆到同一节点,节点实际内存吃紧,触发节点级驱逐,问题更复杂。
  • CPU 的 limits 不要设成 requests 的很多倍,容器内 JVM 按 CPU limit 算 GC/线程数,但实际 CPU 被节流(throttling)时会表现为"内存正常但响应极慢"。

八、效果

指标 修复前 修复后
Pod 重启次数 2~5 次/天 0
oom_kill 计数 持续增长 不再增长
堆峰值 60%~65%(但容器超限) 55%~60%
Metaspace 无上限,峰值 480 MB 上限 256 MB,实际 190 MB
线程数 约 1800(按 64 核配置) 约 220
anon 内存 贴近 2 GiB 约 1.3 GiB

改动全部在启动参数和资源声明里,业务代码零改动。

九、小结

四句话:

  1. OOMKilled + 退出码 137 + 无 OutOfMemoryError,说明是容器超限,不是堆溢出,别再去查堆泄漏。
  2. 堆最多只能占容器内存的 60%~70%,剩下的要留给 Metaspace、直接内存、线程栈和 GC 结构。
  3. availableProcessors() 在容器里可能返回宿主机核数,会连锁放大线程数、线程栈和 GC 线程,必须用 -XX:ActiveProcessorCount 对齐。
  4. 看 cgroup 的 anon 而不是 memory.current,后者包含可回收的页缓存,会误导判断。

如果只记一句:容器里算内存,永远是"堆 + 非堆 + 堆外"三笔账一起算,只盯堆一定出事。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。