首页 运维干货K8s Pod OOM 排查:从 limits 设置到 JVM 调优

K8s Pod OOM 排查:从 limits 设置到 JVM 调优

运维派隶属马哥教育旗下专业运维社区,是国内成立最早的IT运维技术社区,欢迎关注公众号:yunweipai
领取学习更多免费Linux云计算、Python、Docker、K8s教程关注公众号:马哥linux运维

K8s Pod OOM 排查:从 limits 设置到 JVM 调优

一、问题背景

在 Kubernetes 上跑 Java 服务,最让人头疼的故障之一就是 Pod 被频繁 OOMKilled。表现通常是这样的:服务跑着跑着突然重启,kubectl get pod 看到重启次数一直在涨,kubectl describe pod 里赫然写着 Last State: Terminated, Reason: OOMKilled, Exit Code: 137。业务侧的反馈是接口偶发超时、长连接掉线、定时任务没跑完就被掐断,甚至整批 Pod 同时被杀导致服务雪崩。

这种问题的难处理之处在于,它往往不是单纯的”内存配少了”。很多同学第一反应是把 limits.memory 往上调,结果调到 8Gi、16Gi 还是 OOM,甚至越调越频繁。原因在于 OOM 的根因可能在三个完全不同的层面:

  • 容器层:cgroup 内存上限被突破,内核 OOM Killer 杀掉进程,Kubernetes 把它标记为 OOMKilled
  • 节点层:节点内存压力大,kubelet 触发按 QoS 等级的驱逐(Eviction),Pod 被驱逐而非容器 OOM。
  • 应用层:JVM 堆内、堆外、Metaspace、直接内存、线程栈、GC 开销任意一项吃满了容器配额,但堆可能并没有满。

把这三层混在一起谈,是排查最容易跑偏的地方。本文把容器内存机制、Kubernetes 资源管理、JVM 内存模型三条线串起来,给一套能照着执行的排查闭环:从看到 OOMKilled 到定位到具体是哪类内存吃满,再到给出对应的 limits 设置和 JVM 参数调优方案,最后给回滚和验证方法。

二、适用场景

本文适用于以下情况:

  • 在 Kubernetes 集群(1.20 及以上版本,cgroup v1 或 cgroup v2 均涉及)上运行 Java 应用(JDK 8u191+ 或 JDK 11/17/21)。
  • Pod 出现 OOMKilled137 退出码、CrashLoopBackOff 或节点驱逐相关告警。
  • 需要从零建立一套 Pod 内存监控与 OOM 排查流程的运维或 DevOps 工程师。
  • 需要为 Java 服务制定合理的 requests/limits 和 JVM 内存参数规范的团队。

不适用的情况:

  • 非 Java 应用(Go、Node、Python 等)的内存泄漏排查,原理部分(cgroup、Kubernetes 驱逐)仍可参考,但 JVM 调优部分不适用。
  • 裸机/虚拟机上 JVM 调优,没有容器 cgroup 约束时,资源模型不同,本文的 limits 部分不直接适用。

前置条件(排查前应具备,否则效率大打折扣):

  • 集群已部署 metrics-server(kubectl top 可用)和 cAdvisor + Prometheus(历史指标可查)。
  • 业务镜像含 JDK 完整工具链或预装 Arthas,能 kubectl exec 进容器跑 jcmd
  • 关键服务已开 NativeMemoryTracking=summary 和 HeapDumpOnOutOfMemoryError
  • 有 node-problem-detector 或等价的宿主 OOM 事件采集。
  • 有可用的 dump 持久卷或足够节点磁盘空间。
  • 团队有 MAT/async-profiler 离线分析能力。

这些前置条件本身就是 OOM 可观测性的基线。如果都不具备,第一件事不是排查某个 OOM,而是先把这套观测能力建起来,否则每次 OOM 都只能靠猜。

三、核心知识点

3.1 容器 OOM 与节点驱逐是两件事

容器被杀有两种来源,必须先分清:

  • 容器 OOM(Container OOMKilled):容器进程内存用量超过 cgroup 的内存上限,由内核 OOM Killer 选中该 cgroup 内得分最高的进程杀掉。kubectl describe pod 会显示 Reason: OOMKilled,退出码 137(128 + SIGKILL 9)。
  • 节点驱逐(Node Eviction):节点整体内存紧张,kubelet 的驱逐管理器根据 --eviction-hard(如 memory.available<100Mi)触发,按 QoS 等级和超出 requests 的程度挑选 Pod 驱逐。被驱逐的 Pod 事件里是 Status: Evicted,而不是 OOMKilled

判断依据:看 kubectl describe pod <pod> -n <ns> 的 Events 和 Last State。OOMKilled 走容器内存这条线;Evicted 且节点上有 MemoryPressure 条件走节点内存这条线。

3.2 cgroup v1 与 cgroup v2 的内存文件

