K8s Pod 每天 20 次 OOMKilled 实录:JVM 堆外内存治理全链路

订单服务在 K8s 每天 OOMKilled 20+ 次,exit code 137,JVM 无 OOM 日志。投一周排查:JVM 没感知 cgroup limit + Direct Memory 失控 + 线程数 280 个 + Metaspace 涨 + cgroup v2 核算变化。最终参数 + NMT 定位 + 监控告警全套修法,30 天零重启,P99 800ms→80ms。

2024 年我们一个 Java 微服务在 K8s 集群里频繁被 OOMKilled,每天重启 20+ 次,P99 延迟尖刺,业务方反复投诉。日志里只有冷冰冰的 exit code 137,JVM 没留下任何 OOM 痕迹。投了一周排查,发现是 JVM 堆外内存 + Pod limit + cgroup v2 三个问题叠加。修完后服务 30 天零重启,P99 从 800ms 降到 80ms,内存利用率从 60% 提到 85%。本文复盘 K8s + JVM 内存治理的完整链路。

事故现场

服务:订单中心(Spring Boot 2.7 + JDK 17)
部署:K8s 1.28(cgroup v2),20 Pod
资源:Pod limit 2GB,JVM -Xmx 1500m
现象:
- 每天 20+ 次 OOMKilled
- 重启时 P99 飙到 30s
- 业务报错率短时尖刺 5%

排查日志:
$ kubectl describe pod order-xxx
  State:        Waiting
  Reason:       CrashLoopBackOff
  Last State:   Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Started:      Mon, 10 Mar 2024 14:23:11 +0800
  Finished:     Mon, 10 Mar 2024 14:51:33 +0800

$ kubectl logs order-xxx --previous
... (业务日志正常,没有 OutOfMemoryError)
... 进程被强杀,没留下 heap dump

$ dmesg | grep -i kill
[123456.789] Memory cgroup out of memory: Killed process 1234 (java)

# JVM 没 OOM,是被 cgroup 干掉的 → 堆外内存爆了

JVM 内存模型(K8s 视角)

Pod limit 2GB
├── JVM Heap(-Xmx)         1500MB
├── JVM Metaspace          200MB
├── Code Cache             50MB
├── Compressed Class Space 50MB
├── Direct Memory(NIO)     ?(默认 = Xmx)
├── Thread Stack           1MB × 线程数
├── JNI / Native Memory    ?(无限制)
└── 其他(GC、JIT 等)     ~200MB

总和:1500 + 200 + 50 + 50 + 1500 + (1MB×300) + 200 = 3800MB
Pod limit:2000MB
→ 必然 OOMKilled

问题 1:JVM 没感知 cgroup limit

# 老版本 JDK 不感知 cgroup(JDK 8u131 才支持)
# 容器内 -Xmx 不设,JVM 会按宿主机内存(256GB)算 Heap
# 结果:宿主机有 N 个容器,每个都觉得自己有 256GB

# 检查
$ docker exec -it order-pod java -XshowSettings:vm 2>&1 | grep -i max
Max. Heap Size (Estimated): 254.50G   # 错!应该按 Pod limit

# 修复 1:升级到 JDK 11+(默认 UseContainerSupport)
# JDK 8u191+:-XX:+UseContainerSupport(默认开)

# 修复 2:显式设比例
-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=70.0
# Pod 2GB → Heap = 1400MB(留 30% 给堆外)

# 修复 3:固定 Xmx(推荐生产用)
-Xms1400m -Xmx1400m
# 不要 Xms 远小于 Xmx,会动态扩缩,慢

问题 2:堆外内存失控

// NIO Direct Memory(Netty 默认用)
// 默认 -XX:MaxDirectMemorySize = Xmx(没显式设)
// → Xmx 1.4G,Direct 1.4G,加起来 2.8G,爆了

// 启动参数:
-XX:MaxDirectMemorySize=512m
-Dio.netty.maxDirectMemory=0   // 禁用 Netty 自管,走 JDK

