容器跑了两天,啪——退出码 137 重启。日志半句话戛然而止,没异常、没栈。退出码 137 是 128 + 9 = SIGKILL,在容器里这几乎只有一种来源:内核 OOM killer 撞上了你的 --memory 上限。
最快路径: 跑 docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' my-container。如果打印 true 137,就是内核因为进程超出内存上限把它 kill 了。接下来无非两种情况:上限对正常峰值太紧(先测量再提高),或者进程在漏、给多少都迟早 OOM(去找泄漏,别光提上限)。下面这篇会确认你属于哪一种、用堆 dump 找出泄漏,并加一条告警,让它不再悄无声息地挂。
你属于哪一类?
| 症状 | 可能原因 | 跳到 |
|---|---|---|
| 流量峰值时 OOM,峰值之间 RSS 稳定 | 上限对峰值太紧 | Step 4 |
| RSS 几小时/几天一路涨,跟流量无关 | 泄漏 | Step 3,再 Step 5 |
| 重启周期很规律(比如每 36 小时一次) | 慢泄漏 | Step 3,再 Step 5 |
| 语言堆很健康,但 RSS 约 2 倍 | 原生/堆外内存分配 | Step 3(见说明) |
OOMKilled: false,但退出码是 137 | 宿主机级 OOM、liveness probe 或手动 kill | 见 Step 1 的说明 |
常见原因
按踩坑频率排序。
1. 上限对正常峰值太紧
你从某个 Helm chart 抄了 --memory=256m,实际工作集峰值是 400 MB。每次流量上来就 OOM。
怎么判断:docker inspect 显示 "Memory": 268435456;流量峰值时 RSS 贴着上限。
2. 应用代码慢泄漏
进程 RSS 几小时或几天一路单调上涨,最后撞上限。重启周期变得很有规律(比如每 36 小时一次)。
怎么判断:docker stats 时序图单调上扬,流量下来后也不回落。
3. 进程内无界缓存
用 Map/dict 当缓存、没有淘汰。每个新 key 都长一条,内存无止境涨。
怎么判断:堆快照显示一个 Map(或 dict)实例有几十万条 entry。
4. 没上限的连接池
ORM 每个请求新建连接、不归还。每条连接吃几 MB,并发越高内存越大。
怎么判断:指标里池大小超过文档上限;堆里有大量 DB driver 对象。
5. 原生内存分配,堆 profiler 看不到
Node 的 Buffer、Python 的 NumPy 数组、Go 的 cgo 分配,以及 JVM/.NET 的堆外内存,都在语言堆之外。OOM killer 会把它们算进你的 cgroup,但语言层 profiler 看不见。
怎么判断:语言堆很健康,但 cgroup 的 RSS 大约翻倍。拿 cat /sys/fs/cgroup/memory.current(内核实际计的量)和运行时报告的堆大小对比一下。
最短修复路径
Step 1: 确认是 OOM killer
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' my-container
# 期望:true 137
# 内核日志(根命名空间,在宿主机上跑)
dmesg | grep -i 'oom\|killed process'
# 或者用 journald:
sudo journalctl -k | grep -i 'oom\|killed process'
# 找:Memory cgroup out of memory: Killed process 1234 (node)
cgroup 范围内的 kill 会打印 Memory cgroup out of memory;整机级别的 kill 打印的是 Out of memory: Killed process ...,没有 cgroup 前缀。
如果 OOMKilled 是 false、但退出码是 137, 那 SIGKILL 来自容器自身上限以外的地方:
- 宿主机内存耗尽。 即使没设
--memory,整机内存不够时内核 OOM killer 也可能挑中你的进程。这时docker inspect报OOMKilled: false,因为这是宿主机级、而非 cgroup 上限触发的事件。在dmesg里找没有cgroup前缀的Out of memory,并在宿主机上free -m。 - Orchestrator liveness probe kill 了无响应的容器(Kubernetes 会记在
kubectl describe pod里)。 - 手动
docker kill,或某次发布发了 SIGKILL。
Step 2: 看清当前上限和实际占用
docker stats --no-stream my-container
# MEM USAGE / LIMIT,比如 245.3MiB / 256MiB
# 容器里(cgroup v2——现代宿主机的默认)
cat /sys/fs/cgroup/memory.max # 硬上限(值为 "max" 表示不限)
cat /sys/fs/cgroup/memory.current # 当前计入的占用
在宿主机上、用 systemd cgroup driver(cgroup v2)时,上限文件按容器 scope 命名:
cat /sys/fs/cgroup/system.slice/docker-<container-id>.scope/memory.max
关于 docker stats 的坑: MEM USAGE 这一列会减去可回收的 page cache(inactive_file)来近似工作集,所以它读出来可能比 OOM killer 实际计的量更低——内核评估的是整个 cgroup 的占用,包括堆外/原生内存。如果 docker stats 看着没事却还是 OOM,那就以 memory.current 和 dmesg 为准,别信 stats 那一列。
Kubernetes:
kubectl top pod my-pod
kubectl describe pod my-pod | grep -A3 -iE 'memory|OOMKilled|Last State'
kubectl describe 会显示 Last State: Terminated, Reason: OOMKilled 和重启次数,正好坐实这个循环。注意 Kubernetes 对两种不同事件都会记 Reason: OOMKilled:一种是容器上限 OOM(pod 超出了自己的 limits.memory),另一种是节点超卖 OOM(节点整体内存耗尽,即便 pod 没超自己的上限,kubelet 也会驱逐、或内核会把它 kill 掉)。如果 kubectl describe pod 显示容器没超上限却还是 OOMKilled,用 kubectl describe node | grep -A5 MemoryPressure 看节点压力,并通过调高 requests.memory 来降低超卖。
Step 3: profile 堆
拍两张快照(基线一张、加压后一张)再对比。一张是照片,两张才是故事。按 Retained Size(保留大小)排序,别按 Shallow Size:retained 是这个对象被回收后能释放的内存,泄漏会浮到最上面。
Node.js——容器里更顺手的做法是用信号触发,省得开调试端口。启动时带上 --heapsnapshot-signal=SIGUSR2(Node 12 起支持,参见 Node.js heap snapshot 指南),之后发信号把 .heapsnapshot 写到工作目录。要用 SIGUSR2,别用 SIGUSR1——Node 把 SIGUSR1 留给打开 inspector 用了,发 USR1 不会生成快照:
node --heapsnapshot-signal=SIGUSR2 server.js
# 之后在容器里:
kill -USR2 1 # 如果应用是 entrypoint,PID 通常是 1
# 把文件拷出来,在 Chrome DevTools > Memory 里打开
docker cp my-container:/app/Heap.<timestamp>.heapsnapshot ./
或者进程内触发(比如从一个管理路由):
require('v8').writeHeapSnapshot('/tmp/heap.heapsnapshot');
生产环境务必小心:拍快照会暂停主线程(堆大时可能要一分钟),而且快照是在内存里构建的,可能让 RSS 短时间翻倍、把本就吃紧的容器再次推进 OOM。请在副本上、或留足余量时再拍。常见嫌疑:缓存、没移除的 EventEmitter listener、拽着大数组的闭包。
Python——tracemalloc 做行级归因:
import tracemalloc
tracemalloc.start(25)
# ... 加压跑一会儿 ...
snap = tracemalloc.take_snapshot()
for stat in snap.statistics('lineno')[:20]:
print(stat)
或者用 memray,火焰图直观:
pip install memray
memray run -o out.bin my_app.py
memray flamegraph out.bin
Go——pprof:
import _ "net/http/pprof"
go func() { http.ListenAndServe(":6060", nil) }()
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
Step 4: 设合理上限、留 buffer
按 limit = p99_RSS * 1.5 来,且 p99 要在真实流量下至少观察一周。Node 还要顺手限住 V8 的堆,让运行时在内核动手之前先 GC。
# docker-compose.yml
services:
api:
image: my/api
deploy:
resources:
limits:
memory: 768M
environment:
NODE_OPTIONS: "--max-old-space-size=512"
--max-old-space-size 要明显低于容器上限(这里是 768 MB 上限里给 512 MB 堆),给原生内存和其他分配留地方。Kubernetes 同时设 request 和 limit:
resources:
requests:
memory: "512Mi"
limits:
memory: "768Mi"
requests 给调度器决定落点用,limits 是 OOM cutoff。limits 控制在 requests 的约 1.5 倍内,避免 noisy neighbor。
老运行时陷阱: 很老的运行时只读 cgroup v1 文件、读不到 v2 上限,于是看到的是宿主机总内存、把堆设得过大,然后瞬间被 OOM kill。换基础镜像之后最容易踩这个坑(比如切到 Amazon Linux 2023、RHEL 9 或 Ubuntu 22.04+,这些都默认 cgroup v2)。截至 2026 年 6 月,能正确读 cgroup v2 的运行时下限是:OpenJDK 8u372+、11.0.16+ 或 17+(更老的 8 和 11.0.16 以前的版本会悄悄退回去用宿主机内存)、Node 12.17+ / 16+,以及较新的 .NET。只要运行时支持,JVM 默认就是容器感知的(-XX:+UseContainerSupport),通常不必手动设 -Xmx;要调的话可以用 -XX:MaxRAMPercentage 覆盖默认的 25%。
Step 5: 修泄漏,不只提上限
如果 RSS 不管流量都单调上扬,提上限只是推迟下一次 OOM。常见修法:
- 无界
Map缓存换成 LRU(Node 用lru-cache、Python 用cachetools.LRUCache)。 - DB 池设上限,并确认错误路径上连接也归还,不只是正常路径。
- 关闭时移除 EventEmitter listener;长连 socket 限制
setMaxListeners。 - 用
Buffer.alloc()池复用,别每个请求都新分配大 Buffer。
Step 6: 加护栏
工作集超过约 85% 上限就告警——在 OOM 之前就抓住泄漏。
# Prometheus 规则
- alert: ContainerNearOOM
expr: container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.85
for: 10m
labels:
severity: warning
重启次数告警捕捉静默的 OOM 重启循环:
- alert: PodRestartLoop
expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
for: 0m
怎么确认已经修好
- 提高上限和/或修完泄漏后,用当初复现 OOM 的同样负载再压一遍。
- 盯
docker stats(或 Grafana 里的container_memory_working_set_bytes):RSS 应该趋于平稳、不再一路涨,峰值时也保持在约 80% 上限以下。 - 走完一个完整周期后再跑
docker inspect --format '{{.State.OOMKilled}} {{.State.RestartCount}}' my-container——OOMKilled应为false,重启次数不再增长。 - 怀疑是泄漏时,在稳定流量跑几个小时前后各拍一张堆快照;retained size 的大头不应继续增长。
FAQ
退出码 137 一定是 OOM 吗?
不一定。137 是 128 + 9,即进程收到了 SIGKILL。在容器里 OOM killer 是最常见的来源,但 liveness probe、手动 docker kill、宿主机级 OOM 同样会产生 137。用 {{.State.OOMKilled}} 和 dmesg 才能确定。
OOMKilled 是 false,但还是退出码 137,为什么?
kill 来自容器自身内存上限之外。常见原因是宿主机级 OOM(整机内存耗尽,无论有没有设 --memory)、orchestrator 的 liveness probe kill 了无响应的容器,或者发布时手动发了 SIGKILL。docker inspect 只在 kill 被归因到容器自己的 cgroup 上限时才把 OOMKilled 置为 true,所以节点级的 kill 读出来是 false。用 dmesg 确认:如果有 Out of memory: 但没有 Memory cgroup out of memory: 前缀,就是宿主机级的。
docker stats 明明远低于上限,怎么会 OOM?
MEM USAGE 那一列减去了可回收的 page cache 来近似工作集,而且它呈现原生/堆外内存的方式跟内核计量不一样。OOM killer 评估的是整个 cgroup 的占用。以 cat /sys/fs/cgroup/memory.current 和 dmesg 里那行为准,别信 stats 那一列。
直接把 --memory 调大、然后不管了行不行?
只有当 RSS 稳定、且只在峰值 OOM(上限太紧)时才行。如果 RSS 跟流量无关地单调上涨,那就是泄漏,调大上限只是把同一次崩溃往后拖。
--max-old-space-size 和容器上限之间该留多大空间?
要给原生内存、线程、Buffer 留余量。常见做法是把 V8 堆设成容器上限的约 65%-75%(比如 768 MB 上限里设 --max-old-space-size=512),让 V8 在内核动手之前先 GC。
生产环境能拍堆快照吗?
要小心。拍快照会暂停主线程,而且在内存里构建,可能让 RSS 短时翻倍、在吃紧的容器上触发又一次 OOM。优先用留足余量的副本,或用 --heapsnapshot-signal=SIGUSR2 + kill -USR2 这套,免得为此开放 inspector 端口。
预防
- 每个容器都有内存上限,并在约 85% 处告警。
- 至少在真实流量下观察一周 p99 RSS 再定上限。
- 缓存和池都有显式上限。
- 镜像里带堆 profile 工具(或可挂 sidecar),生产能 dump。
- 每个服务都跟踪重启次数,每次 OOM 都查清楚,别吞了。