容器内存上限由 cgroup 实现,Kubernetes 1.25 起默认 cgroup v2(取决于节点配置),不同版本文件路径不同:

  • cgroup v1:
    • 上限:/sys/fs/cgroup/memory/memory.limit_in_bytes
    • 当前用量:/sys/fs/cgroup/memory/memory.usage_in_bytes
    • RSS:/sys/fs/cgroup/memory/memory.stat 里的 rsscacheswap 等字段
    • OOM 控制:/sys/fs/cgroup/memory/memory.oom_control
  • cgroup v2:
    • 上限:/sys/fs/cgroup/memory.max
    • 当前用量:/sys/fs/cgroup/memory.current
    • 明细:/sys/fs/cgroup/memory.stat(字段如 anonfileslab 等,对应 v1 的 rss/cache)
    • OOM 事件计数:/sys/fs/cgroup/memory.events(字段 oomoom_kill
    • 峰值:/sys/fs/cgroup/memory.peak

在容器里 cat 这些文件可以拿到内核视角的真实数据,比 JVM 自己算的更权威。不同发行版、不同 K8s 版本挂载路径可能略有差异,重点看 /sys/fs/cgroup/ 下是否存在 memory.max(v2)还是 memory/ 目录(v1)。

3.3 kubectl top 用的是 working set

kubectl top pod 显示的内存是 container_memory_working_set_bytes,不是 container_memory_usage_bytes。两者的关系在 cgroup v1 下大致是:

   working_set = usage_in_bytes - total_inactive_file(不可回收的文件缓存部分)

working set 更接近”内核认为不能轻易释放、OOM 时算账的那部分内存”。所以在排查时盯 kubectl top 和 Prometheus 的 container_memory_working_set_bytes 是对的。需要注意,不同 exporter、不同 cgroup 版本下这两个指标的换算口径可能略有差异,以实际 exporter 指标为准,重点观察其相对变化趋势。

3.4 QoS 等级与 oom_score_adj

Kubernetes 根据 requests 和 limits 的关系给 Pod 划分 QoS:

  • Guaranteed:每个容器 requests.memory == limits.memory 且 CPU 也相等。oom_score_adj 为 -997,最不容易被节点 OOM Killer 选中。
  • Burstable:至少有一个容器设置了 requests,但不满足 Guaranteed。oom_score_adj 在 2 到 1000 之间按比例计算。
  • BestEffort:没设置任何 requests/limits。oom_score_adj 为 1000,节点内存紧张时最先被杀。

QoS 影响的是节点级 OOM Killer 的优先级和驱逐顺序,不影响容器自身 cgroup 上限是否被突破。也就是说,Guaranteed 的 Pod 一样会 OOMKilled,只要它自己吃满了自己的 limits。

3.5 JVM 在容器里的内存组成

这是排查 Java Pod OOM 最关键的知识点。一个 JVM 进程在容器里占用的内存远不止堆:

  • 堆(Heap):-Xmx 控制的部分,GC 管理的对象。
  • Metaspace:类元数据,-XX:MaxMetaspaceSize 控制,堆外。
  • 直接内存(Direct Buffer):NIO/Netty 用,-XX:MaxDirectMemorySize 控制,堆外。
  • 线程栈:每线程 -Xss,线程数 × Xss,堆外。
  • Code Cache:JIT 编译后的代码,-XX:ReservedCodeCacheSize,堆外。
  • GC 自身开销:G1/Parallel/ZGC 的数据结构和线程占用的本地内存。
  • JNI 本地内存:native 库分配,JVM 之外但同进程内。
  • JVM 自身:运行时数据结构、C 堆分配等。

这就是”-Xmx 设成等于 limits.memory 必然 OOM”的根本原因:cgroup 限制的是整个进程的内存,而 -Xmx 只管堆。堆外的 Metaspace、直接内存、线程栈、Code Cache、GC 开销加起来可能占几百 MiB 到 1Gi 以上,留给堆的”安全垫”一旦没有,堆还没满进程就被杀了。

3.6 UseContainerSupport 与 MaxRAMPercentage

  • JDK 8u191 之前,JVM 在容器里会按宿主机内存算堆,导致 -Xmx 自动推导远超 limits,极易 OOM。8u191 引入 UseContainerSupport(默认开启),JVM 识别 cgroup 限制。
  • 不显式设 -Xmx 时,JVM 按 cgroup 内存上限的百分比自动算最大堆,默认 MaxRAMPercentage=25(Java 8/11 早期部分版本默认 25,Server 模式下部分版本为 25;不同版本默认值可能略有差异,重点以你实际 JDK 的默认值为准,建议显式指定)。
  • 推荐显式用 -XX:MaxRAMPercentage 控制堆占比,而不是依赖默认值。

3.7 退出码 137 的含义

退出码 137 = 128 + 9(SIGKILL)。容器进程被 SIGKILL 强杀,OOM 是最常见原因,但并非唯一:节点驱逐、kubectl delete pod、cgroup 冻结、宿主机 OOM Killer 都可能产生 137。所以看到 137 不要直接断言是容器 OOM,要结合 Reason 字段和节点事件一起判断。

3.8 JVM 内存模型的完整图景

要排查堆外 OOM,必须先在脑子里建立 JVM 进程内存的完整图景。一个 JVM 进程在操作系统眼里就是一段虚拟地址空间,cgroup 限制的是这段空间里实际落实成物理页(RSS)的部分。JVM 把这段空间划分成若干区域:

  • Java Heap:-Xms/-Xmx 控制,分新生代(Eden + Survivor)/老年代,GC 主战场。这部分在 NMT 里归 Java Heap
  • Metaspace:存储类元数据(Klass、方法、常量池等),位于 native 内存,-XX:MaxMetaspaceSize 兜底。NMT 归 Class
  • Code Cache:JIT 编译产物,-XX:ReservedCodeCacheSize。NMT 归 Code
  • Thread Stack:每个 Java 线程一份,-Xss 控制单线程大小,线程数 × Xss 是总量。NMT 归 Thread
  • Direct Memory:NIO ByteBuffer.allocateDirect 分配的堆外缓冲,Netty PooledByteBufAllocator 也走这里,-XX:MaxDirectMemorySize 兜底。NMT 归 Direct(部分实现归 Internal,不同版本口径可能略有差异)。
  • GC 数据结构:G1 的 Remembered Set、Card Table、标记位图等,ZGC 的着色指针元数据。NMT 归 GC
  • Internal:JVM 自身的 C 堆分配、Unsafe 分配等。NMT 归 Internal
  • JNI/GC native:第三方 native 库(如 RocksDB、压缩库、图像处理)直接 malloc 的内存,可能不在 NMT 统计内,是排查盲区。

关键认知:-Xmx 只管 Java Heap 这一项。剩下七项加起来,在没有兜底参数时是可以无限增长的,这就是堆外 OOM 的来源。容器 limits 限制的是上述所有项的物理页总和。

3.9 内存各部分的增长特性

不同内存区域的增长模式不同,识别模式有助于快速定位:

  • Heap:受 -Xmx 硬约束,正常不会超过,超过会先抛 OutOfMemoryError: Java heap space(除非 -XX:+ExitOnOutOfMemoryError 才转 SIGKILL)。
  • Metaspace:随加载的类数量增长。动态生成类(CGLIB 代理、Groovy 脚本、JSP 重编译、反射 MethodHandle)会导致持续增长,是泄漏高发区。
  • Direct Memory:随未释放的 ByteBuf 增长。Netty 池化分配但忘记 release、连接泄漏、响应体未消费都会导致直接内存只涨不降。
  • Thread Stack:随线程数增长。线程池无上限、异步框架无并发限制、每次请求 new 线程都会导致线程栈爆炸。
  • Code Cache:相对稳定,JIT 编译达到稳态后基本不再涨,除非频繁加载新类触发大量编译。
  • GC 数据结构:随堆大小和 Region 数量定,G1 在大堆下 Remembered Set 占用可观,是”堆没满但 GC 占用大”的一个原因。

排查时根据增长模式反推:线性增长看 Metaspace 和 Direct;阶跃式增长看 Thread;与流量同步波动看 Heap 和 Direct;稳定高位不降看是否安全垫不足。

3.10 cgroup v2 的内存统计字段对照

cgroup v2 的 memory.stat 字段与 v1 不同,排查时要知道对应关系:

cgroup v2 字段含义近似 v1 字段
anon匿名页(堆、堆外 malloc 落地)rss
file文件页(缓存)cache
slab内核 slab(可回收/不可回收)slab
socksocket 缓冲sock
shmem共享内存mapped_file/shmem
pgfault/pgmajfault页错误同名
oom/oom_kill(在 memory.events)OOM 事件计数oom_control 里的 under_oom/oom_kill

排查容器 OOM 时,anon 持续增长最可疑(对应进程实际占用的堆+堆外 native),file 增长一般是缓存可回收。不同内核版本字段集合可能略有差异,重点看 memory.currentmemory.peakmemory.events 三个文件。

3.11 Kubernetes 内存配额的两个维度

除了 Pod 级别的 requests/limits,还有两个维度会影响 OOM 行为:

  • Namespace ResourceQuota:限制命名空间总内存申请量和使用量。requests.memory 超过 Quota 的 Pod 创建会被拒绝(Pending),不是 OOM,但容易和资源不足混淆。
  • LimitRange:给没有显式设置 resources 的容器设默认 requests/limits,避免 BestEffort 满天飞。

排查”Pod 创建失败”时,先 kubectl describe namespace <ns> 看 ResourceQuota 使用情况,区分是配额超限还是节点资源不足,再决定是调 Quota 还是扩容,不要误当成 OOM 处理。

四、整体排查思路

遇到 Pod OOM,按这个顺序走,不要一上来就改 JVM 参数:

  1. 确认是容器 OOM 还是节点驱逐:kubectl describe pod 看 Reason 和 Events,kubectl describe node 看是否 MemoryPressure
  2. 确认是哪个容器:Pod 可能多容器,定位到具体 container 的 Last State
  3. 确认内存增长曲线:看 Prometheus container_memory_working_set_bytes 近 24 小时趋势,是缓慢爬升(泄漏)还是尖峰(突发流量/大查询)还是稳态后突杀(堆外或 limits 留得太小)。
  4. 确认是堆内还是堆外:OOM 时堆是否真的满了,决定走堆排查还是 native memory 排查。
  5. 定位根因:根据上面四步收敛到具体原因(limits 配错、堆泄漏、直接内存泄漏、Metaspace、线程爆炸、GC 失败、节点压力)。
  6. 修复:按根因给对应方案,而不是无脑加内存。
  7. 验证:复现压力、观察指标、确认重启次数不再增长。
  8. 回滚预案:保留旧配置和镜像,灰度可退。

排查路径示意(脑内决策树):

   Pod 重启 / 137
  ├─ describe pod Reason=OOMKilled → 容器 OOM
  │    ├─ working_set 平稳后突杀,堆没满 → 堆外/limits 安全垫不足
  │    ├─ working_set 缓慢爬升 → 泄漏(堆 or 堆外)
  │    └─ working_set 尖峰 → 突发流量/大对象/大查询
  └─ describe pod Status=Evicted / node MemoryPressure → 节点内存
       └─ 节点超卖严重 / requests 设置过低 / BestEffort 太多

4.1 排查前的环境信息收集

动手前先把环境信息收集齐,避免排查中途反复确认:

  • 集群版本:kubectl version --short 或 kubectl version(cgroup v2 默认从 1.25 起,影响内存文件路径)。
  • 节点 cgroup 版本:在节点上 stat -fc %T /sys/fs/cgroup/,输出 cgroup2fs 为 v2,tmpfs 为 v1。
  • Pod 的 QoS:kubectl get pod <pod> -n <ns> -o jsonpath='{.status.qosClass}'
  • JDK 版本:容器内 java -version,确认是否 ≥ 8u191(容器感知)、是否支持 -XX:+ExitOnOutOfMemoryError(8u92+)和 -Xlog:gc*(9+)。
  • 是否开 NMT:jcmd <pid> VM.native_memory summary 能跑通即开启。
  • 监控是否就绪:确认 container_memory_working_set_bytes 等指标有数据。
  • 是否有 dump 卷:kubectl get pvc -n <ns> 看是否配了 dump 持久卷。

环境信息收集清单(核对表):

命令用途
集群版本kubectl version判断 cgroup 默认版本、API 兼容性
cgroup 版本stat -fc %T /sys/fs/cgroup/(节点)决定看哪个内存文件
Pod QoSkubectl get pod -o jsonpath=...qosClass判断节点驱逐优先级
JDK 版本java -version(容器)判断可用 JVM 参数
NMT 状态jcmd <pid> VM.native_memory summary堆外排查前提
监控数据Prometheus 查询趋势分析
dump 卷kubectl get pvcdump 落盘位置

信息齐全再动手,能避免排查到一半发现”NMT 没开只能靠排除法”或”cgroup 是 v2 却在 cat v1 路径”这类返工。

五、实战步骤

下面以一个真实风格的案例串起来。业务侧反馈:order-service 在 prod 命名空间,最近两天每隔几小时重启一次,告警里是 Pod restarted

5.1 第一步:确认重启原因

目的:分清容器 OOM 还是节点驱逐。

命令:

   bashkubectl get pod -n prod -l app=order-service -o wide

预期输出:READY 列可能正常,但 RESTARTS 列大于 0 且在增长。

命令:

   bashkubectl describe pod <pod-name> -n prod

重点看两段:

  • Containers: 下每个容器的 Last State
   Last State:    Terminated
  Reason:      OOMKilled
  Exit Code:   137
  Started:     Mon Jun 23 10:00:00 2026
  Finished:    Mon Jun 23 12:30:00 2026
  • Events: 段是否有 Evicted 或节点相关事件。

判断逻辑:

  • Reason: OOMKilled → 走容器内存排查(5.3 起)。
  • Status: Evicted 或 Events 里有 node was low on resource → 走节点内存排查(5.2)。
  • 两者都没有,只是 137 → 看是否人为 delete 或健康检查失败导致的强杀,先排除 Liveness/Startup Probe 失败。

异常表现:Last State 里 Reason 为空但 Exit Code 137,且 Events 里有 Liveness probe failed。这其实是探针失败后被 kill,不是 OOM,不要按 OOM 处理。

下一步动作:根据 Reason 进入对应分支。

5.2 分支 A:节点内存压力导致的驱逐

目的:判断是不是节点层面的问题。

命令:

   bashkubectl describe node <node-name>

重点看:

  • Conditions: 里 MemoryPressure 是否为 True
  • Allocatable: 的 memory 与 Allocated resources 的内存申请量对比,看是否超卖严重。
  • Events: 段是否有 Evicted 记录。

命令:

   bashkubectl get pod -n prod -o wide --field-selector spec.nodeName=<node-name> | \
  awk '{print $1, $2}' | head

辅助看节点上跑了哪些 Pod。

判断逻辑:

  • MemoryPressure=True 或节点 memory.available 长期接近 --eviction-hard 阈值 → 节点内存不足。
  • 节点上 Burstable/BestEffort Pod 多,且 requests.memory 设得很低但实际用得多 → 超卖,被驱逐的是最软的柿子。

常用命令看节点实时内存(在节点上执行,需要主机权限):

   bashfree -h
   bashcat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvailable|Buffers|Cached"

预期输出重点关注 MemAvailable,kubelet 用这个值和 eviction-hard 阈值比较。

修复方向(节点侧):

  • 给关键服务用 Guaranteed QoS(requests == limits),降低被驱逐概率。
  • 扩节点或迁移 Pod,降低单节点内存超卖。
  • 检查是否 requests.memory 设得过低(如 256Mi 但实际用 2Gi),把它调到接近真实用量。

风险提醒:调整 requests 会触发 Pod 重新调度,可能影响滚动更新和 PDB。生产环境先在一个副本上验证,避免批量重建。

下一步动作:如果排除节点压力,回到容器 OOM 分支(5.3)。

5.3 第二步:定位是哪个容器、内存增长形态

目的:多容器 Pod 要锁定到具体容器,并判断内存是泄漏型、突发型还是稳态突杀型。

命令:

   bashkubectl top pod <pod-name> -n prod --containers

预期输出(示意):

   POD                NAME          CPU(cores)   MEMORY(bytes)
order-service-xxx  order-app     120m         1850Mi
order-service-xxx  sidecar-log   10m          80Mi

判断逻辑:锁定到 order-app 容器,内存 1850Mi。

命令(看历史趋势,依赖 Prometheus 已部署 cAdvisor metrics):

   bash# 示例思路:通过 PromQL 查询近 6 小时 working set 趋势
# 实际用 Grafana Explore 或 promtool query 比手敲 PromQL 更稳

PromQL(以实际 exporter 指标为准):

   container_memory_working_set_bytes{namespace="prod",pod=~"order-service-.*",container="order-app"}

判断内存形态:

  • 缓慢单调爬升,每次重启后归零再爬 → 典型泄漏(堆 or 堆外),走 5.4、5.5。
  • 平稳在一个值附近,突然尖峰后重启 → 突发流量/大查询/大对象,走 5.6。
  • 平稳在接近 limits 的值,无明显增长,运行一段时间后被杀 → limits 安全垫不足或堆外吃满,走 5.7。

下一步动作:按形态进入对应子步骤。

5.4 第三步:在容器内确认 cgroup 上限与实际用量

目的:用内核视角核实 limits 生效情况,排除”limits 没生效 / 被覆盖”。

命令(进入容器,需要确认 Pod 还活着或在新副本里执行):

   bashkubectl exec -it <pod-name> -c order-app -n prod -- sh

容器内执行:

   bash# cgroup v2
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.peak
cat /sys/fs/cgroup/memory.events
   bash# cgroup v1
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/memory.usage_in_bytes

预期输出(cgroup v2 示意):

   2147483648          # memory.max = 2Gi,对应 limits.memory: 2Gi
2107637760          # memory.current 已接近上限
2147483648          # memory.peak 等于上限,说明顶到过
oom 3
oom_kill 3          # 已发生 3 次 cgroup OOM

判断逻辑:

  • memory.max 应等于 limits.memory。如果不等,说明 limits 没正确下发或被运行时覆盖,去查 Deployment/Pod spec 和运行时类(如 containerd 配置)。
  • memory.peak 等于或非常接近 memory.max,说明确实顶到上限被杀,确认是容器 OOM。
  • memory.events 的 oom_kill 计数与重启次数能对上。

下一步动作:确认 limits 生效后,判断堆内还是堆外。

5.5 第四步:判断是堆内还是堆外

目的:决定走堆泄漏排查还是 native memory 排查。

命令(容器内,JDK 自带工具):

   bash# 拿到 JVM 进程 PID
jps
# 或
pgrep -f java
   bash# 堆使用概况
jcmd <pid> GC.heap_info
# 或
jstat -gc <pid> 1000 5

jcmd <pid> GC.heap_info 预期输出(示意):

    garbage-first heap   total  1048576K, used 880000K [0x..., 0x..., 0x...)
  region size 2048K, 512 young (1048576K), 0 survivors (0K)
 Metaspace       used 120000K, capacity 130000K, committed 132000K, reserved 1146880K
  class space    used 13000K, capacity 14000K, committed 14000K, reserved 1048576K

判断逻辑:

  • 堆 used 远小于 Xmx(如 Xmx 1.5Gi 但 used 才 880Mi),而容器 memory.current 已接近 memory.max → 堆外内存吃满,走 5.7。
  • 堆 used 接近 Xmx 且 GC 后降不下来 → 堆泄漏或大对象驻留,走 5.6。
  • Metaspace used 接近 MaxMetaspaceSize → Metaspace 泄漏(常见于动态类生成、Groovy 脚本、反射代理),走 5.7。

开启 Native Memory Tracking(需要重启 JVM 加参数,不能热开,除非已预先开启):

   -XX:NativeMemoryTracking=summary

开启后命令:

   bashjcmd <pid> VM.native_memory summary

预期输出会分类给出 Reserved/Committed:Java HeapClass(Metaspace)、ThreadCodeGCInternalDirect 等。重点关注哪一类 Committed 异常大。

异常表现:Direct 或 Internal 的 Committed 持续增长但堆不涨 → 直接内存泄漏(Netty、NIO 场景常见)。

下一步动作:堆内问题走 5.6,堆外问题走 5.7。

5.6 分支 B:堆内泄漏或大对象

目的:定位是哪类对象占住堆。

命令:

   bash# 看 GC 情况,关注 Full GC 频率和各代占用
jstat -gcutil <pid> 2000 10

预期输出(示意):

     S0     S1     E      O      M     CCS    YGC    YGCT    FGC    FGCT    GCT
  0.00  98.00  60.00  95.00  92.00  88.00   120   15.000   12    8.500  23.500

判断逻辑:

  • O(Old 区)长期 90%+ 且 FGC 频繁、FGCT 累计时间长但 O 降不下来 → 老年代驻留大量对象,典型泄漏。
  • YGC 后 O 持续上涨只下不来 → 对象晋升后无法回收。

命令(抓堆 dump,注意风险):

   bash# 抓堆 dump,会触发一次 STW,生产慎用,建议在低峰或副本上抓
jcmd <pid> GC.heap_dump /tmp/heap.hprof

或者更推荐用 Arthas 在线看,开销更小:

   bash# 下载并启动 arthas(按官方方式),attach 到目标 JVM
# arthas 里执行:
dashboard
heapdump /tmp/heap.hprof

拿到 hprof 后用 MAT(Eclipse Memory Analyzer)或 jolokia/async-profiler 配合分析,重点看 Dominator Tree 和 Leak Suspects 报告,定位哪类对象、哪条引用链占住内存。

风险提醒:GC.heap_dump 抓全堆会 STW 并产生与堆等大的临时文件,生产环境务必在副本或低峰执行,dump 文件及时清理避免占满磁盘。

下一步动作:定位泄漏对象后改代码或配置,再走 5.8 的验证。

5.7 分支 C:堆外内存 / limits 安全垫不足

目的:分清是”真的堆外泄漏”还是”limits 留的安全垫不够”。

先看是不是配置问题。计算容器内存预算:

   容器内存预算(limits.memory) =
  -Xmx(堆)
+ -XX:MaxMetaspaceSize
+ -XX:MaxDirectMemorySize(直接内存上限)
+ 线程数 × -Xss(线程栈)
+ -XX:ReservedCodeCacheSize
+ GC 开销(G1 一般预留堆的 10%~20%,ZGC 更多)
+ JVM 自身 + 安全垫(建议 25%~30%)

举例:limits.memory: 2Gi,若 -Xmx 设成 1.8Gi,几乎没有安全垫,Metaspace + 线程栈 + 直接内存 + GC 一上来就把 2Gi 撑爆,堆还没满就 OOM。这是最常见的”配置型 OOM”。

判断逻辑:

  • 用 5.5 的 VM.native_memory summary 看哪一类堆外大。
  • 如果是直接内存(Direct)持续增长 → Netty/NIO 场景,检查是否未释放的 ByteBuf、连接池泄漏。可加 -XX:MaxDirectMemorySize=512m 兜底,并用 jcmd <pid> VM.native_memory baseline + detail 对比增长。
  • 如果是 Metaspace 增长 → 检查是否有动态类生成未被回收,加 -XX:MaxMetaspaceSize=256m 兜底并定位生成源。
  • 如果是线程数爆炸(Thread 类大)→ 查线程数,jcmd <pid> Thread.print 或 kubectl exec -- jstack <pid>,看是否线程池没设上限或异步框架创建了海量线程。
  • 如果配置预算算下来本来就不够 → 走”正确设置 limits + JVM 参数”方案(第六、七节)。

命令(看线程数):

   bash# 容器内
jcmd <pid> Thread.print | grep "java.lang.Thread.State" | wc -l
# 或看进程线程数
cat /proc/<pid>/status | grep Threads

判断逻辑:线程数远超预期(如几万)→ 线程泄漏或池未限流,先治线程,别加内存。

下一步动作:堆外泄漏走代码修复 + 兜底参数;配置型走参数规范。

5.8 第五步:修复方案落地

按根因分场景给方案,下面是最常见的两类。

场景一:配置型 OOM(limits 安全垫不足)。

修复要点:

  • limits.memory 设为应用稳态峰值 × 1.3 左右,留 30% 安全垫。
  • -Xmx 设为 limits.memory 的 60%~70%,而不是等于 limits。
  • 显式设置堆外各部分上限。
  • 用 -XX:MaxRAMPercentage 时,给堆外的部分要单独再留。

场景二:堆外泄漏(直接内存)。

修复要点:

  • 加 -XX:MaxDirectMemorySize 兜底,避免无限增长。
  • 代码侧定位未释放的 ByteBuf(Netty 用 PooledByteBufAllocator 的 leakDetector,加 -Dio.netty.leakDetection.level=ADVANCED)。
  • 修复后灰度发布验证。

修改 Deployment 示例见第七节。

5.9 第六步:验证

目的:确认修复后 OOM 不再发生。

命令(持续观察重启次数):

   bashkubectl get pod -n prod -l app=order-service -w

观察 RESTARTS 列在预期压力下是否保持不增长。

命令(看实时内存):

   bashkubectl top pod -n prod -l app=order-service --containers

Prometheus 看趋势(以实际 exporter 指标为准):

   container_memory_working_set_bytes{namespace="prod",container="order-app"}

验证标准:

  • 在业务高峰(压测或真实流量)下,working_set 峰值低于 limits.memory 的 80%~85%。
  • 连续观察 24~48 小时,重启次数为 0。
  • 容器内 memory.events 的 oom_kill 计数不再增长。

5.10 第七步:复盘

复盘要点:

  • 记录根因、修复方案、验证结果、影响范围。
  • 沉淀监控告警阈值(见第八节)。
  • 把”limits + JVM 参数”规范写入团队基线,避免同类问题在其他服务复现。
  • 如果是泄漏类,跟踪代码修复的 PR 和回归测试。

5.11 分支 D:线程泄漏与 native 线程上限

目的:线程数异常增长会把线程栈总占用顶上去,每线程默认 -Xss1m,几千线程就是几 GiB 堆外,直接吃满容器内存,而堆可能完全没动。

命令(容器内看线程数):

   bashpgrep -f java | head -1 | xargs -I{} cat /proc/{}/status | grep Threads

预期输出(示意):

   Threads: 8432

判断逻辑:业务正常线程数应该是个稳定的小数量级(几十到几百)。Threads: 8432 显然异常,按 -Xss1m 算线程栈理论占用 8.4GiB,远超 2Gi limits,必然 OOM。

命令(看线程在干什么):

   bashjcmd <pid> Thread.print > /tmp/t.txt
grep -E "java.lang.Thread.State" /tmp/t.txt | sort | uniq -c | sort -rn | head

预期输出(示意):

      8400 RUNNABLE
     12 TIMED_WAITING (on object monitor)
     20 WAITING (parking)

判断逻辑:8400 个 RUNNABLE 线程说明线程池没有上限或异步框架(如未限制并发度的响应式/线程池)创建了海量线程。

命令(找创建线程的栈):

   bashjcmd <pid> Thread.print | grep -A 30 "Thread-" | head -60

异常表现:大量线程栈顶停在某个连接池获取、某个 new Thread 调用、某个 CompletableFuture 链路上。

还要检查系统级线程上限(容器内):

   bashcat /proc/sys/kernel/threads-max
ulimit -u

判断逻辑:如果 Threads 计数已经接近 threads-max 或 ulimit -u,会先抛 unable to create new native thread 而不是直接 OOM。两种情况都可能发生,看日志区分:

  • 日志有 OutOfMemoryError: unable to create new native thread → 线程数触顶,先治线程。
  • 日志没有该错误,但进程被 SIGKILL → 线程栈总占用吃满 cgroup,按 OOM 处理。

修复方向:

  • 代码侧给线程池设上限(ThreadPoolExecutor 的 maximumPoolSize、队列容量),响应式框架限制并发度(如 flatMap 的 concurrency 参数)。
  • 临时止血可降低 -Xss(如 -Xss512k,但要看栈深度是否够),或调高 limits 并尽快定位泄漏源。
  • 不要靠调大 threads-max 解决线程泄漏,那是把崩溃时间推迟。

风险提醒:抓 Thread.print 时线程数极高的情况下本身有开销,避开高峰;导出文件及时清理。

下一步动作:定位泄漏源后修复并灰度,走 5.8 修复与 5.9 验证。

5.12 分支 E:GC overhead 与堆泄漏的区分

目的:GC overhead limit exceeded 和真正的堆泄漏在表象上接近,但处理方向不同。

命令:

   bashjstat -gcutil <pid> 2000 20

预期输出(示意,连续 20 次采样):

     S0     S1     E      O      M     CCS    YGC   YGCT    FGC   FGCT     GCT
  0.00  98.00  80.00  92.00  92.00  88.00  130  16.000    5   4.000  20.000
  0.00  98.00  80.00  93.00  92.00  88.00  132  16.200    6   4.800  20.800
  0.00  98.00  80.00  94.00  92.00  88.00  135  16.500    7   5.600  22.100
  0.00  98.00  80.00  94.00  92.00  88.00  138  16.900    8   6.400  23.300

判断逻辑:

  • FGC(Full GC 次数)短时间内快速上涨,O(老年代)持续 92%~94% 降不下来,FGCT(Full GC 累计耗时)线性增长 → 老年代驻留大量无法回收对象,典型堆泄漏。
  • 如果 O 在 Full GC 后能短暂回落但很快又涨满,且伴随业务大流量 → 可能是”瞬时大对象”而非稳定泄漏,需结合业务看是不是大查询/大结果集。

区分方法:在低峰期(流量小)持续观察 O。低峰仍单调爬升 → 泄漏;低峰回落、高峰才涨 → 突发大对象。

修复方向:

  • 泄漏:抓 dump 用 MAT 看 Dominator Tree 和 Path to GC Roots(排除弱/软引用),定位持住大对象的引用链,改代码。
  • 突发大对象:业务侧限制单次查询返回行数、分页、流式处理,或对大结果集加缓存上限。

5.13 分支 F:突发流量与大查询导致的尖峰 OOM

目的:内存形态是平稳基线 + 偶发尖峰,重启时间点与某个业务动作(大查询、报表导出、批量推送)对齐。

判断依据(多指标交叉,不要只看一个):

  • working_set 折线在某个时刻出现陡峭尖峰,尖峰顶部即重启点。
  • 业务日志在该时刻有大量”导出”“查询””批量”类请求。
  • 重启后 working_set 立刻回落到基线,不是缓慢爬升。

这类不是泄漏,是单次操作分配了大量临时对象或加载了大结果集到内存。

命令(看 GC 在尖峰时刻的表现,需开启 GC 日志):

   bash# 如果启动参数里有 -Xlog:gc*(JDK 11+)或 -XX:+PrintGCDetails(JDK 8)
# 在容器内找 GC 日志
ls -lh /var/log/ | grep gc
# 或通过 jcmd 查看 GC 日志配置
jcmd <pid> VM.log list

修复方向(不靠加内存):

  • 大查询分页、流式游标(MyBatis 的 ResultHandler、JPA 的 Stream)。
  • 报表导出改异步 + 分批写文件/对象存储,不在内存里攒全量。
  • 对单请求内存设上限(如限制导出最大行数),超过走拒绝或排队。
  • 限流:对触发大内存操作接口限并发,避免多个大请求同时打满内存。

加内存只是兜底,根因在业务逻辑,否则流量一大就复发。

5.14 完整案例时间线

把前面的步骤串成一个真实复盘时间线,便于对照自己的排查节奏。

  • T0:监控告警 Pod restartedorder-service 重启计数 +1。
  • T+2min:kubectl describe pod 看到 Reason: OOMKilled, Exit Code: 137,确认容器 OOM,排除节点驱逐(node 无 MemoryPressure)。
  • T+5min:kubectl top pod --containers 锁定 order-app 容器,内存 1850Mi / limits 2Gi。
  • T+10min:Prometheus 看 24h 趋势,working_set 平稳在 1.8Gi 附近偶发尖峰到 2Gi 后重启,判断不是缓慢泄漏,是稳态接近上限 + 尖峰。
  • T+15min:进入新副本容器,cat /sys/fs/cgroup/memory.max = 2147483648(2Gi),memory.peak = 2147483648,确认顶到上限。
  • T+20min:jcmd <pid> GC.heap_info,堆 used 才 880Mi,远小于 MaxHeapSize 1.5Gi(原配置 -Xmx 设成了 1.8Gi,实际堆没满)。
  • T+25min:判断堆外吃满。jcmd <pid> VM.native_memory summary(已开 NMT),Direct 类 Committed 约 600Mi 且持续增长,定位直接内存。
  • T+30min:日志搜 Direct buffer memory,确认 Netty 场景直接内存泄漏。加 -XX:MaxDirectMemorySize=512m 兜底,并开 -Dio.netty.leakDetection.level=ADVANCED
  • T+40min:核算内存预算,把 -Xmx 从 1.8Gi 调到 1.2Gi(MaxRAMPercentage=60),Metaspace 256m,Direct 512m,留安全垫。
  • T+50min:改 Deployment,灰度 1 副本 kubectl apply,观察 1 小时,重启次数为 0,working_set 峰值 1.6Gi。
  • T+1h:全量滚动更新,连续观察 48h,重启计数 0,oom_kill 计数不增长。
  • T+48h:复盘,把”netty 泄漏检测 + MaxDirectMemorySize 兜底 + 堆按 limits 60%”写入团队基线,配置告警 PodMemoryNearLimit 85%。

这条时间线说明:从告警到定位根因约 30 分钟,前提是监控指标和 NMT 都就位。没有 NMT 时定位堆外要靠排除法,时间会拉长到数小时,所以建议关键服务默认开 NativeMemoryTracking=summary

六、常用命令速查

   bash# 看 Pod 状态和重启次数
kubectl get pod -n <ns> -o wide
kubectl get pod -n <ns> -l <selector>

# 看重启原因(重点)
kubectl describe pod <pod> -n <ns>

# 看容器级别资源占用
kubectl top pod <pod> -n <ns> --containers
kubectl top node

# 进入容器
kubectl exec -it <pod> -c <container> -n <ns> -- sh

# 容器内 cgroup v2
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.peak
cat /sys/fs/cgroup/memory.events

# 容器内 cgroup v1
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/memory.stat

# 节点内存
kubectl describe node <node>
# 节点上执行
free -h
cat /proc/meminfo

# 看 dmesg 的 OOM 记录(需要宿主机权限,容器内看不到宿主 dmesg)
dmesg -T | grep -i -E "out of memory|oom|killed process"

# JVM 诊断(容器内)
jps
pgrep -f java
jcmd <pid> GC.heap_info
jstat -gc <pid> 1000 10
jstat -gcutil <pid> 2000 10
jcmd <pid> VM.native_memory summary
jcmd <pid> Thread.print
jcmd <pid> VM.flags
jcmd <pid> GC.heap_dump /tmp/heap.hprof

# 看 Pod 资源声明
kubectl get pod <pod> -n <ns> -o jsonpath='{.spec.containers[*].resources}' | jq .

说明:dmesg 在容器内通常看不到宿主机的内核日志,看宿主 OOM 记录需要在节点上以特权方式执行,或通过日志采集(如 node-problem-detector)把 dmesg 里的 OOM 行采集到中心日志。node-problem-detector 会把宿主 OOM 事件转成 Kubernetes Node 事件和告警,是生产环境推荐做法。

6.1 命令输出关键字段速查表

kubectl describe pod 关键字段对照:

字段位置字段名含义判断
Containers.lastStateReason终止原因OOMKilled=容器OOM;空+Evicted=节点驱逐
Containers.lastStateExit Code退出码137=SIGKILL;143=SIGTERM(正常停止)
Containers.lastStateFinished终止时间与监控尖峰对齐确认因果关系
Containers.restartCount计数重启次数趋势增长=反复 OOM
Containers.staterunning/waiting当前状态CrashLoopBackOff=反复崩溃退避
EventsBack-off退避配合 restartCount 判断崩溃频率

jstat -gcutil 列含义对照:

含义关注
S0/S1Survivor 0/1 使用率正常交替
EEden 使用率YGC 后应回落
O老年代使用率长期 90%+ 降不下=泄漏
MMetaspace 使用率接近 MaxMetaspaceSize=元数据泄漏
YGC/YGCTYGC 次数/耗时频繁 YGC=新生代太小或分配过快
FGC/FGCTFull GC 次数/耗时频繁 FGC 且 O 降不下=老年代驻留
GCTGC 总耗时与运行时间比看 GC 占比

jcmd <pid> VM.native_memory summary 分类对照:

分类对应内存增长异常时的方向
Java Heap查堆泄漏/dump
ClassMetaspace查动态类生成
Thread线程栈查线程泄漏
CodeCode Cache一般稳定,看是否频繁加载新类
GCGC 数据结构大堆下 G1 RS 占用,评估换 GC
InternalJVM 内部/Unsafe查 native 分配
Direct直接内存查 Netty/NIO 释放

6.2 一次性诊断脚本

把常用诊断命令打包成一个脚本,进容器后一键采集,避免手敲遗漏。文件路径:scripts/oom-snapshot.sh

   bash#!/usr/bin/env bash
# 用途:在容器内一次性采集 JVM 内存诊断快照,输出到 /tmp/oom-snapshot
# 只读采集,不修改任何配置,不触发 dump(避免 STW),可安全运行
set -uo pipefail

OUT="/tmp/oom-snapshot"
mkdir -p "$OUT"

echo "[1] cgroup v2"
if [ -f /sys/fs/cgroup/memory.max ]; then
  cat /sys/fs/cgroup/memory.max > "$OUT/memory.max"
  cat /sys/fs/cgroup/memory.current > "$OUT/memory.current"
  cat /sys/fs/cgroup/memory.peak > "$OUT/memory.peak"
  cat /sys/fs/cgroup/memory.events > "$OUT/memory.events"
else
  echo "cgroup v1 detected"
  cat /sys/fs/cgroup/memory/memory.limit_in_bytes > "$OUT/limit_in_bytes"
  cat /sys/fs/cgroup/memory/memory.usage_in_bytes > "$OUT/usage_in_bytes"
fi

PID="$(pgrep -f java | head -1)"
if [ -z "$PID" ]; then
  echo "no java process found" >&2
  exit 1
fi
echo "java pid: $PID" > "$OUT/pid"

echo "[2] heap info"
jcmd "$PID" GC.heap_info > "$OUT/heap_info" 2>&1

echo "[3] gcutil"
jstat -gcutil "$PID" 1000 10 > "$OUT/gcutil" 2>&1

echo "[4] native memory"
jcmd "$PID" VM.native_memory summary > "$OUT/nmt" 2>&1

echo "[5] vm flags"
jcmd "$PID" VM.flags > "$OUT/vmflags" 2>&1

echo "[6] thread count"
cat /proc/"$PID"/status | grep Threads > "$OUT/threads" 2>&1

echo "[7] meminfo"
cat /proc/meminfo > "$OUT/meminfo"

echo "done, snapshots in $OUT"
ls -lh "$OUT"

脚本说明:

  • 全程只读,不抓 dump(避免 STW),适合生产环境快速采集现场。
  • 输出落到 /tmp/oom-snapshot,可 kubectl cp 拉到本地分析。
  • pgrep -f java | head -1 取第一个 java 进程,多 JVM 容器需手动指定 PID。
  • set -uo pipefail:未定义变量报错、管道失败传递,但不用 -e(某条命令失败不影响后续采集)。
  • 采集后记得清理 /tmp/oom-snapshot,避免残留。

6.3 拉取诊断快照到本地

   bash# 把容器内的诊断快照拷到本地(按实际 pod/namespace 替换)
kubectl cp prod/<pod>:/tmp/oom-snapshot ./oom-snapshot -c order-app

注意:kubectl cp 依赖容器内有 tar,distroless 镜像可能没有,需要换镜像或用 kubectl exec ... cat 逐个文件拉取。

6.4 常用 PromQL 速查

排查时高频用到的 PromQL(以实际 exporter 指标为准,字段名可能因版本略有差异):

   # 容器内存使用率(working_set / limits)
container_memory_working_set_bytes{namespace="prod",container!=""}
/
container_spec_memory_limit_bytes{namespace="prod",container!=""}

# 容器 RSS(近似堆外 native 占用参考)
container_memory_rss{namespace="prod",container!=""}

# 容器文件缓存(可回收部分,单独看避免误判)
container_memory_cache{namespace="prod",container!=""}

# 近 5 分钟 OOM 事件
increase(container_oom_events_total{namespace="prod"}[5m])

# 近 1 小时重启次数
increase(kube_pod_container_status_restarts_total{namespace="prod"}[1h])

# 节点可用内存
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes

# 节点内存使用率(Allocatable 维度)
1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)

