首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >资源请求与限制配置失衡引发的 OOMKilled:一场由 JVM 与 K8s limits 不匹配导致的生产故障复盘

资源请求与限制配置失衡引发的 OOMKilled:一场由 JVM 与 K8s limits 不匹配导致的生产故障复盘

原创
作者头像
编程小妖女
发布2025-09-15 10:40:42
发布2025-09-15 10:40:42
1492
举报
文章被收录于专栏:后端开发后端开发

我在工作中,曾经处理过一个 customer ticket,这个 ticket 花费了我几天的时间进行调查和处理,那几天真的是吃不好,睡不香。

问题解决之后,我想着一定要把问题的分析过程写下来,方便自己日后查看。也说不定可以帮助其他小伙伴们避免入坑呢?

技术环境

  • 集群:Kubernetes 1.27(containerd 运行时),三节点混部
  • 业务:Java Spring Boot 微服务,JDK 17,G1 GC
  • 监控:Prometheus + Grafana,日志采集到 EFK
  • 关键容器参数:resources.requests.memory: 256Miresources.limits.memory: 512Mi,镜像里 JVM 未显式配置堆大小

现象:频繁重启、接口偶发超时、Pod STATUSOOMKilled

线上告警提示接口延迟飙升,kubectl get pods 看到服务实例反复重启,STATUS 栏位出现 OOMKilledkubectl describe podLast State 明确写着:

代码语言:sh
复制
State:          Terminated
Reason:         OOMKilled
Exit Code:      137

Exit Code 137 代表进程被 SIGKILL 强杀,常见诱因是超出容器内存 limit 而触发 Linux OOM Killer,不过也需要结合 describeReason 来确认是否真是内存杀进程。(Stack Overflow)

应用日志里还能捞到一次完整的 Java OutOfMemoryError 堆栈(节选自实际可复现的同类报错形态,线程名用反引号标注):

代码语言:sh
复制
Exception in thread `pool-2-thread-170` java.lang.OutOfMemoryError: Java heap space
    at scala.meta.internal.semanticdb.SymbolOccurrence$.parseFrom(SymbolOccurrence.scala:118)
    at scalapb.LiteParser$.readMessage(LiteParser.scala:24)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
    at java.base/java.lang.Thread.run(Thread.java:1623)

这类 Java heap space 异常的含义是:JVM 在堆上无法再分配对象,GC 也无法回收出足够空间,或堆已达上限。


排查思路与定位过程

1)先证实是 OOMKilled 而不是别的 137 场景

exit code 137 并不总是内存杀,也可能是被外部 SIGKILL。对比 kubectl describe pod 的容器 State/Reason 字段,如果明确显示 Reason: OOMKilled,即可坐实。

2)确认调度与 QoS 是否暗藏风险

requests 是调度参考线,limits 是上限。内存 limit 的执行是内核层的 cgroup OOMKill;当容器突破 limit,在节点有内存压力时会被杀。若 request 远低于真实需求,调度器可能把过多 Pod 挪到同一节点,整体更易内存打架。

3)复盘 JVM 行为与容器限制的错位

现代 JVM 会感知 cgroup 限制,但若未合理设置 -XX:MaxRAMPercentage / -Xmx,实际可用堆可能与 K8s limit 脱节:

  • 堆 + 元空间 + 线程栈 + 直接内存 + JIT/代码缓存 共同占用内存。
  • limit: 512Mi,而 JVM 默认可能在峰值时让总占用逼近甚至略超 limit,被 OOM Killer 秒杀。业界常见实践是将堆上限设为 limit 的一部分,例如 60%~75%,留出 native/线程等开销。(Fairwinds)

4)证据链

  • 事件与 describe:容器 Reason: OOMKilled
  • 退出码:137 = 128 + 9(SIGKILL),结合 Reason 可判定为 OOM。
  • 应用侧日志:java.lang.OutOfMemoryError: Java heap space 堆栈。
  • 监控:内存曲线逼近 limit 后出现重启尖峰,属典型症状。

可复现的最小案例:一份能在 K8s 里被 OOMKilledJava 容器

Java 程序

代码语言:java
复制
import java.util.ArrayList;
import java.util.List;

public class MemoryEater {
    public static void main(String[] args) throws Exception {
        List<byte[]> bag = new ArrayList<>();
        final int mb = 8;
        while (true) {
            bag.add(new byte[mb * 1024 * 1024]);
            Thread.sleep(150);
        }
    }
}

Dockerfile(多阶段构建,尽量精简镜像体积):

代码语言:dockerfile
复制
# build
FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /src
COPY MemoryEater.java .
RUN javac MemoryEater.java

# run
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=build /src/MemoryEater.class /app/
# 不显式设置 Xmx,模拟很多老镜像里常见的默认配置问题
ENTRYPOINT java MemoryEater

触发 OOM 的清单memory-eater-oom.yaml):

代码语言:yaml
复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: memory-eater
spec:
  replicas: 1
  selector:
    matchLabels:
      app: memory-eater
  template:
    metadata:
      labels:
        app: memory-eater
    spec:
      containers:
        - name: eater
          image: your-registry/memory-eater:demo
          resources:
            requests:
              memory: 128Mi
              cpu: 100m
            limits:
              memory: 256Mi
              cpu: 500m

部署后观察一段时间,你会看到 STATUS 变为 OOMKilleddescribeReason: OOMKilled,退出码 137


解决方案:让 JVM 堆、容器 limitrequests 真正对齐

思路一:显式收敛 JVM 堆上限到容器 limit 的安全比例

JDK 10 以后默认支持容器感知,可用 -XX:MaxRAMPercentage 控制堆上限占比,并配合 -XX:+HeapDumpOnOutOfMemoryError 等参数提升可观测性。也可以增加容器 limit,但在混部场景要避免一味加大导致节点资源紧张。