// 监控堆外
@RestController
public class MemoryEndpoint {
    @GetMapping("/admin/memory")
    public Map<String, Object> memory() {
        Map<String, Object> result = new HashMap<>();

        // Heap
        MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
        result.put("heap_used_mb", heap.getUsed() / 1024 / 1024);
        result.put("heap_max_mb", heap.getMax() / 1024 / 1024);

        // Non-Heap(Metaspace + Code Cache)
        MemoryUsage nonHeap = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage();
        result.put("non_heap_used_mb", nonHeap.getUsed() / 1024 / 1024);

        // Direct Memory(反射拿,JDK 没暴露 API)
        BufferPoolMXBean direct = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
            .stream()
            .filter(b -> "direct".equals(b.getName()))
            .findFirst()
            .orElse(null);
        if (direct != null) {
            result.put("direct_used_mb", direct.getMemoryUsed() / 1024 / 1024);
            result.put("direct_count", direct.getCount());
        }

        // 线程数 × 栈大小
        ThreadMXBean threadMX = ManagementFactory.getThreadMXBean();
        result.put("thread_count", threadMX.getThreadCount());

        // Process RSS(实际占用)
        try {
            String stat = new String(Files.readAllBytes(Paths.get("/proc/self/status")));
            for (String line : stat.split("\n")) {
                if (line.startsWith("VmRSS:")) {
                    result.put("rss_mb", Long.parseLong(line.split("\\s+")[1]) / 1024);
                }
            }
        } catch (Exception ignored) {}

        return result;
    }
}

// 输出:
// heap_used_mb: 800, heap_max_mb: 1400
// non_heap_used_mb: 250
// direct_used_mb: 450     ← 这就是问题!
// direct_count: 1234
// rss_mb: 1900            ← 接近 2GB limit
// thread_count: 280

问题 3:NMT(Native Memory Tracking)定位

# 启动 JVM 加参数
-XX:NativeMemoryTracking=detail
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintNMTStatistics

# 运行时查询
$ jcmd 1 VM.native_memory summary

Native Memory Tracking:
Total: reserved=3.2GB, committed=2.8GB

-                 Java Heap (reserved=1.5GB, committed=1.5GB)
-                    Class (reserved=300MB, committed=250MB)
-                   Thread (reserved=280MB, committed=280MB)  ← 280 线程 × 1MB
-                     Code (reserved=120MB, committed=80MB)
-                       GC (reserved=80MB, committed=80MB)
-                 Compiler (reserved=20MB, committed=20MB)
-                 Internal (reserved=50MB, committed=50MB)
-                   Symbol (reserved=30MB, committed=30MB)
-    Native Memory Tracking (reserved=10MB, committed=10MB)
-               Arena Chunk (reserved=20MB, committed=20MB)
-                   Other  (reserved=450MB, committed=450MB)  ← Direct buffer

# 对比 Heap dump
$ jcmd 1 GC.heap_dump /tmp/heap.hprof

# 用 MAT / VisualVM 分析,找内存泄漏
# 主要看:
# 1. 大对象(byte[] / char[])
# 2. 长生命周期(ThreadLocal、static Map 没清理)
# 3. ClassLoader 泄漏(每次热加载,Metaspace 涨)

问题 4:线程数失控

// 280 线程 × 1MB 栈 = 280MB(堆外)
// 我们的服务为啥这么多线程?

// 1. Tomcat 工作线程
server:
  tomcat:
    threads:
      max: 200          # 默认 200,太多了
      min-spare: 20

// 2. 数据库连接池
spring:
  datasource:
    hikari:
      maximum-pool-size: 50   # 不需要这么多

// 3. RestTemplate 没共享(每次 new 一个客户端)
// 4. CompletableFuture 用 ForkJoinPool.commonPool(共享,但容易满)
// 5. Scheduled tasks 配置不当