使用建议:这些 PromQL 在 Grafana Explore 或 kubectl get --raw "/api/v1/namespaces/monitoring/services/prometheus:9090/proxy/api/v1/query?query=..." 里验证。指标名以集群实际部署的 exporter 为准,cAdvisor 与某些自定义 exporter 的字段名存在差异,先用 {__name__=~"container_memory.*"} 查实际可用指标再套用。

七、配置示例

7.1 推荐的 Deployment 资源声明

文件路径:deploy/order-service.yaml(按实际仓库结构放置)。

   yamlapiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: prod
  labels:
    app: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-app
          image: registry.example.com/order-service:1.2.3
          resources:
            requests:
              cpu: "500m"
              memory: "2Gi"
            limits:
              cpu: "1000m"
              memory: "2Gi"
          env:
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage=60
                -XX:MaxMetaspaceSize=256m
                -XX:MaxDirectMemorySize=512m
                -XX:+HeapDumpOnOutOfMemoryError
                -XX:HeapDumpPath=/dump/heap.hprof
                -XX:NativeMemoryTracking=summary
                -XX:+ExitOnOutOfMemoryError
          volumeMounts:
            - name: dump
              mountPath: /dump
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 15
            failureThreshold: 5
      volumes:
        - name: dump
          emptyDir: {}

