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 出现
OOMKilled、137退出码、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里的rss、cache、swap等字段 - 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(字段如anon、file、slab等,对应 v1 的 rss/cache) - OOM 事件计数:
/sys/fs/cgroup/memory.events(字段oom、oom_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 |
sock | socket 缓冲 | sock |
shmem | 共享内存 | mapped_file/shmem |
pgfault/pgmajfault | 页错误 | 同名 |
oom/oom_kill(在 memory.events) | OOM 事件计数 | oom_control 里的 under_oom/oom_kill |
排查容器 OOM 时,anon 持续增长最可疑(对应进程实际占用的堆+堆外 native),file 增长一般是缓存可回收。不同内核版本字段集合可能略有差异,重点看 memory.current、memory.peak、memory.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 参数:
- 确认是容器 OOM 还是节点驱逐:
kubectl describe pod看 Reason 和 Events,kubectl describe node看是否MemoryPressure。 - 确认是哪个容器:Pod 可能多容器,定位到具体
container的Last State。 - 确认内存增长曲线:看 Prometheus
container_memory_working_set_bytes近 24 小时趋势,是缓慢爬升(泄漏)还是尖峰(突发流量/大查询)还是稳态后突杀(堆外或 limits 留得太小)。 - 确认是堆内还是堆外:OOM 时堆是否真的满了,决定走堆排查还是 native memory 排查。
- 定位根因:根据上面四步收敛到具体原因(limits 配错、堆泄漏、直接内存泄漏、Metaspace、线程爆炸、GC 失败、节点压力)。
- 修复:按根因给对应方案,而不是无脑加内存。
- 验证:复现压力、观察指标、确认重启次数不再增长。
- 回滚预案:保留旧配置和镜像,灰度可退。
排查路径示意(脑内决策树):
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 QoS | kubectl get pod -o jsonpath=...qosClass | 判断节点驱逐优先级 |
| JDK 版本 | java -version(容器) | 判断可用 JVM 参数 |
| NMT 状态 | jcmd <pid> VM.native_memory summary | 堆外排查前提 |
| 监控数据 | Prometheus 查询 | 趋势分析 |
| dump 卷 | kubectl get pvc | dump 落盘位置 |
信息齐全再动手,能避免排查到一半发现”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(如Xmx1.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 Heap、Class(Metaspace)、Thread、Code、GC、Internal、Direct 等。重点关注哪一类 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 restarted,order-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,远小于MaxHeapSize1.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%”写入团队基线,配置告警
PodMemoryNearLimit85%。
这条时间线说明:从告警到定位根因约 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.lastState | Reason | 终止原因 | OOMKilled=容器OOM;空+Evicted=节点驱逐 |
| Containers.lastState | Exit Code | 退出码 | 137=SIGKILL;143=SIGTERM(正常停止) |
| Containers.lastState | Finished | 终止时间 | 与监控尖峰对齐确认因果关系 |
| Containers.restartCount | 计数 | 重启次数 | 趋势增长=反复 OOM |
| Containers.state | running/waiting | 当前状态 | CrashLoopBackOff=反复崩溃退避 |
| Events | Back-off | 退避 | 配合 restartCount 判断崩溃频率 |
jstat -gcutil 列含义对照:
| 列 | 含义 | 关注 |
|---|---|---|
| S0/S1 | Survivor 0/1 使用率 | 正常交替 |
| E | Eden 使用率 | YGC 后应回落 |
| O | 老年代使用率 | 长期 90%+ 降不下=泄漏 |
| M | Metaspace 使用率 | 接近 MaxMetaspaceSize=元数据泄漏 |
| YGC/YGCT | YGC 次数/耗时 | 频繁 YGC=新生代太小或分配过快 |
| FGC/FGCT | Full GC 次数/耗时 | 频繁 FGC 且 O 降不下=老年代驻留 |
| GCT | GC 总耗时 | 与运行时间比看 GC 占比 |
jcmd <pid> VM.native_memory summary 分类对照:
| 分类 | 对应内存 | 增长异常时的方向 |
|---|---|---|
| Java Heap | 堆 | 查堆泄漏/dump |
| Class | Metaspace | 查动态类生成 |
| Thread | 线程栈 | 查线程泄漏 |
| Code | Code Cache | 一般稳定,看是否频繁加载新类 |
| GC | GC 数据结构 | 大堆下 G1 RS 占用,评估换 GC |
| Internal | JVM 内部/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) | 主要对象存储 |
| Metaspace | 256m | 类元数据兜底 |
| 直接内存 | 512m | NIO/Netty 堆外 |
| Code Cache | 默认(约 240m) | JIT 代码,一般不动 |
| 线程栈 | 线程数 × 1m | 默认 -Xss1m,200 线程约 200m |
| GC 开销 | 约 150m | G1 数据结构和线程 |
| 安全垫 | 剩余部分 | 兜底波动,建议留 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_bytes | limits.memory | 做比值的分母 |
container_spec_memory_request_bytes | requests.memory | 看调度和超卖 |
container_oom_events_total | OOM 事件计数 | 告警直接用 |
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看上次崩溃前的日志)。 - 重点搜
OutOfMemoryError、GC overhead limit exceeded、Direct buffer memory、unable 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,结合rss、cache、重启次数、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!=""}
- PromQL(以实际 exporter 指标为准):
- Panel 2:
working_set / limit比率,阈值线 0.85,超过标红。
第二组:内存构成拆解。
- Panel 3:堆叠图,把
rss、cache、swap(如有)叠在一起,看 cache 占比,避免把文件缓存误判成泄漏。- 注意不同 cgroup 版本/exporter 下
rss/cache字段口径可能不同,以实际指标为准。
- 注意不同 cgroup 版本/exporter 下
第三组:重启与 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 告警阈值调优方法
阈值不能拍脑袋,要按业务基线动态调整。方法:
- 采集稳态基线:连续 7 天记录
working_set的 P50/P95/P99 和峰值,以及对应的业务流量。 - 区分服务分组:缓存型服务(如 Redis-like、本地缓存大的)常态内存高,计算型服务波动小,分组设阈值。
- 设阈值:
PodMemoryNearLimit:稳态 P99 的 1.1 倍与 limits × 0.85 取较小者,for: 10m避免瞬时尖峰误报。PodOOMKilled:increase(container_oom_events_total[5m]) > 0,critical,必报。PodRestartAnomaly:increase(kube_pod_container_status_restarts_total[1h]) > 2,warning,频繁重启告警。
- 节点级:
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1对应eviction-hard,提前告警。
阈值是起点不是终点,上线后按误报/漏报持续调整。宁可前期误报多一些,也不要漏报导致业务被 OOM 打了才知道。
8.6 多指标交叉判断速查
OOM 排查最忌讳凭单一指标下结论。下表给出常见组合判断:
| working_set 趋势 | 堆 used | Metaspace | 线程数 | 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_dumpSTW 且产大文件,高峰抓会拖垮服务,emptyDir 落盘可能写满节点磁盘。应在副本/影子/低峰抓。 - 反模式 6:用
-XX:+ExitOnOutOfMemoryError但不配 Liveness。进程退出后靠 K8s 重启,没问题;但如果同时把 LivenessfailureThreshold设成 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 自带:
jcmd、jstat、jps、jmap、jstack(需 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_dump、jstack -F、arthas dashboard都有 STW 或额外开销,避开业务高峰,优先在副本或影子实例上做。GC.heap_dump产生的文件与堆等大,落到 emptyDir 会占节点磁盘,可能间接导致节点磁盘压力 eviction,务必配置定时清理或挂独立持久卷并设保留策略。- 修改
resources.limits/requests会触发 Pod 重建,相当于滚动更新。务必走灰度,配合maxUnavailable/maxSurge和 PDB 控制爆炸半径。 -XX:+ExitOnOutOfMemoryError让进程快速退出,但若你的业务对”半死不活”有自愈逻辑(如内部重连),需评估是否冲突。该参数部分 JDK 版本不支持,使用前确认。NativeMemoryTracking有约 5% 性能开销,常开需评估;建议作为排查期临时开启,排查完关闭。- 节点上
dmesg、free、cat /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 无法更新,变更前评估影响范围。
十二、验证方式
修复后按以下顺序验证,全部通过才算闭环:
- 配置生效验证:容器内
cat /sys/fs/cgroup/memory.max等于新 limits;jcmd <pid> VM.flags中MaxHeapSize符合预期。 - 压力验证:用与生产相近的流量或压测工具(如 wrk、JMeter)打一段高峰,观察
working_set峰值是否低于 limits 的 85%。 - 稳定性验证:连续观察 24~48 小时,
RESTARTS不增长,container_oom_events_total不增长。 - 告警验证:人为构造接近 limits 的内存压力(如压测),确认
PodMemoryNearLimit告警触发且能被收敛处理。 - 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中MaxHeapSize、MaxMetaspaceSize、MaxDirectMemorySize符合预期。 - [ ]
UseContainerSupport=true,MaxHeapSize按 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 日志不再出现
LEAK,Direct类 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_bytes、container_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.memory | MaxRAMPercentage | MaxMetaspaceSize | MaxDirectMemorySize | GC | 说明 |
|---|---|---|---|---|---|---|
| 轻量服务 | 512Mi~1Gi | 60 | 128m | 128m | G1 | 纯计算、无堆外 |
| 常规服务 | 2Gi~4Gi | 60 | 256m | 256m | G1 | 多数业务 |
| IO 密集/Netty | 2Gi~4Gi | 55 | 256m | 512m | G1 | 直接内存大 |
| 大堆低延迟 | 8Gi+ | 50 | 256m | 512m | ZGC | 延迟敏感大堆 |
通用必备参数(所有档位):
-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 都能在一个可控的时间内收敛到根因并给出有依据的修复方案。
最后给一组可直接落地的行动清单,按优先级执行:
- 把所有 Java 服务的
-Xmx与limits.memory关系排查一遍,凡-Xmx >= limits.memory × 0.8的,立即按 60%~70% 重设,留安全垫。 - 关键服务默认开
-XX:NativeMemoryTracking=summary、-XX:+HeapDumpOnOutOfMemoryError、-XX:MaxDirectMemorySize、-XX:MaxMetaspaceSize。 - 关键服务统一
requests == limits走 Guaranteed QoS。 - 部署 node-problem-detector,把宿主 OOM 事件接进监控。
- 建四组监控面板 + 三条告警(near limit / oom kill / restart anomaly),阈值按 7 天基线调。
- 把”内存预算核算脚本”和”诊断快照脚本”纳入运维工具箱,OOM 时一键采集现场。
- 团队基线文档化,新服务默认套用分档 JVM 参数规范。
做完这七步,Java Pod OOM 就从”偶发事故”变成”可控的、有预案的、能快速闭环的常规问题”。

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





网友评论comments