一次Docker容器OOM引发的服务雪崩排查实录

问题背景

周三晚上 8 点 23 分,钉钉群里炸了锅。

“发货中心挂了,后台全部 502!”——运营同事的消息连着发了 5 条。紧接着,运维监控大屏上 3 个核心服务的健康检查指示灯全部由绿转红。

我们平台是一个电商 SaaS 系统,发货服务(delivery-service)是核心链路里的一环,负责生成电子面单、调用快递接口、更新订单状态。晚 8 点到 10 点是发货高峰期,如果这个点挂了——当天的订单基本全积压。

技术栈:Docker Swarm 管理集群,每个服务至少 3 个副本。发货服务是 Java 11 + Spring Boot 2.7,跑在 8 台 16 核 32G 的物理机上。

紧急程度:P0(核心业务中断),运维同事已经开始手动重启容器,但问题是——重启完不到 3 分钟又崩了。

故障现象

第一波告警来自 Nginx 反向代理层:

1
2
2026-06-18 20:23:15 [error] upstream prematurely closed connection while reading response header from upstream
2026-06-18 20:23:16 [error] connect() failed (111: Connection refused) while connecting to upstream

同时,Prometheus 监控面板显示 delivery-service 的可用副本数在 3 → 2 → 1 → 0 之间快速跳变:

  • 20:23:3 个副本正常运行
  • 20:24delivery-service-2 状态变为 unhealthy,被 Swarm 调度器踢出
  • 20:25delivery-service-1 也挂了,仅剩 delivery-service-3
  • 20:27:最后一个副本也倒下,所有请求 502

登上管理节点 docker service ps delivery-service 一看:

1
2
3
4
5
ID       NAME                  NODE     DESIRED STATE   CURRENT STATE          ERROR
x7a2f3 delivery-service.1 node03 Running Running 12 seconds ago
8b3k9m delivery-service.1 node03 Shutdown Failed 15 seconds ago "task: non-zero exit (137)"
p1q5r7 delivery-service.2 node05 Running Running 8 seconds ago
n4j6w8 delivery-service.2 node05 Shutdown Failed 10 seconds ago "task: non-zero exit (137)"

Exit code 137 是 Docker 世界的死亡代码——容器被 OOM Killer 干掉了(137 = 128 + 9,SIGKILL)。

更让人头疼的是:delivery-service 挂了之后,同一台宿主机上的 order-servicenotify-service 也开始出现间歇性 502。问题在蔓延。

排查过程

第一步:止血——先让服务活过来

盲目重启没用,每次启动不到 3 分钟就挂。当务之急是把 delivery-service 的副本数临时扩容,让它至少能扛到我们找到根因:

1
2
# 先用更多副本来分摊压力,争取排查时间
docker service scale delivery-service=6

但这是饮鸩止渴——如果问题是内存泄漏,副本越多,整个集群的内存压力越大。

果然,扩容到 6 个之后,宿主机 node03 上的 order-service 容器直接被 OOM Kill 了。这下确定了一个事实:问题不只是 delivery-service 本身,宿主机的内存也在吃紧

第二步:看 OOM 日志——谁在吃内存

登录 node03

1
2
# 查看系统 OOM 日志
dmesg -T | grep -i oom | tail -20

输出触目惊心:

1
2
3
4
5
6
7
8
[Wed Jun 18 20:23:45 2026] oom-kill:constraint=CONSTRAINT_MEMCG,
oom_memcg=/docker/abc123def456,
task_memcg=/docker/abc123def456,
task=java, pid=28471, uid=0

[Wed Jun 18 20:23:45 2026] Memory cgroup out of memory: Killed process 28471 (java)
total-vm:12582912kB, anon-rss:9467808kB, file-rss:0kB, shmem-rss:0kB
oom_score_adj:0

关键数据:anon-rss:9467808kB——这个 Java 进程的常驻内存占了 9.2 GB

再看容器级别的内存曲线:

1
2
3
# 查看被 Kill 容器的资源使用历史
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" \
| grep delivery