配置说明:

  • requests == limits(均为 2Gi),构成 Guaranteed QoS,节点内存紧张时不易被驱逐,也避免被调度到内存不足的节点后又被赶走。
  • -XX:MaxRAMPercentage=60:堆按 limits.memory 的 60% 算,约 1.2Gi,给堆外留约 800Mi 安全垫。
  • -XX:MaxMetaspaceSize=256m:兜底 Metaspace,防止动态类生成无限涨。
  • -XX:MaxDirectMemorySize=512m:兜底直接内存,Netty/NIO 场景必须设。
  • -XX:+HeapDumpOnOutOfMemoryError + HeapDumpPath:堆 OOM 时自动 dump,便于事后分析。注意 dump 落到 emptyDir,会占节点磁盘,需配合定时清理或挂持久卷。
  • -XX:NativeMemoryTracking=summary:开 NMT,便于 VM.native_memory 排查,有约 5% 性能开销,权衡使用;生产可只在排查期开启。
  • -XX:+ExitOnOutOfMemoryError:堆 OOM 时让进程直接退出而非继续带病运行,配合 K8s 重启更快恢复,避免半死状态。该参数在 JDK 8u92+ 可用,部分早期版本不支持,使用前确认 JDK 版本。
  • Liveness failureThreshold: 5:避免单次抖动误杀,给 GC 停顿留余地。