// 修复:
// 1. Tomcat threads.max = 50(配 HikariCP 50 = 1:1)
// 2. 共享 HTTP 客户端(OkHttp connection pool)
// 3. 自定义 ExecutorService,显式管理生命周期
// 4. 减小 -Xss(默认 1MB → 512KB)

// 验证
$ jstack 1 | grep "java.lang.Thread.State" | wc -l
280    →    100   (砍掉 180 个)

问题 5:Metaspace 持续涨

# Metaspace 一周从 100MB 涨到 280MB
# 原因:Spring Boot DevTools 热加载,ClassLoader 泄漏

# 监控
$ jstat -gc 1 5s

# 关闭热加载(生产必须)
spring:
  devtools:
    restart:
      enabled: false

# 限制 Metaspace
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m

# 监控告警
- alert: MetaspaceHigh
  expr: jvm_memory_used_bytes{area="nonheap",id="Metaspace"} / 1024 / 1024 > 200
  for: 10m
  annotations:
    summary: "Metaspace > 200MB,可能 ClassLoader 泄漏"

问题 6:cgroup v2 + 内存核算

# K8s 1.25+ 默认 cgroup v2
# 内存核算口径变化:
# - cgroup v1:RSS + Page Cache
# - cgroup v2:anon + file + sock + ...

# 看 Pod 实际内存
$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.current
1856372736       # 1.73G

$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.max
2147483648       # 2GB

# 详细分类
$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.stat
anon 1234567890           # 匿名内存(堆 + 堆外)
file 234567890            # Page Cache
kernel 12345678
sock 1234567

# OOMKilled 触发条件:memory.current >= memory.max
# Page Cache 也算!所以日志写盘多的服务要注意

最终启动参数

#!/bin/bash
# Pod limit 2GB

JAVA_OPTS="
  -Xms1400m -Xmx1400m
  -Xss512k
  -XX:MetaspaceSize=256m
  -XX:MaxMetaspaceSize=256m
  -XX:MaxDirectMemorySize=256m
  -XX:ReservedCodeCacheSize=64m
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=200
  -XX:G1HeapRegionSize=16m
  -XX:+ParallelRefProcEnabled
  -XX:+UnlockExperimentalVMOptions
  -XX:+UseStringDeduplication
  -XX:+HeapDumpOnOutOfMemoryError
  -XX:HeapDumpPath=/var/log/heapdump.hprof
  -XX:+ExitOnOutOfMemoryError
  -XX:+PrintGCDetails
  -Xloggc:/var/log/gc.log
  -XX:+UseGCLogFileRotation
  -XX:NumberOfGCLogFiles=5
  -XX:GCLogFileSize=10M
  -XX:NativeMemoryTracking=summary
  -Dio.netty.maxDirectMemory=0
  -Dfile.encoding=UTF-8
"

# 内存预算:
# Heap:       1400MB
# Metaspace:  256MB
# Direct:     256MB
# Stack:      50MB(100 线程 × 512KB)
# Code:       64MB
# Other:      ~150MB(GC、JIT、Native)
# Total:      ~2176MB → Pod 配 2.2GB 比较安全

exec java $JAVA_OPTS -jar /app/app.jar

K8s 配置

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order
spec:
  replicas: 20
  template:
    spec:
      containers:
      - name: app
        image: order:v1.2.3
        resources:
          requests:
            memory: "2Gi"      # request == limit,QoS Guaranteed
            cpu: "1"
          limits:
            memory: "2Gi"
            cpu: "2"
        env:
        - name: JAVA_TOOL_OPTIONS
          value: "-XX:MaxRAMPercentage=70.0"   # 兜底
        # 优雅终止
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 15"]
        # Liveness/Readiness 分开
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
        startupProbe:        # 启动慢就用这个
          httpGet:
            path: /actuator/health
            port: 8080
          failureThreshold: 30
          periodSeconds: 5
      terminationGracePeriodSeconds: 60

监控告警

# Prometheus + JMX Exporter
- alert: PodMemoryHigh
  expr: container_memory_working_set_bytes{pod=~"order-.*"} / container_spec_memory_limit_bytes > 0.85
  for: 5m
  annotations:
    summary: "Pod 内存 > 85% limit,可能 OOMKilled"

