背景
生产环境一台 MGT 服务 Pod 在无任何预警的情况下被重启, 多个接口响应十分慢。本文记录从发现到定位根因的完整排查过程,涵盖应用日志、kubectl describe、kubelet 探针日志、dmesg 节点日志和 error.log 五个维度的交叉分析。
环境信息
- Pod :
***-dr-6dbcd6dfcc-9fg92 - 命名空间: ``
- 环境: dr(灾备)
- 对比环境 :
gray-***-dr(灰度环境,同期未发生重启) - JVM 配置 :
-Xmx2G -Xms2G -XX:+UseG1GC - 容器内存限制: 16Gi
- 探针配置 : HTTP GET
***/index.do, timeout=3s, interval=15s, failureThreshold=5
排查过程
第一步:应用日志分析
从应用日志发现,Pod 在 20:10:03 之后日志完全中断,直到 20:12:07 新 JVM 启动,中间有 124 秒的空白。
最后一条日志仍然是正常的业务请求:
2026-08-20 20:10:03.749 [http-nio-8080-exec-73] INFO
...path= ***//execute.do ...
第二步:Kubernetes 层面排查
kubectl describe pod 显示:
Last State: Terminated
Reason: Error
Exit Code: 137
Finished: 20:13:08
Exit Code 137 = SIGKILL,说明进程是被外部强制杀死的,不是 JVM 自己崩溃退出。
第三步:kubelet 探针日志分析
通过节点上的 kubelet 日志(journalctl -u kubelet),可以精确还原探针失败的全过程:
2026-08-20T20:11:37.885343+08:00 kubelet prober.go:107 "Probe failed" probeType="Liveness"
pod="/***-dr-6dbcd6dfcc-9fg92"
output="Get "http://10.214.201.2:8080***/index.do":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
2026-08-20T20:11:37.885343+08:00 kubelet prober.go:107 "Probe failed" probeType="Readiness"
pod="/***-dr-6dbcd6dfcc-9fg92"
output="Get "http://10.214.201.2:8080***/index.do":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
2026-08-20T20:11:52.880973+08:00 kubelet prober.go:107 "Probe failed" probeType="Liveness"
... (同上,第2次失败)
2026-08-20T20:11:52.880973+08:00 kubelet prober.go:107 "Probe failed" probeType="Readiness"
... (第2次失败)
2026-08-20T20:12:07.885030+08:00 kubelet prober.go:107 "Probe failed" probeType="Liveness"
... (第3次失败)
2026-08-20T20:12:07.885030+08:00 kubelet prober.go:107 "Probe failed" probeType="Readiness"
... (第3次失败)
2026-08-20T20:12:22.881043+08:00 kubelet prober.go:107 "Probe failed" probeType="Liveness"
... (第4次失败)
2026-08-20T20:12:22.881043+08:00 kubelet prober.go:107 "Probe failed" probeType="Readiness"
... (第4次失败)
关键发现:
- 探针错误类型一致 :每次都是
context deadline exceeded (Client.Timeout exceeded while awaiting headers),说明应用完全无法在 3 秒超时内返回 HTTP Header,处于彻底无响应状态 - Liveness 和 Readiness 同时失败:每次探测周期,两个探针同时失败,说明 Pod 已经整体不可用
- 失败间隔严格 15 秒:每次失败间隔正好 15 秒(与探针 interval 配置一致),说明 kubelet 的探测行为完全正常,问题出在应用侧
- 连续失败 4 次记录:日志中记录了 4 次失败(20:11:37、20:11:52、20:12:07、20:12:22),加上 failureThreshold=5,第 5 次失败后(约 20:12:37)kubelet 判定容器 unhealthy,触发重启
第四步:dmesg 节点日志分析
在宿主机上执行 dmesg 命令,查看内核环形缓冲区日志。通过分析 Pod 网络接口的创建和销毁事件,可以辅助判断 Pod 的生命周期变化:
[Thu Aug 20 20:28:29 2026] device vvport2921 entered promiscuous mode
[Thu Aug 20 20:28:29 2026] eth0: renamed from vport2921
[Thu Aug 20 20:32:38 2026] device vvport2748 left promiscuous mode
[Thu Aug 20 20:33:29 2026] device vvport2922 entered promiscuous mode
[Thu Aug 20 20:33:30 2026] eth0: renamed from vport2922
[Thu Aug 20 20:44:13 2026] device vvport2921 left promiscuous mode
[Thu Aug 20 20:45:50 2026] device vvport2688 left promiscuous mode
dmesg 的作用 :虽然 dmesg 中并未直接显示 OOM Kill 事件,但通过 vport 网络接口的创建(entered promiscuous mode)和销毁(left promiscuous mode)事件,可以辅助判断 Pod 的启停时间点,帮助还原故障时间线。这也说明 Pod 并非被内核 OOM Killer 直接杀掉------如果是 OOM Kill,dmesg 中会有明确的 Out of memory: Kill process 记录。
第五步:HTTP线程分析
k8s探活请求没有被处理,我猜测是200个HTTP线程池被打满,但是观察prometheus监控面板,200个tomat线程并没有被打满
第六步:关键错误日志
在 error.log 中找到了决定性证据:
2026-08-20 20:09:40.389 [http-nio-8080-exec-15] ERROR
o.a.c.h.Http11NioProtocol.log - Failed to complete processing of a request
java.lang.OutOfMemoryError: Metaspace
根因分析
Metaspace OOM
有问题的应用代码导致不断的产生新的类,最终metaspace被消耗完
完整时间线
| 时间 | 来源 | 事件 |
|---|---|---|
| 20:09:40.379 | app log | 最后一次存活探针成功 |
| 20:09:40.389 | error.log | Metaspace OOM 发生 |
| 20:09:42.952 | app log | 就绪探针仍成功,最后一批业务请求正常处理 |
| 20:09:55 ~ 20:10:03 | app log | 探针全部失败,业务请求量骤降,最后一条应用日志 |
| 20:11:37 | kubelet log | kubelet 首次记录探针失败(prober.go:107),Liveness/Readiness 同时失败 |
| 20:11:52 | kubelet log | 第2次探针失败 |
| 20:12:07 | kubelet log | 第3次探针失败 |
| 20:12:07 | app log | 新 JVM 启动(旧容器仍在运行) |
| 20:12:22 | kubelet log | 第4次探针失败 |
| 20:12:37 | kubectl | kubelet 判定存活探针失败(连续 5 次),触发重启 |
| 20:12:38 | kubectl | preStop hook 执行失败(stop.sh 缺失) |
| 20:13:08 | kubectl/dockerd | SIGTERM 超时,发送 SIGKILL,旧容器终止 |
| 20:13:08 | kubectl | 新容器开始启动,服务逐步恢复 |
为什么 Metaspace OOM 会导致整体不可用?
Metaspace 存储的是类的元数据(类名、方法、字段等)。虽然 OOM 只发生在一个线程上,Tomcat 的 ConnectionHandler 也 catch 了这个 Error(见 AbstractProtocol.java 源码),但 Metaspace 满了是 JVM 全局状态。后续任何需要加载新类或反射的操作都会失败,导致 Tomcat 的 NIO 处理层逐步退化,最终完全无法处理任何请求。
关键点:从 SIGTERM 发出到 SIGKILL 的 30 秒内,容器无法正常退出,说明 JVM 内部已经严重僵死,连 shutdown hook 都无法正常执行。
灰度环境对比
gray-***-dr 同期未发生任何异常重启。两个环境镜像版本、配置基本一致,差异在于灰度环境流量较小,Metaspace 的增长速度更慢。这进一步印证了"长期运行 + 流量压力 → Metaspace 累积增长 → 最终溢出"的根因链条。
补充说明:Pod 并非被内核 OOM Killer 杀掉
通过 dmesg 日志可以确认,宿主机内核并未触发 OOM Killer。如果是 cgroup 内存超限导致的内核 OOM Kill,dmesg 中会有明确的 Out of memory: Kill process 记录,且 Exit Code 会是 137(SIGKILL from OOM Killer)。本次 Exit Code 137 的来源是 kubelet 因探针连续失败而发出的 SIGKILL,根因是 Metaspace OOM 导致 JVM 僵死 → 探针超时 → kubelet 杀容器。
解决方案
1. 设置 Metaspace 上限(立即生效)
bash
-XX:MaxMetaspaceSize=1024m
2. 添加 OOM 自动 dump 和快速失败参数
bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=app/logs/
-XX:+ExitOnOutOfMemoryError
ExitOnOutOfMemoryError 可以让 JVM 在 OOM 时立即退出,避免进入"僵尸"状态,配合 K8s 自动重启机制可以更快恢复服务。
3. 排查类加载泄漏
长期运行后 Metaspace 持续增长,排查方向:
- 动态代理(CGLIB、Javassist)
- 反射调用
- Groovy 脚本引擎
- 频繁的 ClassLoader 创建
总结
这次排查的关键收获:
- ExitOnOutOfMemoryError 很重要:OOM 后让 JVM 快速失败,而非进入僵尸状态,配合 K8s 自动重启更快恢复
- dmesg 辅助排除:dmesg 虽然没有直接显示 OOM 事件,但通过 vport 网络接口事件帮助排除内核 OOM Killer,确认 Pod 并非被内核因内存超限而杀
- Exit Code 137 不等于 OOM Kill:也可能是 kubelet 因探针失败发出的 SIGKILL。需要结合 kubelet 探针日志、dmesg、error.log 和 kubectl describe 综合判断
- 探针失败是结果,不是原因:不要停留在"探针失败导致重启",要追问"为什么探针会失败"