生效方式:kubectl apply -f deploy/order-service.yaml。修改 resources 或 env 会触发滚动更新,Pod 逐个重建。

7.2 JVM 内存预算核算表

以 limits.memory: 2Gi 为例,推荐分配:

组成推荐值说明
堆 -Xmx约 1.2Gi(MaxRAMPercentage=60)主要对象存储
Metaspace256m类元数据兜底
直接内存512mNIO/Netty 堆外
Code Cache默认(约 240m)JIT 代码,一般不动
线程栈线程数 × 1m默认 -Xss1m,200 线程约 200m
GC 开销约 150mG1 数据结构和线程
安全垫剩余部分兜底波动,建议留 200m+

合计约 2Gi,留有余量。MaxRAMPercentage 与显式 -Xmx 二选一,不要同时设,否则以 -Xmx 为准可能导致混乱。

7.3 cgroup v2 下确认 limits 生效

部署后在容器内验证:

   bashcat /sys/fs/cgroup/memory.max
# 预期:2147483648(2Gi)
   bash# 确认 JVM 看到的内存上限
jcmd <pid> VM.flags | grep -i -E "MaxHeapSize|MaxRAMPercentage|UseContainerSupport"

预期看到 MaxHeapSize 约为 1.2Gi 量级,UseContainerSupport 为 true。若 MaxHeapSize 远大于 limits(如按宿主机内存算),说明容器感知未生效,检查 JDK 版本是否 ≥ 8u191。

7.4 告警规则示例

Prometheus 告警(指标名以实际 cAdvisor/exporter 为准):

   yamlgroups:
  - name: pod-memory
    rules:
      - alert: PodMemoryNearLimit
        expr: |
          sum(container_memory_working_set_bytes{namespace!="",container!=""})
          by (namespace, pod, container)
          /
          sum(container_spec_memory_limit_bytes{namespace!="",container!=""})
          by (namespace, pod, container)
          > 0.85
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.pod }} 内存使用超过 limits 的 85%"

      - alert: PodOOMKilled
        expr: |
          increase(container_oom_events_total{namespace!=""}[5m]) > 0
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.pod }} 发生 OOMKilled"

阈值说明:> 0.85 不是绝对标准,需结合业务基线调整。读写密集、有缓存的服务常态可能就高,要按服务分组设阈值,不要全局一刀切。

7.5 不同 GC 在容器里的取舍

JVM 参数里 GC 选择直接影响堆外开销和停顿,进而影响 limits 设置。

  • Parallel GC(-XX:+UseParallelGC):吞吐优先,老年代连续,堆外开销相对小,但 Full GC 停顿大,大堆下不适合对延迟敏感的服务。
  • G1 GC(-XX:+UseG1GC,JDK 9+ 默认):Region 化,可控停顿目标 -XX:MaxGCPauseMillis=200,堆外开销中等(Remembered Set、Card Table 等),2Gi~8Gi 堆的通用选择。
  • ZGC(-XX:+UseZGC,JDK 15+ 生产可用):低延迟,停顿 <1ms 量级,但堆外开销和元数据占用比 G1 大,需要更大安全垫;大堆(几十 GiB)且延迟敏感场景才值得。
  • Shenandoah(OpenJDK 部分发行版):低延迟,开销类似 ZGC,选用前确认你的 JDK 发行版支持。

容器内存预算影响:ZGC/Shenandoah 的堆外开销更明显,limits.memory 要多留安全垫(堆占比可降到 50%~55%),否则容易堆没满、GC 元数据把容器顶爆。G1 用 60%~70% 比较稳。

判断逻辑:选 GC 不是看哪个”更先进”,是看你的堆大小、延迟要求、容器内存预算三者匹配。2Gi 容器跑普通业务用 G1,别盲目上 ZGC。

7.6 堆 dump 与 dump 卷配置

HeapDumpOnOutOfMemoryError 产生的 dump 文件不能随便落。推荐挂独立持久卷或带保留策略的目录,避免占满节点磁盘触发磁盘压力 eviction。

   yaml          volumeMounts:
            - name: dump
              mountPath: /dump
      volumes:
        - name: dump
          persistentVolumeClaim:
            claimName: order-service-dump

PVC 示例(按实际 StorageClass 调整):

   yamlapiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: order-service-dump
  namespace: prod
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: standard

配置说明:

  • dump 文件大小约等于堆,2Gi 堆就给 PVC 留 10Gi 以上容纳多次 dump。
  • 配合 sidecar 或 CronJob 定时清理 dump(保留最近 N 个),否则迟早写满。
  • 不要把 dump 落到 emptyDir 又不清理,emptyDir 占的是节点可写层磁盘,写满会触发节点磁盘压力 eviction,影响同节点所有 Pod。

7.7 JVM 启动参数完整模板(容器场景)

把常用容器场景 JVM 参数整理成一个可直接套用的模板(通过 JAVA_TOOL_OPTIONS 注入,避免改镜像):

   -XX:MaxRAMPercentage=60
-XX:InitialRAMPercentage=60
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dump/heap.hprof
-XX:NativeMemoryTracking=summary
-XX:+ExitOnOutOfMemoryError
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

逐项说明:

  • InitialRAMPercentage=60:初始堆也按比例设,避免启动后堆立刻扩容造成抖动。
  • MaxGCPauseMillis=200:G1 停顿目标,按业务延迟要求调,不是越小越好(太小会增加 GC 频率)。
  • -Xlog:gc* 是 JDK 9+ 统一日志格式(JDK 8 用 -XX:+PrintGCDetails -Xloggc:/var/log/gc/gc.log),落文件并滚动,便于事后用 GCViewer/gceasy 分析。
  • filecount=5,filesize=20m:GC 日志滚动保留 5 个 20MiB 文件,避免无限增长。

注意:-Xlog:gc* 是 JDK 9+ 语法,JDK 8 不支持,会启动失败。跨版本镜像务必按 JDK 版本选 GC 日志参数,不要混用。

7.8 内存预算核算脚本

人工算内存预算容易漏项,用一个 Shell 脚本(支持 dry-run,只输出建议不修改)辅助核算。文件路径:scripts/mem-budget.sh

   bash#!/usr/bin/env bash
# 用途:根据 limits.memory 和 JVM 参数核算内存预算,给出是否安全垫不足的判断
# 用法:./mem-budget.sh <limits_memory_mib> <xmx_mib> <metaspace_mib> <direct_mib> <thread_count> <xss_kb>
# 示例:./mem-budget.sh 2048 1228 256 512 300 1024
# 注意:本脚本只做只读核算,不修改任何配置,可安全在生产外运行

set -euo pipefail

if [ "$#" -ne 6 ]; then
  echo "Usage: $0 <limits_mib> <xmx_mib> <metaspace_mib> <direct_mib> <thread_count> <xss_kb>" >&2
  exit 2
fi

LIMITS_MIB="$1"
XMX_MIB="$2"
META_MIB="$3"
DIRECT_MIB="$4"
THREAD_COUNT="$5"
XSS_KB="$6"

# 线程栈总量(MiB)
THREAD_STACK_MIB=$(( THREAD_COUNT * XSS_KB / 1024 ))
# Code Cache 预估(默认 240MiB)
CODE_CACHE_MIB=240
# GC 开销预估(G1,约堆的 12%)
GC_OVERHEAD_MIB=$(( XMX_MIB * 12 / 100 ))
# JVM 自身 + 安全垫预留
JVM_BASE_MIB=128

TOTAL=$(( XMX_MIB + META_MIB + DIRECT_MIB + THREAD_STACK_MIB + CODE_CACHE_MIB + GC_OVERHEAD_MIB + JVM_BASE_MIB ))
HEADROOM=$(( LIMITS_MIB - TOTAL ))
HEADROOM_RATIO=$(( HEADROOM * 100 / LIMITS_MIB ))