- alert: JvmHeapHigh
  expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.85
  for: 10m
  annotations:
    summary: "JVM Heap > 85%,可能内存泄漏"

- alert: JvmDirectMemoryHigh
  expr: jvm_buffer_memory_used_bytes{id="direct"} / 1024 / 1024 > 200
  for: 5m
  annotations:
    summary: "Direct Memory > 200MB"

- alert: ThreadCountHigh
  expr: jvm_threads_live_threads > 200
  for: 10m
  annotations:
    summary: "线程数 > 200,可能泄漏"

- alert: OOMKilledRate
  expr: increase(kube_pod_container_status_terminated_reason{reason="OOMKilled"}[1h]) > 0
  annotations:
    summary: "Pod 1h 内被 OOMKilled"

# 自动 heap dump 上传
preStop:
  exec:
    command:
    - sh
    - -c
    - |
      if [ -f /var/log/heapdump.hprof ]; then
        ossutil cp /var/log/heapdump.hprof oss://heapdumps/$POD_NAME-$(date +%s).hprof
      fi
      sleep 15

优化效果

指标                    优化前          优化后
========================================================
Pod 重启次数(天)       20+             0
平均 RSS                1.9GB(危险)   1.5GB
内存利用率              60%(浪费)     85%
线程数                  280             100
Metaspace               280MB(还在涨) 200MB(稳)
Direct Memory           450MB(失控)   180MB
P50 延迟                100ms           30ms
P99 延迟                800ms(GC尖刺) 80ms
Young GC 频率           1 次/秒         1 次/10秒
Full GC                 每天 5 次       每周 0-1 次

业务影响:
- 业务报错率从 0.5% 降到 0.01%
- SLA 从 99.5% 提升到 99.99%
- Pod 资源(20 个)省下来,节约月成本 6000 元
- on-call 不再被半夜叫起来

避坑清单

  1. JDK 11+ 默认 UseContainerSupport,JDK 8 必须 u191+
  2. 显式设 -Xmx(不要让 JVM 自己估)
  3. 显式设 -XX:MaxDirectMemorySize(默认 = Xmx 太大)
  4. NMT 必开(NativeMemoryTracking=summary 几乎零开销)
  5. HeapDumpOnOutOfMemoryError + ExitOnOutOfMemoryError
  6. 线程数控制(Tomcat threads.max + DB pool + Xss 一起调)
  7. Pod request == limit(QoS Guaranteed,不会被驱逐)
  8. cgroup v2 注意 Page Cache 也计入
  9. 分 Liveness / Readiness / Startup 三种探针
  10. terminationGracePeriodSeconds 给足 preStop 时间

总结

K8s 上的 JVM 内存治理是个系统工程:不是简单调个 -Xmx 就完事。Pod limit 2GB,你能给 Heap 多少?不是 1.9GB,是 1.4GB 左右,剩下 600MB 要留给堆外(Direct、线程栈、Metaspace、Code Cache、Native)。这次最大的认知改变是:OOMKilled 跟 OutOfMemoryError 不是一回事 — 前者是 cgroup 干的,后者是 JVM 自己。如果只看 JVM 日志找不到 OOM,基本就是堆外炸了,必须开 NMT 才能定位。最被低估的工具是 NMT,几乎零开销,定位堆外问题神器。最容易被忽视的是线程栈:300 个线程 × 1MB = 300MB,直接吃掉小 Pod 的可用堆外内存。最后,生产环境强烈建议 request == limit,Guaranteed QoS 让 Pod 不会被驱逐,稳定性远胜于"节省资源"的 BestEffort 配置。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

商品爆款 30w QPS 雪崩复盘:Redis 缓存三大问题工程修法

2026-5-19 13:00:57

技术教程

MySQL 主从延迟 25 分钟事故复盘:并行复制 + ProxySQL 工程治理

2026-5-19 13:05:16

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索