一次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 | 2026-06-18 20:23:15 [error] upstream prematurely closed connection while reading response header from upstream |
同时,Prometheus 监控面板显示 delivery-service 的可用副本数在 3 → 2 → 1 → 0 之间快速跳变:
- 20:23:3 个副本正常运行
- 20:24:
delivery-service-2状态变为unhealthy,被 Swarm 调度器踢出 - 20:25:
delivery-service-1也挂了,仅剩delivery-service-3 - 20:27:最后一个副本也倒下,所有请求 502
登上管理节点 docker service ps delivery-service 一看:
1 | ID NAME NODE DESIRED STATE CURRENT STATE ERROR |
Exit code 137 是 Docker 世界的死亡代码——容器被 OOM Killer 干掉了(137 = 128 + 9,SIGKILL)。
更让人头疼的是:delivery-service 挂了之后,同一台宿主机上的 order-service 和 notify-service 也开始出现间歇性 502。问题在蔓延。
排查过程
第一步:止血——先让服务活过来
盲目重启没用,每次启动不到 3 分钟就挂。当务之急是把 delivery-service 的副本数临时扩容,让它至少能扛到我们找到根因:
1 | # 先用更多副本来分摊压力,争取排查时间 |
但这是饮鸩止渴——如果问题是内存泄漏,副本越多,整个集群的内存压力越大。
果然,扩容到 6 个之后,宿主机 node03 上的 order-service 容器直接被 OOM Kill 了。这下确定了一个事实:问题不只是 delivery-service 本身,宿主机的内存也在吃紧。
第二步:看 OOM 日志——谁在吃内存
登录 node03:
1 | # 查看系统 OOM 日志 |
输出触目惊心:
1 | [Wed Jun 18 20:23:45 2026] oom-kill:constraint=CONSTRAINT_MEMCG, |
关键数据:anon-rss:9467808kB——这个 Java 进程的常驻内存占了 9.2 GB!
再看容器级别的内存曲线:
1 | # 查看被 Kill 容器的资源使用历史 |
正常启动时,delivery-service 内存使用约 1.2 GB。但每处理一批发货请求,内存就涨一点,从不回落。启动 3 分钟后直奔 9 GB,触发了 Swarm 为这个服务设置的 --memory=10g 上限(已逼近宿主机 32G 总内存的警戒线)。
第三步:抓 Java 堆——确认泄漏对象
在容器还没挂的时候(黄金窗口只有启动后的 2 分钟内),紧急上去抓堆:
1 | # 进入容器 |
histo 输出前三名:
1 | num #instances #bytes class name |
WaybillCache 这个自定义缓存对象有 150 万个实例,占了约 120MB。但真正吞内存的是 [B(字节数组)——3.2 亿字节、约 **250MB 的 byte[]**。
WaybillCache 里每个对象都持有一个 byte[](快递公司返回的面单 PDF 数据),一个面单 PDF 约 80KB。正常业务场景下,面单生成后应该被序列化到 OSS 并从内存中释放,但现在它们全滞留在了 LinkedHashMap 里。
第四步:读代码——缓存为什么不清
找到 WaybillCache 的实现:
1 |
|
根因一目了然:
LinkedHashMap没有任何淘汰策略——没有removeEldestEntry(),没有最大容量- 写入后从不删除——面单生成后存在 Map 里,直到 JVM OOM
- 没有 TTL——即使订单已经发货完毕,缓存里的 PDF 数据永远不释放
每次发货请求都往这个 Map 里 put 一次。晚高峰每秒 200 单的发货量,1 分钟就是 12000 条记录 × 80KB ≈ 960MB。3 分钟直接撑爆 10G 内存限制。
第五步:验证其他宿主机
检查其他 7 台机器的 OOM 日志,发现 node05、node07 也在同一时刻触发了 OOM。问题不是单机故障,是整个 delivery-service 集群的系统性内存泄漏。
而且,OOM Killer 在回收 delivery-service 时,同一台宿主机的其他容器也会被牵连——内核 OOM 打分机制下,order-service 的 oom_score 也不低,所以出现了连带 Kill。
解决方案
1. 紧急修复——代码热修复 + 重启
运维同事紧急拉了一个 hotfix 分支,把 LinkedHashMap 改成基于 Caffeine 的有界缓存:
1 | import com.github.benmanes.caffeine.cache.Cache; |
CI/CD 流水线跑完,重新部署:
1 | # 滚动更新 |
2. 容器层加固
在 docker-compose / stack 文件里加了硬限制:
1 | services: |
要点说明:
-Xmx6g给 JVM 堆设了明确上限,留 2G 给堆外内存和 OS;-XX:+HeapDumpOnOutOfMemoryError:再出问题自动 dump,省掉了上去手动jmap的黄金窗口压力;- 容器
memory: 8G硬限制,超过直接 Kill,不拖累宿主机上其他容器。
3. 监控告警
在 Prometheus 里加了容器级别的内存告警规则:
1 | groups: |
根因分析
直接原因:WaybillCache 使用无界 LinkedHashMap 做面单 PDF 数据缓存,写入后从不淘汰,高峰流量下内存持续线性增长直至 OOM。
深层原因有三层:
代码层面:开发同学对缓存基础组件缺乏敬畏,”一个 Map 就是缓存”的认知导致没有考虑淘汰策略和容量上限。任何缓存系统必须回答三个问题:最大容量多少?什么条件下淘汰?淘汰策略是什么? 用
LinkedHashMap不代表它就是安全的缓存。流程层面:这个
WaybillCache是一个月前由一位离职同事写的,Code Review 时没有人关注缓存的容量和淘汰策略。CR 只看了业务逻辑对不对,没看资源安全和边界条件。运维层面:容器 OOM 告警缺失。直到用户报障、监控大屏亮红灯才知道出了问题。晚 8 点之前其实内存就在涨了,如果 70% 的时候就有告警,完全可以提前介入。
预防措施
缓存规范:团队内部定了缓存使用规范——禁止裸用
HashMap/LinkedHashMap做缓存;必须使用有界缓存(Caffeine / Guava Cache),强制设置maximumSize和expireAfterWrite。在 CI 里加了简单的静态检查规则,扫描new LinkedHashMap和new HashMap出现在Cache相关类里会报警。JVM 参数标准化:所有 Java 服务统一带上
-XX:+HeapDumpOnOutOfMemoryError,生产环境预留至少 20% 容器内存给堆外。容器资源限制白盒化:在 CI 打包阶段自动校验 Dockerfile 或编排文件里是否声明了
resources.limits.memory,没声明的不允许上线。监控前置:每条业务线至少配置 3 条基础告警——CPU > 80%、内存 > 80%、容器重启次数 > 0。告警阈值在代码合入 production 分支时自动生成 Ansible playbook 推送到 Prometheus。
压测 + 内存 Profile:所有涉及缓存的模块,要求在上线前做一次至少 30 分钟的压测,附带 JVM 内存趋势图,确认没有持续增长。
总结
这次故障从发现到修复历时 47 分钟,影响订单发货约 12000 单。教训非常深刻:
**第一,容器给运维带来了便利,也带来了”邻居效应”**。以前物理机部署,一个应用内存泄漏大不了重启自己。Docker 环境下,一个容器的无限制增长会拖累整个宿主机的内存池,OOM Killer 的连带伤害让问题变成雪崩。
第二,缓存不是”先跑起来再优化”的东西。无界缓存在生产环境就是定时炸弹,只是你不知道引线有多长。晚高峰就是那根火柴。
第三,OOM 排查的顺序很重要:先看 dmesg 确认是 OOM → 看 docker stats 确定是哪个容器 → jmap 抓堆确定是什么对象泄漏 → 读代码定位根因。不要跳步,不要上来就怀疑网络、怀疑数据库,exit code 137 就是最诚实的线索。
运维圈有一句话:”没有容量上限的缓存,就是内存泄漏的另一种写法。”