echo "limits.memory       : ${LIMITS_MIB} MiB"
echo "  - Xmx             : ${XMX_MIB} MiB"
echo "  - Metaspace       : ${META_MIB} MiB"
echo "  - Direct          : ${DIRECT_MIB} MiB"
echo "  - ThreadStack     : ${THREAD_STACK_MIB} MiB (${THREAD_COUNT} threads x ${XSS_KB} KB)"
echo "  - CodeCache       : ${CODE_CACHE_MIB} MiB"
echo "  - GC overhead     : ${GC_OVERHEAD_MIB} MiB"
echo "  - JVM base        : ${JVM_BASE_MIB} MiB"
echo "estimated total     : ${TOTAL} MiB"
echo "headroom            : ${HEADROOM} MiB (${HEADROOM_RATIO}% of limits)"

if [ "${HEADROOM_RATIO}" -lt 20 ]; then
  echo "[WARN] 安全垫不足 20%,堆外波动极易触发容器 OOM,建议降低 MaxRAMPercentage 或提高 limits"
  exit 1
elif [ "${HEADROOM_RATIO}" -lt 30 ]; then
  echo "[NOTE] 安全垫 20%~30%,可接受但偏紧,高峰需观察"
else
  echo "[OK] 安全垫 >= 30%"
fi

脚本说明:

  • set -euo pipefail:出错即停、未定义变量报错、管道失败传递,避免静默错误。
  • 所有算术用整数运算,避免浮点依赖。
  • 脚本只读输出,不写文件不连集群,可在任何环境安全运行(dry-run 思想)。
  • GC 开销按 G1 的 12% 估算,用 ZGC 应调高到 20%~25%,对应改脚本里的系数。
  • 阈值(20%/30%)不是绝对标准,按业务波动调整。脚本给出的是判断依据,不是金科玉律。

执行权限与运行:

   bashchmod +x scripts/mem-budget.sh
./mem-budget.sh 2048 1228 256 512 300 1024

预期输出:

   limits.memory       : 2048 MiB
  - Xmx             : 1228 MiB
  ...
estimated total     : 1638 MiB
headroom            : 410 MiB (20% of limits)
[NOTE] 安全垫 20%~30%,可接受但偏紧,高峰需观察

7.9 async-profiler 定位堆外/native 分配

当 NMT 不够精确(JNI/native 库分配可能不在 NMT 统计内)时,用 async-profiler 采样 native 内存分配,定位到具体调用栈。这是堆外泄漏排查的利器。

用法(在容器内,需要下载 async-profiler,按官方 release 取版本):

   bash# 采样 native 分配,生成火焰图(示例思路,路径按实际放置)
./profiler.sh --alloc --malloc -d 60 -f /tmp/malloc.html <pid>

参数说明:

  • --malloc:采样 malloc 调用,定位 native 堆外分配热点。
  • --alloc:采样 Java 对象分配。
  • -d 60:采样 60 秒。
  • -f /tmp/malloc.html:输出火焰图 HTML。

判断逻辑:火焰图里占比最大的栈顶就是分配热点。如果是某个 native 库(RocksDB、压缩、图像)的栈帧占大头,说明是 JNI native 内存泄漏,NMT 看不到,只有 profiler 能定位。

风险提醒:async-profiler 采样有性能开销(约 1%~3%),生产环境短时采样(30~60 秒)可接受,避开极端高峰,采样文件及时清理。

7.10 Netty 直接内存泄漏检测配置

Netty 场景的直接内存泄漏,开 leakDetector 是最快定位手段。在 JAVA_TOOL_OPTIONS 或启动参数加:

   -Dio.netty.leakDetection.level=ADVANCED

等级说明(按 Netty 官方):

  • DISABLED:关闭。
  • SIMPLE:默认,1% 采样率,开销极小。
  • ADVANCED:1% 采样率,但泄漏时打印完整访问栈,定位用。
  • PARANOID:100% 采样,开销大,仅排查期用。

判断逻辑:开了 ADVANCED 后,应用日志里若出现 LEAK: ByteBuf.release() was not called before it's garbage-collected,并附带分配栈,直接定位到未 release 的 ByteBuf 创建点。

注意:PARANOID 生产常开会显著拖慢,确认修复后回退到 SIMPLE。泄漏检测本身有开销,是排查期手段,不是常驻配置。

八、日志与指标观察方法

8.1 关键指标清单(以实际 exporter 指标为准)

指标含义关注点
container_memory_working_set_bytes工作集,kubectl top 来源与 limits 比值,OOM 直接相关
container_memory_rss匿名内存,近似进程 RSS堆外/native 内存参考
container_memory_cache文件缓存可回收部分,单独看别误判
container_memory_usage_bytes总用量(含 cache)看 cache 占比时用
container_spec_memory_limit_byteslimits.memory做比值的分母
container_spec_memory_request_bytesrequests.memory看调度和超卖
container_oom_events_totalOOM 事件计数告警直接用
kube_pod_container_status_last_terminated_reason上次终止原因含 OOMKilled 标签
kube_pod_container_status_restarts_total重启计数趋势监控
node_memory_MemAvailable_bytes节点可用内存节点驱逐判断

不同 exporter(cAdvisor、kube-state-metrics、node-exporter)版本下部分指标字段名可能略有差异,以实际环境 kubectl get --raw "/metrics" 或 Prometheus __name__ 查询为准。

8.2 日志观察

  • 容器标准输出:kubectl logs <pod> -n <ns> --previous(注意 --previous 看上次崩溃前的日志)。
  • 重点搜 OutOfMemoryErrorGC overhead limit exceededDirect buffer memoryunable to create new native thread
    • java.lang.OutOfMemoryError: Java heap space → 堆内。
    • OutOfMemoryError: Direct buffer memory → 直接内存。
    • OutOfMemoryError: Metaspace → Metaspace。
    • OutOfMemoryError: unable to create new native thread → 线程数/系统限制。
    • GC overhead limit exceeded → GC 回收跟不上分配,常伴随堆泄漏。
  • 节点 dmesg:通过 node-problem-detector 采集,搜 Out of memory: Killed process,能看到宿主 OOM Killer 杀的具体进程和得分。
   bashkubectl logs <pod> -n prod --previous | grep -i -E "OutOfMemory|GC overhead|Direct buffer|native thread"

8.3 观察方法

  • 趋势看 24~48 小时 working_set 折线,识别缓慢爬升(泄漏)还是尖峰(突发)。
  • 重启时间点与指标尖峰对齐,确认因果关系。
  • 多指标交叉:不要只看 working_set,结合 rsscache、重启次数、OOM 事件计数一起判断。例如 working_set 高但 cache 占大头,可能是文件缓存可回收,不一定是真泄漏。

8.4 Grafana 面板配置思路

OOM 排查大盘建议放四组面板,按实际数据源调整字段。

第一组:容器内存总览。

  • Panel 1:working_set 与 spec_memory_limit 双线,看是否长期贴着 limits 走。
    • PromQL(以实际 exporter 指标为准):   container_memory_working_set_bytes{namespace="$namespace",pod=~"$pod",container!=""}
      叠加 limits:   container_spec_memory_limit_bytes{namespace="$namespace",pod=~"$pod",container!=""}
  • Panel 2:working_set / limit 比率,阈值线 0.85,超过标红。

第二组:内存构成拆解。

  • Panel 3:堆叠图,把 rsscacheswap(如有)叠在一起,看 cache 占比,避免把文件缓存误判成泄漏。
    • 注意不同 cgroup 版本/exporter 下 rss/cache 字段口径可能不同,以实际指标为准。

第三组:重启与 OOM 事件。

  • Panel 4:increase(container_oom_events_total[5m]),看 OOM 突发。
  • Panel 5:kube_pod_container_status_restarts_total,看重启趋势。

第四组:节点内存。

  • Panel 6:node_memory_MemAvailable_bytes 与 node_memory_MemTotal_bytes,标 eviction-hard 阈值线。
  • Panel 7:节点上所有 Pod 的 working_set 汇总,与节点 Allocatable 对比,看超卖。

变量设置:$namespace$pod 用 Grafana 变量,namespace 查询 label_values(kube_pod_container_status_restarts_total, namespace)pod 联动 label_values(container_memory_working_set_bytes{namespace="$namespace"}, pod)。面板字段依赖具体数据源,需按实际环境调整 label 名。

8.5 告警阈值调优方法

阈值不能拍脑袋,要按业务基线动态调整。方法:

  1. 采集稳态基线:连续 7 天记录 working_set 的 P50/P95/P99 和峰值,以及对应的业务流量。
  2. 区分服务分组:缓存型服务(如 Redis-like、本地缓存大的)常态内存高,计算型服务波动小,分组设阈值。
  3. 设阈值:
    • PodMemoryNearLimit:稳态 P99 的 1.1 倍与 limits × 0.85 取较小者,for: 10m 避免瞬时尖峰误报。
    • PodOOMKilledincrease(container_oom_events_total[5m]) > 0,critical,必报。
    • PodRestartAnomalyincrease(kube_pod_container_status_restarts_total[1h]) > 2,warning,频繁重启告警。
  4. 节点级:node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1 对应 eviction-hard,提前告警。

阈值是起点不是终点,上线后按误报/漏报持续调整。宁可前期误报多一些,也不要漏报导致业务被 OOM 打了才知道。

8.6 多指标交叉判断速查

OOM 排查最忌讳凭单一指标下结论。下表给出常见组合判断:

working_set 趋势堆 usedMetaspace线程数Direct判断
缓慢单调爬升接近 Xmx正常正常正常堆泄漏
缓慢单调爬升远小于 Xmx正常正常增长直接内存泄漏
缓慢单调爬升远小于 Xmx接近上限正常正常Metaspace 泄漏
缓慢单调爬升远小于 Xmx正常增长正常线程泄漏
平稳高位远小于 Xmx正常正常正常安全垫不足/配置型
偶发尖峰尖峰时接近 Xmx正常正常正常突发大对象/大查询
平稳但节点 MemoryPressure节点超卖/驱逐

用这张表对照 5.5 采集到的数据,能快速收敛到根因分支。前提是 NMT 开启、jstat 能跑、指标采集正常,这三样是排查的基础设施,没准备好就不要急着排 OOM。

8.7 node-problem-detector 采集宿主 OOM

容器内看不到宿主 dmesg 的 OOM 记录,生产环境用 node-problem-detector 把宿主内核事件转成 Node 事件和告警。

部署方式(DaemonSet,按官方 manifest 调整):

   yamlapiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-problem-detector
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: node-problem-detector
  template:
    metadata:
      labels:
        app: node-problem-detector
    spec:
      hostNetwork: true
      containers:
        - name: node-problem-detector
          image: registry.k8s.io/node-problem-detector/node-problem-detector:v0.8.20
          command:
            - "/node-problem-detector"
            - "--logtostderr"
            - "--config.system-log-monitor=/config/kernel-monitor.json"
          volumeMounts:
            - name: log
              mountPath: /var/log
            - name: kmsg
              mountPath: /dev/kmsg
            - name: config
              mountPath: /config
              readOnly: true
      volumes:
        - name: log
          hostPath:
            path: /var/log
        - name: kmsg
          hostPath:
            path: /dev/kmsg
        - name: config
          configMap:
            name: node-problem-detector-config

