1堆还空着,Pod 为什么先被内核杀掉
大促前压测,订单服务 JDK 17,Deployment 配置 memory.limit=512Mi,镜像未显式治理 JVM 参数。
QPS 刚过生产 1.2 倍,Pod 三分钟内重启四次。值班同学拉日志:没有 java.lang.OutOfMemoryError,
只有 access log 被截断。kubectl describe 写着 Reason: OOMKilled、Exit Code: 137。
Grafana 上堆使用率峰值 58%,container_memory_working_set_bytes 却在 limit 处打平。
开发说「堆才用一半」;运维说「limit 已经给到 512Mi」;SRE 看到 working set 贴边横跳。 三方各有一套判据,根因却是同一件事:cgroup 杀的是进程 RSS,不是 Heap。 JVM 按 512Mi 的约 75% 自动设堆约 384Mi,元空间加载 Spring Boot 3 后稳定在百兆级, 再叠加线程栈、Netty DirectBuffer 与 G1 native,RSS 合计越界——内核 SIGKILL,JVM 来不及抛 Java 异常。
这条复合场景在容器化迁移期反复出现。物理机时代,-Xmx 与机器内存之间常有大量余量,
Metaspace 缓慢增长数月才可能触顶;容器化后 limit 是硬合同,多租户节点上余量被 Quota 与 HPA 进一步压缩,
参数错配从「性能隐患」升级为「分钟级重启事故」。T08 讲清了堆与 GC;T09 给了工具链;
本篇负责把测量单位对齐——否则堆健康的错觉会系统性误导排障。
为什么这类问题往往拖到「架构治理」阶段才集中爆发?因为单服务试错时,大家习惯用加 limit 止血; 服务数量上百之后,每个微服务各自试参,运维看板上的 137 呈散弹状,难以归纳成统一规范。 团队若缺少 JAVA_OPTS 模板与 limit 配比公式,值班同学只能在「加堆」「加机器」「重启看看」之间轮转, 复盘文档写满现象却写不清账本。本篇要把账本变成可评审的合同,而不是个人经验。
现场还会叠加三种变体。其一:从虚拟机照搬 -Xmx4g,K8s limit 也写 4Gi,堆曲线「健康」,
但 Metaspace unlimited 与默认 Direct 上限把 RSS 顶穿。其二:HPA 扩出的新 Pod 在冷启动数十秒到两分钟内 137,
老 Pod 稳态反而正常——启动期类加载与 CodeCache 陡增必须纳入预算。其三:极老 JDK 8 镜像在 cgroup v2
节点「看见整台机器」,一启动即被杀(历史对照,供基线镜像审计)。
读者画像是负责 Java 上 K8s 的架构师、SRE 与高级开发:需要把参数写进 Helm values,并能向运维解释 「为什么堆 60% 仍可能被杀」。若需回顾内存区域划分,请先读 T08。文中数字为数量级经验与合成案例, 不是某一家公司的事故复述;实际上线必须以本服务在相同 cgroup limit 下的压测为准。
| 观测信号 | 更可能指向 | 首要动作 |
|---|---|---|
| Exit 137,无 Java OOM 日志 | cgroup RSS 超 limit | 查 working set/limit,核对 MaxRAM% 与非堆 |
OutOfMemoryError: Java heap space | 堆不足或泄漏 | HeapDump;区分泄漏 vs 堆过小 |
OutOfMemoryError: Metaspace | 类元数据过多或 ClassLoader 泄漏 | jcmd Metaspace;查动态代理数量 |
| 堆 40% 但 working set 99% | 非堆或 native 占主导 | 短期开启 NMT 抽样 |
| 仅新 Pod 137,老 Pod 正常 | 启动峰值 / 预热不足 | 拉长 Startup Probe;按冷启动峰值定 limit |
「堆没满就不可能是内存问题」——cgroup 杀的是 RSS。「limit 设成和 -Xmx 一样就行」——元空间、栈、堆外与 JVM native 都要余量。 「UseContainerSupport 默认开就万事大吉」——百分比与非堆 cap 仍需显式治理。「压测在裸机 JAR 跑通即可」——无 cgroup 的压测会系统性高估可用堆。 「和物理机用同一套 -Xmx 就行」——容器 limit 往往更小,且非堆占比更高。
分享结构如下:第二章画三层边界地图;第三章拆 cgroup 感知与版本差异;第四、五章分别讲堆百分比与元空间/堆外账本; 第六章给 OOM 分科排障;第七章划适合边界;第八章给验证案例与 SOP;第九章落检查表;第十章用决策矩阵收束。 机制描述属机制共识,余量公式属作者经验,场景数字属合成数量级——上线前必须用本机 workload 在真实 limit 下复测。 读完应能独立完成:画出 RSS 账本、算出百分比起点、用命令区分 137 与 Java OOM、并在评审会上辩护一版规格。
2概念地图:K8s limit、cgroup 与 JVM RSS 如何叠在一起
容器化 JVM 必须同时看清三层边界:Pod 规格里的 resources.limits.memory、内核 memory 控制器的硬顶、
以及 JVM 进程内部的堆/非堆分区。三层数值可以不同,但最终硬顶由最外层 cgroup limit 决定。
堆只是 RSS 的一块;监控只盯 HeapMemoryUsage,等于在用局部坐标导航全局悬崖。
这一章的目标不是背参数名,而是让你能在白板上画出三层,并指出事故发生在哪一层的越界。
若三层讲不清,后面的百分比与检查表都会变成填空游戏:表面上每项都勾了,出事时仍无法定位该改哪一层。
把这张地图当成团队共同语言:开发说「堆」,SRE 说「working set」,平台说「memory.max」, 三者必须能互相换算。评审会上若有人只用其中一种语言,就很容易做出单向补丁—— 例如只加堆、只加 limit、或只加副本。地图的价值,是强迫所有人先指出「哪一层越界」。
requests.memory 只影响调度与 QoS,不参与 JVM 堆推算。百分比参数永远相对 limit。
若只设 request 不设 limit,JVM 可能按更大可见内存推算堆——生产应对 Java 工作负载始终设 memory limit。
Guaranteed QoS(requests=limits)在节点 MemoryPressure 时更晚被驱逐,但不免疫本容器 137;
Burstable 更要在 limit 上留足非堆,否则「有时 137、有时 Evicted」会叠成复合故障。
Prometheus 的 container_memory_working_set_bytes 与 cgroup 的 memory.current 口径接近,
是判断濒临 OOMKilled 的核心指标。JMX 的 HeapMemoryUsage 仅覆盖堆——二者差值可粗估非堆,
但拿不到精确 DirectBuffer 时,应用侧应配合 BufferPoolMXBean 或 Micrometer 导出。
值班看板若只挂堆使用率,等于在用「油箱一半」的读数判断整车是否超重。
多容器 Pod 里,业务容器与 Sidecar(日志、网格代理、导出器)各有独立 cgroup。 业务 JAVA_OPTS 只对本容器 limit 生效;若 Sidecar 吃掉数百兆而业务 limit 按「单进程」估算, 账本会在 Pod 视角再次失真。T17 从 Pod 规格展开 Sidecar 预算;本篇要求业务容器内部先闭合。
JDK 10 起引入 -XX:+UseContainerSupport(JDK 17+ 默认开启)。HotSpot 通过容器感知路径读取 cgroup 内存上限,
作为 Ergonomics 的 MaxRAM 输入;它解决的是「不要把宿主机整机内存当成堆」,并不会自动为元空间与 DirectMemory 预留 headroom。
3JVM 如何读到容器内存:v1 / v2 与版本差异
容器感知的第一性问题是:启动瞬间,HotSpot 认为「物理内存」是多少。 读错这一步,后面所有百分比与 GC 线程数都会建在错误地基上。本文默认 cgroup v2 与 JDK 17+; v1 与老 JDK 作为迁移审计项单独标注。 你可以把它想成「量杯刻度」:刻度错了,后面所有配比公式都会系统性偏差,而且偏差在低流量时不一定立刻爆炸。
| 环境 | cgroup 接口 | JDK 17+ 行为(机制共识) |
|---|---|---|
| cgroup v2(本文默认) | /sys/fs/cgroup/memory.max | 读取 max;值为 max 表示无限制 |
| cgroup v1(legacy) | memory.limit_in_bytes | 老 JDK 8 曾需额外开关;17+ 走统一容器支持路径 |
| 无 cgroup(本地 IDE) | — | 回退物理内存或显式 -Xmx |
实务上建议把「容器内读到的 memory.max」与「清单里写的 limits.memory」做成发布后自动对账: 二者不一致时(单位换算、Downward API 注入错误、运行时改 spec 未滚动),百分比会建在错误上限上。 这种对账成本很低,却能挡住一类「配置看起来对、进程实际看见另一个数」的幽灵故障。
v1 与 v2 对照(迁移审计用)
| 主题 | cgroup v1 | cgroup v2 |
|---|---|---|
| 硬限制文件 | memory.limit_in_bytes | memory.max |
| 当前用量 | memory.usage_in_bytes | memory.current |
| 软限制 | memory.soft_limit_in_bytes | memory.high |
| 排查路径 | 常在 /sys/fs/cgroup/memory/ | 统一 cgroup 根;确认未混挂 v1 |
Kubernetes 1.25+ 多数发行版默认 v2。JVM 主要读硬顶(max);memory.high 的软回收行为属于内核侧,
不要假设它会替你完成「堆收缩」。多容器 Pod 中每个容器独立 cgroup,Sidecar 内存需单独预算——
Pod 级视角见系列 T17。升级 JDK 小版本后,建议回归 limit 边界压测:容器感知与 GC 细节持续修复,属于作者经验总结。
与内存并行的另一条感知链是 CPU:ActiveProcessorCount 会受 CPU quota 影响,进而影响并行 GC 线程数。
内存与 CPU 配额不匹配时,常见表象是「limit 内 RSS 还够,但 Full/Mixed 跟不上分配,对象堆积推高峰值」。
评审 Java 服务规格时,应把 memory limit、MaxRAMPercentage、CPU limit 放在同一张表里看,
而不是内存组与计算组各改各的。T17 对配额与探针有交叉要求,本篇只强调:CPU throttling 可能伪装成内存压力。
本地开发机无 cgroup 或 limit 很大时,同一套百分比会算出远大于生产的堆。这解释了「本地很稳、预发一上就 137」: 不是业务代码突变,而是测量坐标系变了。任何宣称「本地压测通过」的容量结论,若未声明 cgroup 条件, 在治理评审中应直接打回。
把「容器感知已开启」当成上线结论是危险的。它只保证读数来源正确;若 ConfigMap 里残留 VM 时代的固定 -Xmx,
百分比会被静默覆盖,limit 缩放时堆不变、非堆相对挤压——评审应 grep 全部 JAVA_OPTS 事实来源。
4MaxRAMPercentage:让堆随 limit 伸缩,而不是跟虚拟机时代的固定值
容器场景推荐百分比驱动堆大小:limit 变更时堆自动跟随,避免每个环境手写一套 -Xmx。
固定 -Xmx/-Xms 仅在 limit 极稳定且团队禁止百分比时使用,且必须显著小于 limit。
百分比的本质不是「更先进」,而是把堆与 limit 绑成同一推导式,使垂直伸缩与多环境复用变得可审计。
选择 70% 还是 60%,不是审美问题,而是非堆结构问题。类少、线程池克制、几乎无 Direct 的 CRUD 服务, 可以把百分比抬高以换取更大存活集;网关、Feign 密集、Netty 写放大的服务,应主动把空间让给非堆。 团队若只有一个全局默认值,请把默认值定在偏保守的一侧(例如 65),再允许个别服务在压测证明后上浮。
# 推荐:百分比驱动(G1 在线服务示例,JDK 17+)
JAVA_OPTS="
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70.0
-XX:InitialRAMPercentage=70.0
-XX:MinRAMPercentage=50.0
-XX:+UseG1GC
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
"
| 参数 | 作用 | 容器场景建议 |
|---|---|---|
MaxRAMPercentage | 堆上限 = cgroup limit × 百分比 | 在线服务 60~75;大堆外取 55~65 |
InitialRAMPercentage | 初始堆百分比 | 与 Max 对齐,减少扩容抖动 |
MinRAMPercentage | 小内存容器堆比例下限 | 微型 sidecar 才需关注 |
-Xmx / -Xms | 固定堆,覆盖百分比 | 必须 < limit × 约 0.65 并留余量 |
余量公式(作者经验总结,上线前须压测)
memory.limit ≥ Heap目标 + Metaspace峰值 + 线程栈估算 + 堆外预留 + 安全垫
Heap目标 ≈ limit × MaxRAMPercentage / 100
线程栈估算 ≈ 线程数 × 1Mi × commit 比例(经验 0.3~0.8)
堆外预留:Netty/gRPC ≥ 128~256Mi;普通 Spring MVC 64~128Mi
安全垫:≥ 10% limit(GC native spike / glibc arena)
反推:limit=1Gi、MaxRAMPercentage=70 时堆约 716Mi,剩余约 308Mi 给非堆——对类加载多、线程池大的服务往往不够。 不同工作负载起点(经验,非官方默认):轻量 REST 70~75;Spring Cloud 多 Feign 60~65;Netty/gRPC 55~65; 批处理大堆 65~75(limit 本身较大时);Sidecar 小 limit 50~60。 把这些起点写进平台模板时,务必标注「起点而非承诺」,并要求业务在首次上线附带 working_set 峰值截图。
安全垫为什么建议至少约 10%?因为 GC 与分配器在峰值时会有短暂 native 尖峰,glibc arena 与页面对齐也会吃掉边角。 把 limit 算到「理论刚好塞下」等于把生产建立在理想模型上。宁可多留一点不可用的空档,也不要在大促夜把模型误差交给 OOM Killer。
同时设置 -Xmx896m 与 MaxRAMPercentage=75 时,固定 -Xmx 优先。
迁移脚本里残留的虚拟机参数会静默覆盖百分比,导致 HPA/垂直伸缩时堆不跟随——发布评审必须清理冲突来源。
Initial 远低于 Max 时,运行期堆 expand 可能带来额外 native 分配与短时 RSS 尖峰。limit 紧贴预算时, 建议 Initial 与 Max 同百分比。启动即需大堆的服务可接受 Initial=Max,减少启动后 expand。 有人担心「Initial=Max 会浪费内存」:在容器里,limit 已经买下整段额度,堆迟迟不 expand 省下的往往只是短时账面数字, 换来的却是流量上来时的 expand 尖峰风险。容器场景更应关心峰值可解释,而不是虚拟机时代的「慢慢长大」。
Helm 与多环境管理上,推荐把 memory.limit 当作单一事实来源,MaxRAMPercentage 相对固定,
堆由 limit 推导;或维护 heapTargetMb 与 nonHeapBudgetMb 相加反推 limit。
最危险的模式是「limit 在 chart A、JVM 在 chart B」长期分叉,预发与生产 limit 不一致却复制同一镜像参数。
环境差异必须同步改百分比或显式堆,并在变更单里写明推导式,方便半年后的人看懂。
还有一类隐性冲突:镜像 ENTRYPOINT 脚本、基础镜像默认 JAVA_TOOL_OPTIONS、以及 Deployment 的 env 三重叠加。
最终生效参数应以容器内 jcmd 1 VM.flags 为准,而不是以 Git 仓库里「看起来最新」的 values 为准。
治理成熟度的标志之一,是 CI 能解析并断言 MaxHeapSize 与 limit 的比例落在策略区间。
5元空间与堆外:cgroup OOM 的隐形杀手账本
JDK 8 后永久代移除,类元数据进入 Metaspace(本地内存)。默认 MaxMetaspaceSize 无上限。
Spring Boot、大量动态代理、字节码增强场景下,Metaspace 可持续增长直至 RSS 触顶。
它与 Heap OOM 独立:设了上限可能抛 OutOfMemoryError: Metaspace;
未设上限且缓慢泄漏时,更常见的是直接 137。
许多团队第一次设 Meta 上限时设得过小,启动高峰就 Metaspace OOM——正确做法是用预发观察平台期后再定 cap,
并同步把 cap 计入 limit,而不是「先随便写个 128m」。
# 元空间与直接内存显式治理
JAVA_OPTS="
-XX:MaxMetaspaceSize=256m
-XX:MetaspaceSize=128m
-XX:ReservedCodeCacheSize=240m
-XX:MaxDirectMemorySize=256m
"
| 区域 | 典型参数 | 与 limit 关系 | 溢出表现 |
|---|---|---|---|
| Metaspace | MaxMetaspaceSize | 计入 RSS,不受 -Xmx | Java Metaspace OOM 或 137 |
| DirectByteBuffer | MaxDirectMemorySize | 默认约等于 -Xmx | Direct OOM 或 137 |
| CodeCache | ReservedCodeCacheSize | JIT 代码,通常数十 MiB | CodeCache full,间接伤性能 |
| 线程栈 | -Xss | 高并发线性增长 | 更多撑 RSS,少见 StackOverflow |
Fat JAR + 大量 @FeignClient、CGLIB 代理会在启动后五到十五分钟达到 Metaspace 平台期。
压测必须覆盖冷启动与预热后稳态;仅看启动一分钟会低估。若稳态仍线性上涨,优先怀疑 ClassLoader 泄漏,
用 T09 的 jcmd/MAT,而不是无限抬高 MaxMetaspaceSize 把问题推迟为 137。
Elastic Metaspace(JDK 16+)使回缩更平滑,但流量尖峰仍可能在前一次 expand 基础上触顶。
一个实用经验是:把「启动后十五分钟」与「压测峰值」两个点的 Metaspace 用量同时记入容量卡,
差值过大往往提示动态类或泄漏,而不是单纯「limit 太小」。
MaxDirectMemorySize 默认与 -Xmx 同量级。百分比把堆放大时 Direct 上限同步放大,
可能「堆还够、Direct 先顶满」。Netty 服务应显式设 Direct 上限(如 128~256Mi),并监控 BufferPool。
默认 -Xss1m 下线程数飙升会 commit 大量栈页;容器场景控制线程池上限,必要时评估 -Xss512k(需验证栈溢出风险)。
架构层亦可降低 Metaspace 压力:收敛 Feign 接口数量、禁止生产热部署框架、谨慎使用 Groovy 或动态脚本引擎。
Spring Boot 3 原生镜像走不同路径;若仍用 HotSpot 容器镜像,类元数据治理规则不变。
需要强调:抬高 MaxMetaspaceSize 而不重算 limit,只是把 Java Metaspace OOM 换成更难取证的 137——
这在复盘会上常被误写成「偶发重启」,实际上是账本未闭合。
GC 选型也会影响 native 峰值:ZGC 在超大堆上有延迟优势,但小 limit 容器未必划算,且 native 结构占用需重新测量。 容器场景的优先级应是内存预算先闭合,再谈收集器延迟优化;否则你会在错误地基上比较 G1 与 ZGC。 与 T08 的分工:T08 负责选哪类收集器,本篇负责保证测量单位与总账正确。
预发复现 137 时短期开启 -XX:NativeMemoryTracking=summary,用 jcmd <pid> VM.native_memory summary scale=MB
核对 Metaspace / Thread / Code / GC / Internal。生产全量常开 NMT 不推荐;诊断窗口结束即关闭。
6OOMKilled 与 JVM OOM:先分科,再开药
两类故障都叫「内存」,处置完全不同。混为一谈会导致:该降百分比时却加堆、该做泄漏分析时却盲目升 limit。
排障第一问永远是:应用日志有没有完整的 OutOfMemoryError?describe 有没有 OOMKilled?
把这两个问题写成值班手册的前两行,比再增加十个 JVM 参数更有用。
还要警惕「日志里偶尔出现 OOM,但最终退出码是 137」的复合路径:JVM 已开始内存挣扎,GC 与分配失败推高 RSS, 内核抢先 SIGKILL。此时既要保留可能存在的部分 dump/错误栈,也要按 cgroup 路径重算预算。 不要因为看见过一次 Java OOM 文案,就忽略 working_set 贴边的事实。
| 维度 | JVM 内部 OOM | cgroup OOMKilled |
|---|---|---|
| 触发层 | HotSpot 分配失败 | memory controller,RSS 超 limit |
| 退出码 | 通常 1(未 ExitOnOOM 时) | 137(128 + SIGKILL) |
| 日志 | OutOfMemoryError 栈 | 日志 abrupt 结束 |
| 处置 | 泄漏分析、调堆/Meta | 降 MaxRAM%、升 limit、压非堆 |
节点 MemoryPressure 导致的 Evicted 与单容器 137 不同:前者调 requests、节点容量或副本密度;
后者改 JVM 预算或本容器 limit。OOMKilled 进程来不及写 HeapDump——应靠 metrics、logs --previous,
以及曾触发 Java OOM 时 PVC 上的 dump。建议 working set/limit > 0.9 持续五分钟即告警,抢在 137 之前介入。
证据保全纪律:137 之后再进容器往往什么都没有。预发与生产应约定——HeapDumpPath 指向可持久化卷; 关键指标保留至少覆盖一次完整发布窗口;Events 与 previous 日志在工单里自动粘贴。 没有这些,复盘只能靠「好像是内存」的口述,无法区分参数问题与泄漏问题,治理闭环会断在取证层。
另一条易混路径是探针误杀:内存升高导致接口变慢,readiness/liveness 超时,Pod 被重启, 表象类似内存事故,根因却是超时预算与资源不足的耦合。T17 专门处理探针与优雅停机; 本篇要求在怀疑 137 时先看 Last State Reason,再看探针事件,避免两套 SOP 互相覆盖结论。
kubectl describe pod <pod> -n <ns> | grep -E "Reason|Exit Code|Last State" -A2
kubectl exec <pod> -- cat /sys/fs/cgroup/memory.max
kubectl exec <pod> -- jcmd 1 VM.flags | grep -E "MaxHeapSize|MaxRAMPercentage|UseContainerSupport"
kubectl exec <pod> -- jcmd 1 GC.heap_info
kubectl exec <pod> -- jcmd 1 VM.metaspace | head -20
T17 从 K8s 视角要求 limits 覆盖 JVM 总 RSS,并覆盖探针误杀与优雅停机。 本篇给出 Java 侧如何算出该预算。上线评审应同时勾选本篇检查表与 T17 资源/探针表。 两表缺一,等于只签了半份合同:要么 JVM 参数漂亮但 Pod 合同破洞,要么 K8s 规格齐全但 Java 账本未闭合。
7这套适配范式适合谁,不适合谁
容器化 JVM 治理不是「所有 Java 进程一套 JAVA_OPTS」。先判断工作负载形态,再选百分比与是否允许上 K8s 的小 limit。 适合与不适合的讨论,本质上是在回答:这套「百分比 + 非堆 cap + limit 闭合」的合同,对当前服务是否便宜且足够安全。
适合继续用百分比 + 显式非堆 cap
- Spring Boot / Cloud 微服务,limit 在 1~4Gi 量级,需随环境伸缩
- 多环境共用同一镜像,靠 Deployment 改 limit 而非改镜像内 -Xmx
- Netty/gRPC 服务愿意单独治理 DirectMemory
- 平台统一模板,业务只允许在窄幅内调百分比并附压测
不适合硬套「默认 75%」或盲目上小 limit
- 完整 Spring Boot 塞进 <512Mi 却仍用 70%+ 堆占比
- 堆外主导(大 Direct、大量 mmap)却只调 MaxRAMPercentage
- 无 limit 的 Burstable「弹性」幻想——JVM 可能看见过大内存
- 仍跑无容器感知的极老 JRE,却假设「和 17 一样」
- 把裸机压测结论直接当容器容量结论
JDK 21 虚拟线程会改变栈占用形态(更多状态挂在堆上),Metaspace 与堆相对占比可能变化。 容器仍看 RSS 总量;引入虚拟线程后应重做 limit 边界压测,不宜直接沿用平台线程时代的百分比—— 此为待验证推断,以本服务压测为准。
批处理与在线服务的适合边界也不同。批处理常给更大 limit、更追求吞吐,百分比可以偏高,但仍需非堆 cap; 在线服务更怕启动峰值与尾延迟,百分比应保守,并与 HPA、探针联动。Sidecar Agent 若 limit 很小, 不应强行塞完整应用服务器栈——宁可拆进程或升规格,也不要在 512Mi 内用「虚拟机时代默认值」硬撑。
不适合本篇范式的,还有「把容器当轻量虚拟机、拒绝设 limit」的组织习惯。 没有硬顶就没有可计算的 MaxRAM,百分比失去锚点,容量规划退化为拍脑袋。 若平台策略强制无 limit,Java 服务应升级为特例评审,而不是默默沿用百分比模板。 同样不适合的是「为了通过门禁而把百分比写得很低、同时把 limit 开到远超真实峰值」——看起来永不 137, 实际是在用集群超卖掩盖治理缺失,节点 MemoryPressure 与成本会在别处报复。
HotSpot 文档明确:容器支持影响基于可用内存的 Ergonomics;显式 -Xmx 仍可覆盖自动堆大小。
因此「开启容器感知」与「堆一定合理」之间没有蕴含关系。
8参数组合验证、预算演算与排障 SOP
下列为合成对照案例(非单一真实事故),用于说明配比逻辑;上线以本服务压测为准。 把「推荐模板」当成起点而不是终点:同一 1Gi + 65% 在轻量 REST 上可能充裕,在网关服务上可能仍贴边。 模板的价值是统一语言与默认安全垫,个性化必须用压测数字辩护。
| 案例 | limit | JVM 配置 | 预期堆 | 非堆预算 | 结论 |
|---|---|---|---|---|---|
| 反例 1 | 512Mi | 默认约 75%,无 Meta cap | ~384Mi | ~128Mi | 中等 Spring Boot 易 137 |
| 推荐 1 | 1Gi | 65% + Meta 256m + Direct 128m | ~665Mi | ~359Mi | 常规 REST 起点 |
| 推荐 2 | 2Gi | 70% + Meta 384m + G1 | ~1.4Gi | ~600Mi | 类多 / 中等 Netty |
| 反例 2 | 1Gi | -Xmx1024m | 1024Mi | 负余量 | 必 137 |
| 推荐 3 | 512Mi | 55% + Meta 128m + Xss512k | ~281Mi | ~231Mi | 小型 Agent;禁完整 Boot |
速算与 Deployment 片段
MaxRAMPercentage ≈ (limit − 非堆预算) / limit × 100。
例:limit=1536Mi,非堆 450Mi,堆目标约 1086Mi,百分比约 71——若策略上限 65,则升 limit 或压非堆。
requests 取 P95 RSS 的 1.1~1.2 倍,limits 取峰值的 1.2~1.5 倍,且必须覆盖闭合预算。
此速算用于评审会心算,不能替代压测;压测脚本必须包含冷启动、预热稳态与峰值流量三段。
验证步骤建议固化为预发流水线:部署后立即 jcmd 打印 MaxHeapSize 与 MaxRAMPercentage;
跑五到十五分钟预热,采集 working_set 峰值;再施压到目标 QPS,观察是否触达 0.9 水位。
任一步失败,回滚参数或规格,禁止「先上生产再观察」。对已在线服务的整改,按风险排序分批,
优先处理最近出现过 137、或 limit 小于 1Gi 且无 Metaspace 上限的工作负载。
流水线通过并不等于永久正确:依赖升级与流量形态变化后,应把同一套验证当作回归,而不是一次性仪式。
resources:
requests:
memory: "768Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
env:
- name: JAVA_OPTS
value: >-
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=65.0
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=128m
-XX:+ExitOnOutOfMemoryError
-XX:+UseG1GC
排障 SOP(现象 → 工具 → 根因 → 处置 → 预防)
| 现象 | 工具链 | 根因定位 | 处置 | 预防 |
|---|---|---|---|---|
| 137,无 Java OOM | describe;working_set;jcmd VM.flags | RSS 超 limit;堆占比过高或非堆无 cap | 降 MaxRAM% / 升 limit / 设 Meta·Direct | 预算表 + 0.9 告警 |
| Heap OOM | HeapDump;MAT;GC 日志 | 泄漏或堆目标过低 | 修泄漏或升堆(同时重算 limit) | ExitOnOOM + dump 路径 PVC |
| 仅新副本 137 | 对比冷/热 RSS;Startup Probe | 启动峰值未进预算 | 按冷启动峰值定 limit;拉长探针 | 扩容演练纳入发布门禁 |
| Evicted 非 137 | 节点 MemoryPressure;QoS | 节点级压力 | 调 requests / 扩节点(T17) | Guaranteed 核心服务 |
SOP 落地时,建议在工单系统里把「现象」字段做成枚举(137 / Java OOM / Evicted / 探针重启), 强制选择后才展开对应工具链,避免自由文本把问题描述糊成「服务不稳定」。 根因字段同样枚举(参数错配 / 泄漏 / 启动峰值 / 节点压力),便于季度统计哪一类占比最高—— 那就是下一季度治理投入该砸向模板、代码质量还是集群容量。
9容器化 JVM 参数配置检查表
面向架构治理落地:开发保证参数正确,SRE 保证 limit 与监控,平台保证基础镜像与 cgroup 基线。 任一项为否,禁止合并生产。存量服务先扫描 JAVA_OPTS 与近期 OOMKilled,优先处理 limit <1Gi 且无 MaxMetaspaceSize 的实例。 检查表可以剪成「阻塞项」与「建议项」两档:JDK 版本、limit 必设、Meta cap、预算闭合、冲突 -Xmx 清理建议做阻塞; NMT 演练、虚拟线程重测、cgroup 退役计划可做建议项,按季度推进。
| 检查项 | 标准 | 责任人 | 频率 |
|---|---|---|---|
| JDK 版本 | 生产 ≥ JDK 17 LTS;禁无容器感知的极老构建 | 开发/平台 | 基础镜像变更 |
| 容器感知 | UseContainerSupport 未显式关闭 | 开发 | 每次发布 |
| 堆策略 | 优先 MaxRAMPercentage;若 -Xmx 则 ≤ limit×65% | 开发 | 每次发布 |
| 百分比取值 | 在线 60~75;高堆外取低并压测 | 开发 | 发布/架构变更 |
| 元空间 | 显式 MaxMetaspaceSize,禁 unlimited 上生产 | 开发 | 每次发布 |
| DirectMemory | Netty/gRPC 显式 MaxDirectMemorySize | 开发 | 每次发布 |
| limit 闭合 | limit ≥ 堆 + Meta + 栈 + 堆外 + 10% 垫 | 开发/SRE | 每次发布 |
| limit 必设 | Java 负载不得长期无 memory limit | SRE | 集群巡检 |
| 压测一致 | 压测 cgroup limit 与生产一致 | QA/SRE | 大版本前 |
| OOM 监控 | working_set/limit > 0.9;reason=OOMKilled | SRE | 持续 |
| ExitOnOOM | 生产建议开启,便于重启与 dump | 开发 | 每次发布 |
| 遗留 -Xmx | 无与 limit 冲突的固定堆 | 开发 | 每次发布 |
| cgroup 版本 | v2 确认 JDK 支持;v1 标注退役 | 平台 | 集群升级 |
| 联动 T17 | 探针、PreStop、配额与 JVM 预算同工单 | SRE/开发 | 每次上线 |
组织建议:平台维护默认 JAVA_OPTS 模板;业务仅允许百分比窄幅浮动并附压测;GitOps 合并前校验预算闭合。 每季度对 OOMKilled 归因(参数 / 泄漏 / 节点驱逐),参数类占比过高说明规范未落地。 变更 limit、JDK 大版本、Feign/Netty 依赖时必须重审——避免「代码没动、基座 Metaspace 涨了」的隐性 137。
检查表不是「写一次贴墙上」。建议嵌入三处:服务脚手架生成的默认 values;合并请求模板的阻塞项清单; 以及季度容量评审的抽样审计。平台可用简单策略引擎拦截「-Xmx 大于等于 limit」或「缺少 MaxMetaspaceSize」的清单。 自动化拦底线,人工评审管业务语义(例如该服务是否属于高 Direct 负载、冷启动是否异常)。
责任边界要写清:开发对参数正确性签字,SRE 对监控与 limit 签字,平台对基础镜像与节点 cgroup 版本签字。 出现 137 时,按签字链回溯,而不是在群里互相指认。这是架构治理落地与「个人英雄式救火」的本质区别。 若组织暂时做不到三人会签,至少保证「改 JAVA_OPTS 与改 limits 必须出现在同一合并请求」,避免分仓库异步漂移。
10决策矩阵、相邻知识地图与要点回顾
当评审会上出现「加堆还是加 limit、百分比还是固定 -Xmx」争议时,用下表做继续 / 调整 / 暂缓判断。 矩阵刻意把「继续当前配置」写成可辩护状态,而不是默认正确:没有证据的继续,与盲目迁移一样危险。 把表格投影到评审会,比口头争论「我司惯例」更能收敛决策时间。
| 场景信号 | 继续当前配置 | 迁移 / 调整 | 选型注意 |
|---|---|---|---|
| working_set 稳态 <75% limit,无 137 | 可继续 | 微调百分比空间有限 | 保持 Meta/Direct cap |
| 堆空、working_set 贴边、137 | 勿盲目加堆 | 降 MaxRAM% 或升 limit;压非堆 | 先 NMT 再改参 |
| 固定 -Xmx ≈ limit | 立即停用该组合 | 改百分比或显著降低 -Xmx | 清理 ConfigMap 残留 |
| 仅冷启动 137 | — | 按启动峰值定 limit / 探针 | 与 HPA 扩容演练绑定 |
| Java Metaspace OOM | — | 查泄漏;必要时抬 Meta 并重算 limit | 勿只抬 Meta 不改 limit |
| Evicted + MemoryPressure | — | 转 T17:requests/节点 | 与本篇 137 分科 |
| 引入虚拟线程 / JDK 大版本 | 勿直接沿用旧百分比 | 重做边界压测后定参 | 证据未齐则暂缓扩流量 |
| 镜像/依赖升级后 Meta 平台期明显上移 | 勿只改业务代码假定无影响 | 重算 Meta cap 与 limit | 与基础镜像变更联动评审 |
决策矩阵与第九章检查表是同一枚硬币的两面:检查表阻止错误进入生产,矩阵指导事故后或争议中的方向选择。 两者都应版本化管理——当 JDK 默认行为或集群 cgroup 大版本变化时,同步修订表格中的「标准」列,避免规范本身过期。
要点回顾
- 容器化后硬顶在 cgroup,不在 -Xmx;堆未满仍可能 137。
- JDK 17+ 优先 MaxRAMPercentage,并为非堆留足 limit 比例(随负载浮动,常见约三成量级)。
- 元空间与 DirectMemory 必须显式 cap;unlimited Metaspace 是生产隐患。
- 排障先分科:Java OOM、cgroup OOMKilled、节点 Evicted,再分别开药。
- 治理沉淀:本篇检查表与 T17 K8s 稳定性检查表联合签署,构成 Java on K8s 上线门禁。
若只用一句话带走:先让 RSS 总账在 limit 内可解释,再谈堆大小与 GC 漂亮程度。 做不到这一点,再多的 GC 日志解读都可能建在错误坐标系上。把这句话写进团队 Wiki 首页, 比再收集十个「最佳实践链接」更能减少下一季度的 137 工单。 若只能推动一件事:先统一「limit 与 MaxRAMPercentage 成对出现」的模板,其余优化都往后排。
相邻知识地图
- T08 JVM 内存与 GC 选型:先建立堆/元空间/Direct 概念,再读本篇对齐容器单位。
- T09 线上 JVM 高频故障定位:HeapDump、jcmd、MAT;本篇负责区分是否该进入堆分析。
- T17 K8s 生产稳定性配置:limits、探针、优雅停机与本篇预算闭合。
- T16 / T18:资源模型与运维排障;出现 137 时回到本篇参数账本。
- T15 Docker 镜像工程化:基础镜像 JDK 与默认 JAVA_OPTS 不应与本篇模板打架。
四篇合读的推荐顺序是 T08(会选)→ 本篇(会配)→ T17(会合约)→ T09(会查)。 若你的角色是平台工程,也可以本篇与 T17 并行推进模板,再回头用 T08/T09 培训业务研发。 顺序服务于职责,不必教条,但缺任何一环都会在大促窗口暴露。
从组织能力成熟度看,初级团队往往停留在「出事加 limit」;中级团队开始统一百分比模板; 高级团队能把预算闭合做成合并门禁,并用季度归因驱动模板迭代。本篇检查表与决策矩阵, 就是为了把团队从初级推进到中高级——不是多背几个参数名,而是让错误配置更难混进生产。
分享讨论可自检三问:团队 Java 服务的 limit 与 MaxRAMPercentage 是否成对文档化; 最近一月 OOMKilled 是否做过归因;压测是否在 cgroup 限制下进行。三项皆否时, 优先做存量参数扫描,而不是继续拧 GC。三项皆是,则可以把精力转到泄漏治理与容量预测,而不是重复争论默认百分比该写多少。先把测量单位对齐,争论才有意义与清晰边界。
决策矩阵的使用方式:先定位「场景信号」行,再看「继续 / 调整」列是否同时满足证据条件。 若信号是堆空加 137,却选择继续加堆,属于典型误判;若信号是 Java Heap OOM,却只升 limit 不查泄漏, 会在成本与稳定性上同时失败。矩阵不能替代判断,但能阻止最常见的方向性错误。
下一步学习路径:读完本篇后,用一份真实服务的 Deployment 对照第九章检查表逐项勾选; 在预发用第八章命令框架跑一遍 jcmd;再打开 T17 把探针与配额补齐。 若你同时负责镜像工程,可衔接 T15,确认基础镜像 JDK 版本与默认 JAVA_OPTS 不与本篇冲突。
系列证据链应读作:T10 保证测量单位正确,T08 保证选型逻辑正确,T09 保证事故可复盘,T17 保证 Pod 合同完整。 分开阅读可以,分开决策容易漏约束——尤其是「只加堆」与「只加 limit」两种单向补丁。