记一次 Docker 容器反复重启引发的微服务链路雪崩

问题背景

公司有一组基于 Spring Boot 构建的订单服务,以 Docker 容器化方式部署在三台物理机上,每个节点运行 4 个实例,由 Nginx 做反向代理负载均衡。该服务是整个订单处理链路的核心上游,日均 QPS 稳定在 3000 左右。

故障现象

凌晨 2:00 左右,运维监控平台突然收到大量告警:订单服务 P99 延迟从正常的 200ms 飙升至 30s,5xx 错误率在 2 分钟内从 0% 上升到 45%。登录服务器后发现该服务的容器正处于 CrashLoopBackOff 状态:

1
2
3
4
$ docker ps -a | grep order-svc
f3a2b1c4d5e6   order-svc:v2.1.3   "java -jar app.jar"   Exited (137) 3 seconds ago
a1b2c3d4e5f6   order-svc:v2.1.3   "java -jar app.jar"   Exited (137) 15 seconds ago
...

所有实例均在启动后几十秒内被 Kill,退出码 137(SIGKILL),容器日志在退出前无任何异常堆栈,只有 Spring Boot 的标准启动日志。与此同时,上游的支付服务和库存服务也开始出现连接超时,业务链路出现雪崩式故障。

排查过程

第一步:排查容器退出原因

退出码 137 = 128 + 9(SIGKILL),容器被强制终止。Docker 发送 SIGKILL 最常见的原因是 OOM(Out of Memory)

查看系统日志确认:

1
2
3
$ dmesg -T | grep -i "out of memory" | tail -20
[Thu Jan 15 02:03:12 2024] Memory cgroup out of memory: Kill process 29183 (java) score 1087
[Thu Jan 15 02:04:15 2024] Memory cgroup out of memory: Kill process 30241 (java) score 1112

确认容器是 OOM Killer 杀死的。

第二步:检查容器资源配置

1
$ docker inspect order-svc | grep -A5 "HostConfig" | grep -E "Memory|CpuPeriod|CpuQuota"

发现容器的 --memory 限制为 512MB,但 JVM 的 -Xmx 参数设置为 384MB。理论上剩余 128MB 加上 JVM 的堆外内存应该足够,为什么还会 OOM?

第三步:分析 JVM 内存分布

使用 jcmd 在容器存活期间抓取 NMT(Native Memory Tracking)报告:

1
-XX:NativeMemoryTracking=summary

NMT 输出显示 Internal(Direct Buffer + JNI 等)内存持续增长,堆外内存从 60MB 涨到 180MB,已经接近容器总内存上限。进一步分析发现是一个改造后的 HTTP 客户端库没有释放 DirectByteBuffer,每次建立新连接都会分配堆外内存,连接关闭后并未归还。

第四步:确认雪崩根因

上游调用方使用 Feign + Ribbon 做客户端负载均衡。当所有 order-svc 实例不断重启时,Ribbon 的重试机制在短时间内发出大量重试请求,加上连接池未及时关闭,最终耗尽上游服务的线程池,导致整体链路雪崩。

解决方案

  1. 紧急修复:将 --memory 上限临时提升到 1GB,并重启所有实例。
  2. 修复内存泄漏:定位 HTTP 客户端库中未释放 DirectByteBuffer 的代码,显式调用 ((DirectBuffer) buffer).cleaner().clean()
  3. 添加 JVM 参数-XX:MaxDirectMemorySize=128m,限制堆外内存上限,防止无限增长。
  4. 配置 Hystrix 熔断:在 Feign 客户端面添加熔断降级逻辑,避免上游被拖垮。
  5. Docker healthcheck 优化:增加健康检查,在容器被 Kill 前主动介入。

根因分析

直接原因:HTTP 客户端库的 DirectByteBuffer 泄漏 → 堆外内存持续增长 → 容器内存超限 → OOM Kill → 容器重启 → 泄漏重新开始 → 恶性循环。

深层原因:Docker 内存限制(512MB)过于紧凑,对 JVM 堆外内存的预留不足。JVM 的 -Xmx 只约束了堆内存,堆外内存(Metaspace、Direct Buffer、线程栈)完全不受限制,很容易超出容器限制。

预防措施

  1. 容器内存分配公式:总内存 ≥ Xmx + MaxDirectMemorySize + MetaspaceSize + (线程数 × 栈大小) + 系统预留 100MB
  2. 使用 -XX:+UseContainerSupport:让 JVM 感知容器内存限制
  3. 压测验证:上线前对每个服务做内存压力测试,确认内存稳定区间
  4. 监控堆外内存:接入 Prometheus JMX Exporter,监控堆外内存增长趋势

总结

JVM 应用容器化需要特别注意内存规划的完整性。-Xmx 只控制堆内存,加上 Metaspace、Direct Buffer、线程栈等堆外开销,实际内存远大于堆内存。推荐使用 -XX:MaxRAMPercentage=75.0 替代硬编码的 -Xmx,让 JVM 自适应容器内存限制。同时,任何直接操作 DirectByteBuffer 的代码都需要严格的内存生命周期管理,否则泄漏问题会在容器环境下被 OOM Killer 无情放大。

使用 Hugo 构建
主题 StackJimmy 设计