配置说明:镜像 tag 按实际可用版本调整;kernel-monitor.json 里定义匹配 Out of memory: Killed process 等内核日志的规则,命中后上报为 Node Condition 或 Event。生产部署需结合集群版本和镜像仓库调整,以实际可用 manifest 为准。

看到 Node 事件里有 KernelDeadlock 或 OOMKilling 类条件,再结合节点上哪个 Pod 被杀,就能把宿主 OOM 和容器 OOM 区分清楚。

九、排查路径汇总

完整闭环一图流(文字版):

   现象:Pod 重启 / 137 / 重启计数告警
  │
  ├─ kubectl describe pod
  │     │
  │     ├─ Reason=OOMKilled ──────────────── 容器 OOM 分支
  │     │     │
  │     │     ├─ kubectl top --containers / Prom 趋势
  │     │     │     │
  │     │     │     ├─ 缓慢爬升 → 泄漏
  │     │     │     │     ├─ jstat/jcmd 看堆 → 堆满 → 抓 dump → MAT 分析 → 改代码
  │     │     │     │     └─ 堆没满 → NMT summary → 堆外哪类大 → 对症
  │     │     │     │
  │     │     │     ├─ 尖峰 → 突发流量/大查询 → 限流/分批/缓存优化
  │     │     │     │
  │     │     │     └─ 稳态突杀 → limits 安全垫不足
  │     │     │           └─ 核算内存预算 → 调 MaxRAMPercentage/Metaspace/Direct/limits
  │     │     │
  │     │     └─ 容器内 cat memory.max/current/events 确认 limits 生效
  │     │
  │     └─ Status=Evicted / node MemoryPressure ── 节点内存分支
  │           └─ describe node + free/meminfo
  │                 ├─ 节点超卖 → 扩容 / 调 requests / 给关键服务 Guaranteed QoS
  │                 └─ BestEffort 太多 → 补齐 requests/limits
  │
  ├─ 修复 → apply Deployment(灰度)
  ├─ 验证 → 重启计数不涨 / working_set 峰值 < 85% limits / oom_kill 不涨
  ├─ 回滚 → 回退镜像 tag 或 apply 旧 yaml
  └─ 复盘 → 沉淀告警阈值 + JVM 参数基线

9.1 快速分诊三连命令

刚收到告警、来不及细查时,用三条命令快速分诊,决定走哪条排查线:

   bash# 1. 看重启原因(OOMKilled / Evicted / 探针失败)
kubectl describe pod <pod> -n <ns> | grep -A5 -E "Last State|Reason|Exit Code"
   bash# 2. 看节点是否内存压力
kubectl describe node <node> | grep -A3 -E "MemoryPressure|Allocated resources"
   bash# 3. 看容器实时内存与 limits 对比
kubectl top pod <pod> -n <ns> --containers

分诊结论:

  • 命令 1 显示 OOMKilled + 命令 2 无 MemoryPressure → 容器 OOM,走第五节。
  • 命令 1 显示 Evicted 或命令 2 显示 MemoryPressure=True → 节点内存,走 5.2。
  • 命令 1 显示探针失败、无 OOM → 探针问题,先查 Liveness/Startup,别按 OOM 处理。
  • 命令 3 显示内存远低于 limits 却重启 → 可能是探针/异常退出,不是内存问题。

这三条命令 30 秒内出结果,能避免一上来就钻进 JVM 细节却走错方向。分诊对了,后面所有步骤才有效率。

十、常见误区与反模式

排查 OOM 时最容易踩的几个坑,单列出来对照自查。

  • 反模式 1:一看到 OOM 就加 limits.memory。加内存只是抬高天花板,泄漏类问题加多少都迟早撑爆,还会加剧节点超卖。先定位是泄漏还是配置,再决定动不动 limits。
  • 反模式 2:把 -Xmx 设成等于 limits.memory。堆外一点安全垫都不留,Metaspace/线程栈/直接内存/GC 一上来就顶满,堆还没满就被杀。这是最高频的配置错误。
  • 反模式 3:只看 kubectl top 一个数判断。working_set 高也可能是文件缓存可回收部分,要看 rss/cache 拆解和重启时间点对齐,多指标交叉。
  • 反模式 4:把节点驱逐当容器 OOM 治。节点内存不足时给单个 Pod 加 limits 无济于事,要去查节点超卖和 QoS 分布,给关键服务 Guaranteed 并扩容。
  • 反模式 5:在生产 Pod 直接抓全堆 dump。GC.heap_dump STW 且产大文件,高峰抓会拖垮服务,emptyDir 落盘可能写满节点磁盘。应在副本/影子/低峰抓。
  • 反模式 6:用 -XX:+ExitOnOutOfMemoryError 但不配 Liveness。进程退出后靠 K8s 重启,没问题;但如果同时把 Liveness failureThreshold 设成 1,GC 停顿几秒就被探针杀掉,会和 OOM 混在一起,难以分清是 OOM 还是探针误杀。failureThreshold 至少 5,给 GC 留余地。
  • 反模式 7:盲目换 ZGC 以为更省内存。ZGC 低延迟但堆外开销更大,2Gi 小容器上反而更容易因 GC 元数据吃满而 OOM。GC 选择要匹配堆大小和容器预算。
  • 反模式 8:抓了 dump 不分析就归因为”业务正常波动”。dump 一定要用 MAT 看 Leak Suspects 和 Dominator Tree,否则等于没抓。
  • 反模式 9:改完不验证就关工。必须连续观察 24~48h 重启计数和 oom_kill 不增长才算闭环,不要看重启一次没发生就判定修复。
  • 反模式 10:把 requests 设得很低、limits 设得很高。Burstable QoS 在节点内存紧张时容易被驱逐,且调度时按 requests 算,实际用得多会造成节点超卖。关键服务用 requests == limits

10.11 排查工具链与镜像准备

排查 OOM 依赖一组工具,临时找工具会耽误黄金时间。建议在基础镜像里预装,或准备一个诊断 sidecar 镜像:

  • JDK 自带:jcmdjstatjpsjmapjstack(需 JDK 完整镜像,JRE 镜像没有)。
  • Arthas:在线诊断,开销小,适合生产实时看。
  • async-profiler:native 采样,定位堆外热点。
  • MAT:离线分析 dump,本地运行。

镜像选择影响排查能力:

  • 用 JRE slim 镜像省空间,但缺 jcmd 等工具,OOM 时无法现场诊断。建议生产用 JDK 镜像或预装 Arthas 的镜像,体积换可诊断性。
  • distroless 镜像连 sh 都没有,kubectl exec 进不去,只能靠 sidecar 或 kubectl debug 临时注入调试容器。生产用 distroless 要配套 kubectl debug 流程。

kubectl debug 临时注入诊断容器(不修改原 Pod,按实际集群版本支持情况使用):

   bash# 给目标 Pod 注入一个带诊断工具的 ephemeral container
kubectl debug -it <pod> -n prod --image=registry.example.com/diag-tools:latest --target=order-app

注入后在 ephemeral container 里用 nsenter 或直接访问 /proc/<pid> 诊断目标容器进程。这种方式不重启业务 Pod,适合生产现场排查。不同 K8s 版本对 ephemeral container 支持程度不同,使用前确认集群版本。

10.12 容量规划与超卖控制

OOM 很多时候根因是节点超卖。容量规划建议:

  • 节点 Allocatable 内存 = Capacity – 系统预留(kube-reserved + system-reserved + eviction-threshold)。
  • 调度时按 requests.memory 之和 ≤ Allocatable 判断,不超这一层。
  • 实际用量(limits 或真实 working_set)可能远超 requests,造成运行时超卖。控制 limits/requests 比值,关键服务比值接近 1(Guaranteed),避免 Burstable 大量超卖。
  • 监控节点 working_set 总和与 Allocatable 的比值,超过 80% 预警,提前扩容而非等驱逐。

节点预留配置(kubelet 参数,按节点角色调整):

   --kube-reserved=memory=1Gi
--system-reserved=memory=1Gi
--eviction-hard=memory.available<100Mi

这些参数影响节点可调度量,调整需重启 kubelet,生产变更窗口操作,并在一个节点验证后再批量推。

十一、风险提醒

  • kubectl exec 进入生产容器执行诊断命令时,jcmd GC.heap_dumpjstack -Farthas dashboard 都有 STW 或额外开销,避开业务高峰,优先在副本或影子实例上做。
  • GC.heap_dump 产生的文件与堆等大,落到 emptyDir 会占节点磁盘,可能间接导致节点磁盘压力 eviction,务必配置定时清理或挂独立持久卷并设保留策略。
  • 修改 resources.limits/requests 会触发 Pod 重建,相当于滚动更新。务必走灰度,配合 maxUnavailable/maxSurge 和 PDB 控制爆炸半径。
  • -XX:+ExitOnOutOfMemoryError 让进程快速退出,但若你的业务对”半死不活”有自愈逻辑(如内部重连),需评估是否冲突。该参数部分 JDK 版本不支持,使用前确认。
  • NativeMemoryTracking 有约 5% 性能开销,常开需评估;建议作为排查期临时开启,排查完关闭。
  • 节点上 dmesgfreecat /proc/meminfo 需要主机权限,不要在生产节点上随意执行其他破坏性命令,限定为只读观察。
  • 把 -Xmx 设成等于 limits.memory 是最常见的踩坑配置,务必留安全垫。
  • 堆外泄漏加 -XX:MaxDirectMemorySize 兜底只是止血,真正要修的是未释放的 ByteBuf/连接,否则只是把 OOM 时间推迟。
  • 调 threads-max/ulimit -u 解决线程泄漏是把崩溃推迟,治标不治本,且可能掩盖真正的无界线程池问题,必须配合代码层修复。
  • kubectl debug 注入 ephemeral container 不重启业务 Pod,但仍会占用节点资源,注入后及时退出清理,避免遗留调试容器占内存。
  • 节点 kubelet 预留参数(kube-reserved/system-reserved/eviction-hard)调整需重启 kubelet,影响节点可调度量,生产务必单节点验证后批量推,留回滚(恢复旧参数)窗口。
  • ResourceQuota 调整影响整个命名空间可分配量,调大需确认集群总容量,调小可能导致已有 Pod 无法更新,变更前评估影响范围。

十二、验证方式

修复后按以下顺序验证,全部通过才算闭环:

  1. 配置生效验证:容器内 cat /sys/fs/cgroup/memory.max 等于新 limits;jcmd <pid> VM.flags 中 MaxHeapSize 符合预期。
  2. 压力验证:用与生产相近的流量或压测工具(如 wrk、JMeter)打一段高峰,观察 working_set 峰值是否低于 limits 的 85%。
  3. 稳定性验证:连续观察 24~48 小时,RESTARTS 不增长,container_oom_events_total 不增长。
  4. 告警验证:人为构造接近 limits 的内存压力(如压测),确认 PodMemoryNearLimit 告警触发且能被收敛处理。
  5. dump 可用性验证:人为触发一次堆 OOM(测试环境),确认 /dump/heap.hprof 生成且可被 MAT 打开,证明 dump 链路通。
   bash# 稳定性验证脚本示例思路(伪代码,按实际监控平台调整)