正常启动时,delivery-service 内存使用约 1.2 GB。但每处理一批发货请求,内存就涨一点,从不回落。启动 3 分钟后直奔 9 GB,触发了 Swarm 为这个服务设置的 --memory=10g 上限(已逼近宿主机 32G 总内存的警戒线)。

第三步:抓 Java 堆——确认泄漏对象

在容器还没挂的时候(黄金窗口只有启动后的 2 分钟内),紧急上去抓堆:

1
2
3
4
5
6
7
8
# 进入容器
docker exec -it delivery-service.3.abc123 /bin/bash

# 抓 heap dump
jmap -dump:live,format=b,file=/tmp/heap.hprof 1

# 快速看下 top 对象
jmap -histo:live 1 | head -30

histo 输出前三名:

1
2
3
4
num     #instances         #bytes  class name
1: 3284512 262760960 [B
2: 1558327 174533424 java.util.LinkedHashMap$Entry
3: 1539820 123185600 com.xxx.delivery.cache.WaybillCache

WaybillCache 这个自定义缓存对象有 150 万个实例,占了约 120MB。但真正吞内存的是 [B(字节数组)——3.2 亿字节、约 **250MB 的 byte[]**。

WaybillCache 里每个对象都持有一个 byte[](快递公司返回的面单 PDF 数据),一个面单 PDF 约 80KB。正常业务场景下,面单生成后应该被序列化到 OSS 并从内存中释放,但现在它们全滞留在了 LinkedHashMap 里。

第四步:读代码——缓存为什么不清

找到 WaybillCache 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class WaybillCache {
// 问题代码:没有大小限制,没有过期策略
private static final Map<String, byte[]> CACHE =
new LinkedHashMap<>();

public void put(String orderNo, byte[] pdfData) {
CACHE.put(orderNo, pdfData);
}

public byte[] get(String orderNo) {
return CACHE.get(orderNo);
}
// 没有 remove(),没有 evict(),没有 TTL
}

根因一目了然:

  1. LinkedHashMap 没有任何淘汰策略——没有 removeEldestEntry(),没有最大容量
  2. 写入后从不删除——面单生成后存在 Map 里,直到 JVM OOM
  3. 没有 TTL——即使订单已经发货完毕,缓存里的 PDF 数据永远不释放

每次发货请求都往这个 Map 里 put 一次。晚高峰每秒 200 单的发货量,1 分钟就是 12000 条记录 × 80KB ≈ 960MB。3 分钟直接撑爆 10G 内存限制。

第五步:验证其他宿主机

检查其他 7 台机器的 OOM 日志,发现 node05node07 也在同一时刻触发了 OOM。问题不是单机故障,是整个 delivery-service 集群的系统性内存泄漏。

而且,OOM Killer 在回收 delivery-service 时,同一台宿主机的其他容器也会被牵连——内核 OOM 打分机制下,order-serviceoom_score 也不低,所以出现了连带 Kill。

解决方案

1. 紧急修复——代码热修复 + 重启

运维同事紧急拉了一个 hotfix 分支,把 LinkedHashMap 改成基于 Caffeine 的有界缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;

@Component
public class WaybillCache {
private final Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最多存 1000 条
.expireAfterWrite(5, TimeUnit.MINUTES) // 5 分钟过期
.removalListener((key, value, cause) -> {
log.info("WaybillCache evicted: {}, cause: {}", key, cause);
})
.build();

public void put(String orderNo, byte[] pdfData) {
cache.put(orderNo, pdfData);
}

public byte[] get(String orderNo) {
return cache.getIfPresent(orderNo);
}
}

CI/CD 流水线跑完,重新部署:

1
2
3
4
5
6
# 滚动更新
docker service update --image registry.xxx.com/delivery-service:hotfix-v2 \
--limit-memory 8g --limit-memory-reservation 4g delivery-service

# 观察启动
docker service ps delivery-service --no-trunc

2. 容器层加固

在 docker-compose / stack 文件里加了硬限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
services:
delivery-service:
image: registry.xxx.com/delivery-service:latest
deploy:
replicas: 3
resources:
limits:
memory: 8G # 硬上限
reservations:
memory: 4G # 软预留
environment:
- JAVA_OPTS=-Xms2g -Xmx6g -XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof

要点说明:

  • -Xmx6g 给 JVM 堆设了明确上限,留 2G 给堆外内存和 OS;
  • -XX:+HeapDumpOnOutOfMemoryError:再出问题自动 dump,省掉了上去手动 jmap 的黄金窗口压力;
  • 容器 memory: 8G 硬限制,超过直接 Kill,不拖累宿主机上其他容器。

3. 监控告警

在 Prometheus 里加了容器级别的内存告警规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
groups:
- name: container_memory
rules:
- alert: ContainerMemoryHigh
expr: container_memory_working_set_bytes{name=~"delivery-service.*"}
/ container_spec_memory_limit_bytes > 0.8
for: 2m
labels:
severity: warning
annotations:
summary: "delivery-service 内存使用超过 80%"

- alert: ContainerOOMKilled
expr: rate(container_oom_events_total[5m]) > 0
labels:
severity: critical
annotations:
summary: "检测到容器被 OOM Kill"

根因分析

直接原因:WaybillCache 使用无界 LinkedHashMap 做面单 PDF 数据缓存,写入后从不淘汰,高峰流量下内存持续线性增长直至 OOM。

深层原因有三层:

  1. 代码层面:开发同学对缓存基础组件缺乏敬畏,”一个 Map 就是缓存”的认知导致没有考虑淘汰策略和容量上限。任何缓存系统必须回答三个问题:最大容量多少?什么条件下淘汰?淘汰策略是什么?LinkedHashMap 不代表它就是安全的缓存。

  2. 流程层面:这个 WaybillCache 是一个月前由一位离职同事写的,Code Review 时没有人关注缓存的容量和淘汰策略。CR 只看了业务逻辑对不对,没看资源安全和边界条件。

  3. 运维层面:容器 OOM 告警缺失。直到用户报障、监控大屏亮红灯才知道出了问题。晚 8 点之前其实内存就在涨了,如果 70% 的时候就有告警,完全可以提前介入。

预防措施

  1. 缓存规范:团队内部定了缓存使用规范——禁止裸用 HashMap / LinkedHashMap 做缓存;必须使用有界缓存(Caffeine / Guava Cache),强制设置 maximumSizeexpireAfterWrite。在 CI 里加了简单的静态检查规则,扫描 new LinkedHashMapnew HashMap 出现在 Cache 相关类里会报警。

  2. JVM 参数标准化:所有 Java 服务统一带上 -XX:+HeapDumpOnOutOfMemoryError,生产环境预留至少 20% 容器内存给堆外。

  3. 容器资源限制白盒化:在 CI 打包阶段自动校验 Dockerfile 或编排文件里是否声明了 resources.limits.memory,没声明的不允许上线。

  4. 监控前置:每条业务线至少配置 3 条基础告警——CPU > 80%、内存 > 80%、容器重启次数 > 0。告警阈值在代码合入 production 分支时自动生成 Ansible playbook 推送到 Prometheus。

  5. 压测 + 内存 Profile:所有涉及缓存的模块,要求在上线前做一次至少 30 分钟的压测,附带 JVM 内存趋势图,确认没有持续增长。

总结

这次故障从发现到修复历时 47 分钟,影响订单发货约 12000 单。教训非常深刻:

**第一,容器给运维带来了便利,也带来了”邻居效应”**。以前物理机部署,一个应用内存泄漏大不了重启自己。Docker 环境下,一个容器的无限制增长会拖累整个宿主机的内存池,OOM Killer 的连带伤害让问题变成雪崩。

第二,缓存不是”先跑起来再优化”的东西。无界缓存在生产环境就是定时炸弹,只是你不知道引线有多长。晚高峰就是那根火柴。

第三,OOM 排查的顺序很重要:先看 dmesg 确认是 OOM → 看 docker stats 确定是哪个容器 → jmap 抓堆确定是什么对象泄漏 → 读代码定位根因。不要跳步,不要上来就怀疑网络、怀疑数据库,exit code 137 就是最诚实的线索

运维圈有一句话:”没有容量上限的缓存,就是内存泄漏的另一种写法。”