修复版清单memory-eater-fixed.yaml):

代码语言:yaml
复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: memory-eater
spec:
  replicas: 1
  selector:
    matchLabels:
      app: memory-eater
  template:
    metadata:
      labels:
        app: memory-eater
    spec:
      containers:
        - name: eater
          image: your-registry/memory-eater:demo
          env:
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage=60
                -XX:InitialRAMPercentage=60
                -XX:+HeapDumpOnOutOfMemoryError
                -XX:HeapDumpPath=/heap/dump.hprof
                -XX:+ExitOnOutOfMemoryError
          volumeMounts:
            - name: heap
              mountPath: /heap
          resources:
            requests:
              memory: 384Mi
              cpu: 200m
            limits:
              memory: 512Mi
              cpu: 1
      volumes:
        - name: heap
          emptyDir: {}
  • MaxRAMPercentage=60 让堆保持在 limit 的 60% 左右,剩余 40% 给元空间、线程栈、直接内存与 JIT 等。
  • 打开 ExitOnOutOfMemoryError 让 JVM 失败即退出,避免僵尸进程;HeapDumpOnOutOfMemoryError 保留现场用于定位。对此类参数,也可通过 -XX:OnOutOfMemoryError 搭配 jstack 自动抓取线程信息。

思路二:把 requests 设成可信的基线

  • 先用负载与容量测试跑出稳定峰值,再把 requests.memory 设为 P95 甚至 P99 的实测值。
  • limits.memory 给出合理头寸(例如 requests 的 1.2~1.5 倍)。
  • 对于极端敏感的系统可采用 Guaranteedrequests == limits)以降低被驱逐的概率,但需要确保节点层面也留好缓冲。资源模型与内核 OOMKiller 行为在官方文档里有清晰解释。

思路三:避免把 137 与 OOMKilled 生搬硬套

如果只看到 137 而没有 Reason: OOMKilled,需要继续排查是否是探针超时、手动 kill -9、节点压力等引发的 SIGKILL


真实错误信息与截图

  • kubectl describe 中的 Reason: OOMKilledExit Code: 137 是诊断锚点。
  • 典型 Java heap space 堆栈如前文代码块所示,可作为模式匹配参考。
  • 顶部截图展示了 OOMKilled 事件在监控/通知里的呈现方式(Slack 报警与内存曲线)。

一份可直接落地的 CI/CD 片段(构建与部署)

构建镜像

代码语言:bash
复制
# 构建与推送
docker build -t your-registry/memory-eater:demo .
docker push your-registry/memory-eater:demo

部署与验证

代码语言:bash
复制
# 先部署触发 OOM 的版本观察
kubectl apply -f memory-eater-oom.yaml
kubectl get pods -w
kubectl describe pod -l app=memory-eater

# 替换为修复版本
kubectl apply -f memory-eater-fixed.yaml
kubectl rollout status deploy/memory-eater
kubectl logs -l app=memory-eater --tail=100

延伸思考:为什么这类问题在 Java 服务上更常见?

Java 生态经常叠加 Netty 直接内存、线程池、Metaspace、ClassLoader 动态加载、JIT 代码缓存等多源开销;只把 Xmx 当作总内存的代名词,往往会在容器内被打脸。官方与行业资料对 OOMKilledOutOfMemoryError 有更系统的解释与处置建议,可用作学习清单:


结合这次事故沉淀的避坑清单

  • JVM 与 K8s 的契约要写在镜像里:用 JAVA_TOOL_OPTIONS 固化 -XX:MaxRAMPercentage-XX:+ExitOnOutOfMemoryError-XX:+HeapDumpOnOutOfMemoryError,别把 Xmx 留成默认。
  • 不要让 requests 比实际基线低太多,也不要把 limits 卡得过死;根据真实流量的 P95/P99 去定数,而不是拍脑袋。
  • exit code 137 不是判定 OOM 的全部证据,务必结合 Reason: OOMKilled 与事件时间线交叉验证。
  • 压测要覆盖峰值工况,观察内存曲线是否逼近 limit;升级版本后留意曲线形态是否突变。
  • 有子进程模型时警惕 Invisible OOM:子进程被杀而 init 存活,K8s 侧不易察觉。为关键路径加守护与自检。
  • 监控与告警要上齐:Pod 层内存用量、容器 OOMKill 事件、节点内存压力,配合日志侧 OutOfMemoryError 关键字聚合。

参考与延伸阅读

  • K8s 官方:容器与 Pod 资源管理与内存请求/限制的行为边界。(Kubernetes)
  • OOMKilled 综述与排查步骤,包含典型命令与现象汇总。(Komodor)
  • 137 退出码与 SIGKILL 的关系与判读。(Groundcover)
  • Java heap space 异常的成因与处理。(Oracle Documentation)

这次事故的根源并不玄妙:当 JVM 的贪婪与 K8s 的刚性上限相遇,就会在曲线顶端撞一次墙。让 requestslimits 与 JVM 的内存策略彼此知根知底,才是在容器里跑 Java 的底层秩序。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 现象:频繁重启、接口偶发超时、Pod STATUS 为 OOMKilled
  • 排查思路与定位过程
  • 可复现的最小案例:一份能在 K8s 里被 OOMKilled 的 Java 容器
  • 解决方案:让 JVM 堆、容器 limit 与 requests 真正对齐
  • 真实错误信息与截图
  • 一份可直接落地的 CI/CD 片段(构建与部署)
  • 延伸思考:为什么这类问题在 Java 服务上更常见?
  • 结合这次事故沉淀的避坑清单
  • 参考与延伸阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档