# 每 5 分钟记录一次重启次数与 oom_kill,连续 48 小时
# while true; do
#   ts=$(date +%s)
#   restarts=$(kubectl get pod -n prod <pod> -o jsonpath='{.status.containerStatuses[0].restartCount}')
#   echo "$ts $restarts" >> /tmp/restart_trace.log
#   sleep 300
# done

12.6 验证 Checklist

修复完成后逐项打勾,全部通过才算闭环:

  • [ ] 容器内 cat /sys/fs/cgroup/memory.max 等于新 limits。
  • [ ] jcmd <pid> VM.flags 中 MaxHeapSizeMaxMetaspaceSizeMaxDirectMemorySize 符合预期。
  • [ ] UseContainerSupport=trueMaxHeapSize 按 cgroup 上限算(非宿主机内存)。
  • [ ] 压测或高峰流量下 working_set 峰值低于 limits 的 85%。
  • [ ] 连续 24~48h RESTARTS 不增长。
  • [ ] 连续 24~48h container_oom_events_total 不增长。
  • [ ] memory.peak 低于 memory.max(没顶到上限)。
  • [ ] 告警链路通:人为构造接近 limits 的内存压力,PodMemoryNearLimit 能触发。
  • [ ] dump 链路通:测试环境触发堆 OOM,/dump/heap.hprof 生成且 MAT 可打开。
  • [ ] 回滚预案就绪:旧镜像 tag 和旧 yaml 已保存,rollout undo 验证过可执行。
  • [ ] 监控大盘四组面板(容器内存总览/内存构成/重启OOM/节点内存)数据正常。
  • [ ] JVM 参数基线已更新到团队规范文档。

12.7 不同场景的验证侧重

不同根因修复后,验证侧重点不同:

  • 配置型 OOM:重点验证安全垫,压测到峰值看 working_set 是否稳在 85% 以下,memory.peak 是否远离 memory.max
  • 堆泄漏:重点验证长周期(48h+)O 区是否能回落,FGC 频率是否恢复正常,不靠加内存。
  • 直接内存泄漏:重点验证 Netty leakDetector 日志不再出现 LEAKDirect 类 Committed 稳定不增。
  • 线程泄漏:重点验证线程数稳定在预期范围,不再随时间/流量增长。
  • 突发大对象:重点验证高峰场景下 working_set 不再出现陡峭尖峰,限流/分页生效。
  • 节点超卖:重点验证节点 MemAvailable 长期高于 eviction-hard 阈值,MemoryPressure 不再出现。

十三、回滚方案

任何修改都要有可回滚的路径。建议两种回滚方式都备好:

方式一:镜像 tag 回滚(推荐,最快)。

   bash# 把 Deployment 镜像回退到上一个稳定 tag
kubectl set image deployment/order-service order-app=registry.example.com/order-service:1.2.2 -n prod

或:

   bashkubectl rollout undo deployment/order-service -n prod

方式二:yaml 回滚(改了 resources/env 时用)。

   bash# 保留上一版 yaml,直接 apply 旧版本
kubectl apply -f deploy/order-service.yaml.prev

回滚注意:

  • rollout undo 默认回退到上一个 revision,若改了多次需要 --to-revision=N 指定。
  • 回滚同样是滚动更新,仍受 PDB 和 maxUnavailable 约束,不会瞬间全切。
  • 回滚后同样要验证重启计数和内存指标,确认问题确实回到可接受状态。
  • 如果是泄漏类问题且回滚到旧版本仍有泄漏,说明泄漏在更早的版本就存在,需要继续往回追溯定位引入版本。

13.5 滚动更新策略与爆炸半径控制

修改 resources/env 触发滚动更新,要控制爆炸半径:

   yamlspec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  • maxUnavailable: 0:滚动期间不减少可用副本,保证 SLA。
  • maxSurge: 1:最多多起 1 个新副本,控制资源占用峰值,避免新副本把节点内存打爆。
  • 配合 PDB(PodDisruptionBudget)minAvailable 保证主动驱逐时也留足副本。
   yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: order-service-pdb
  namespace: prod
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: order-service

灰度顺序建议:先改 1 个副本观察一个完整业务周期(含高峰),确认无 OOM 再全量。可用 kubectl patch 临时把某个副本的镜像/参数指向新版本做金丝雀,或用 ArgoCD 的渐进式发布。

十四、生产环境注意事项

  • 灰度发布:内存相关变更先在 1 个副本验证,观察一个完整业务周期(含高峰)再全量。配合 maxSurge: 1, maxUnavailable: 0 滚动策略。
  • 执行窗口:JVM 参数和 limits 变更尽量选低峰,滚动更新期间会有 Pod 重建,可能短时降低可用副本数。
  • 影响范围评估:改 limits 可能影响调度(节点可分配内存变化),提前确认目标节点有足够 Allocatable 内存,避免 Pod 变成 Pending。
  • 备份与可追溯:每次变更保留旧 yaml 和镜像 tag,便于 rollout undo。变更走 GitOps(如 ArgoCD)留审计记录。
  • 权限控制:生产 kubectl apply 通过 CI/CD 或受控运维通道执行,避免个人直接操作集群;关键命名空间用 RBAC 限制写权限。
  • 监控先行:上线任何内存变更前,确保 container_memory_working_set_bytescontainer_oom_events_total、重启计数这三个指标有告警和大盘,否则改完没有观测等于盲改。
  • 不要在生产 Pod 上抓全堆 dump:优先在预发或影子流量副本抓;如必须生产抓,选低峰、单副本、抓完立即清理 dump 文件。
  • 安全垫规范团队统一:把”堆 = limits × 60%~70%、Metaspace/Direct 显式兜底、requests == limits”写进服务基线,新服务默认套用,避免逐个踩坑。
  • 容器内存与宿主内核版本相关:cgroup v2 下部分内存统计口径与 v1 不同,迁移节点时(如从 v1 节点迁到 v2 节点)要重新观察指标基线,不要假设完全一致。
  • JVM 版本统一:团队内统一 JDK 小版本(如统一 8u3xx 或 17.0.x),避免不同小版本的容器感知和默认参数差异导致行为不一致。

14.1 JVM 参数团队基线规范

把一次性排查沉淀成可复用的基线,新服务默认套用,避免逐个踩坑。推荐基线(按服务类型分档):

服务档位limits.memoryMaxRAMPercentageMaxMetaspaceSizeMaxDirectMemorySizeGC说明
轻量服务512Mi~1Gi60128m128mG1纯计算、无堆外
常规服务2Gi~4Gi60256m256mG1多数业务
IO 密集/Netty2Gi~4Gi55256m512mG1直接内存大
大堆低延迟8Gi+50256m512mZGC延迟敏感大堆

通用必备参数(所有档位):

  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump/heap.hprof
  • -XX:+ExitOnOutOfMemoryError(确认 JDK 版本支持)
  • -Xlog:gc*(JDK 9+)或 -XX:+PrintGCDetails(JDK 8)
  • -XX:NativeMemoryTracking=summary(关键服务常开,普通服务排查期开)

基线是起点,每个服务上线后根据真实指标微调,但安全垫(堆占比 ≤ 70%、堆外显式兜底、requests == limits)是硬约束,不能破。

14.2 监控大盘与告警就绪标准

任何内存相关变更上线前,确认以下观测就绪,否则等于盲改:

  • container_memory_working_set_bytes 大盘有折线,能看 24h+ 趋势。
  • container_spec_memory_limit_bytes 在同一图上叠加,肉眼能看是否贴顶。
  • container_oom_events_total 有告警(increase > 0 即 critical)。
  • kube_pod_container_status_restarts_total 有告警(increase[1h] > 2)。
  • 节点 node_memory_MemAvailable_bytes 有大盘和告警(低于阈值)。
  • node-problem-detector 部署,宿主 OOM 能转成 Node 事件。

这六项是 OOM 可观测的底线,缺一不可。没有可观测性就改内存配置,出了问题无法定位也无法验证修复是否生效。

14.3 团队协作与变更流程

OOM 排查不是运维一个人的事,需要研发和运维协作:

  • 运维负责:监控告警、容器/cgroup 层面排查、limits 与 JVM 参数基线、灰度与回滚。
  • 研发负责:dump 分析、代码层泄漏修复、业务侧大查询/限流改造、线程池与并发控制。
  • 变更流程:所有 limits/JVM 参数变更走 GitOps(yaml 提 PR → review → CI 校验 → ArgoCD 同步),留审计;生产 kubectl apply 不允许个人直接执行,通过受控通道。
  • 复盘机制:每次生产 OOM 事件出复盘文档,记录根因/修复/验证/影响/改进项,归档到团队知识库,避免同类问题重复发生。

十五、总结

K8s Pod OOM 排查的核心是把三件事分清楚:是容器 OOM 还是节点驱逐、是堆内还是堆外、是泄漏还是配置不足。这三组判断做对,后面的修复方向才不会跑偏。

实操上记住几条主线:

  • 看到 137 先 kubectl describe pod 看 Reason,不要直接断言 OOM。
  • 容器内 cat /sys/fs/cgroup/memory.max 和 memory.events 是内核视角的权威证据,比 JVM 自算更可信。
  • 堆没满却 OOM,去查堆外:Metaspace、直接内存、线程栈、GC 开销,用 jcmd VM.native_memory summary 拆解。
  • -Xmx 永远不要等于 limits.memory,堆按 limits 的 60%~70% 设,堆外各部分显式兜底,留 25%~30% 安全垫。
  • requests == limits 走 Guaranteed QoS,既防节点驱逐又防调度后被赶走。
  • 修复走灰度,验证看重启计数和 working_set 峰值,回滚靠镜像 tag 或旧 yaml。
  • 沉淀告警阈值和 JVM 参数基线,把一次性排查变成团队可复用的规范。

OOM 不是靠加内存解决的,是靠定位到具体哪类内存、为什么涨、谁来兜底来解决的。把这套闭环跑顺,后续遇到任何 Java Pod OOM 都能在一个可控的时间内收敛到根因并给出有依据的修复方案。

最后给一组可直接落地的行动清单,按优先级执行:

  1. 把所有 Java 服务的 -Xmx 与 limits.memory 关系排查一遍,凡 -Xmx >= limits.memory × 0.8 的,立即按 60%~70% 重设,留安全垫。
  2. 关键服务默认开 -XX:NativeMemoryTracking=summary-XX:+HeapDumpOnOutOfMemoryError-XX:MaxDirectMemorySize-XX:MaxMetaspaceSize
  3. 关键服务统一 requests == limits 走 Guaranteed QoS。
  4. 部署 node-problem-detector,把宿主 OOM 事件接进监控。
  5. 建四组监控面板 + 三条告警(near limit / oom kill / restart anomaly),阈值按 7 天基线调。
  6. 把”内存预算核算脚本”和”诊断快照脚本”纳入运维工具箱,OOM 时一键采集现场。
  7. 团队基线文档化,新服务默认套用分档 JVM 参数规范。

做完这七步,Java Pod OOM 就从”偶发事故”变成”可控的、有预案的、能快速闭环的常规问题”。

K8s Pod OOM 排查:从 limits 设置到 JVM 调优插图

本文链接:https://www.yunweipai.com/49304.html

网友评论comments

发表回复

您的电子邮箱地址不会被公开。

暂无评论

Copyright © 2012-2022 YUNWEIPAI.COM - 运维派 京ICP备16064699号-6
扫二维码
扫二维码
返回顶部