Docker 容器内存限制与 OOM 排查实战
容器被 OOM Killer 干掉时往往只有一句 "Killed",没有堆栈没有日志。这篇文章讲清楚怎么提前设限、怎么定位真凶。
#OOM 的典型症状
容器突然消失,docker ps 里看不到,日志最后一行只有:
Killed
没有堆栈、没有异常、没有退出码解释。 这是 OOM Killer 的典型特征 —— 进程被内核直接发信号杀掉,来不及做任何收尾。
#先分清是哪种 OOM
这里有三个不同层面的内存限制,排查时必须分清:
| 层面 | 限制来源 | 症状 |
|---|---|---|
| 容器级 | docker run -m / compose 的 mem_limit |
容器内进程被杀,容器重启 |
| 系统级 | 宿主机物理内存耗尽 | 任意进程被杀,dmesg 有记录 |
| cgroup 级 | 内核 cgroup 内存限制 | 与容器级类似 |
判断方法:
# 看内核有没有杀进程的记录
dmesg | grep -i -E 'oom|killed process'
# 输出示例:
# [12345.678] Out of memory: Killed process 12345 (node) total-vm:1234567kB
# [12345.678] oom_reaper: reaped process 12345 (node)
如果 dmesg 里有记录,说明确实是 OOM。如果没有,那问题在别处(比如应用自己崩溃、容器健康检查失败被重启)。
#关键指标:看 available 而不是 free
这是最容易误判的一点:
free -h
# total used free shared buff/cache available
# Mem: 982Mi 399Mi 138Mi 10Mi 444Mi 432Mi
free 列的 138 MB 会让人以为"内存只剩 138 MB 了",但 buff/cache 的 444 MB 是可回收的文件缓存。真正决定会不会 OOM 的是 available 列的 432 MB。
内核视角的更精确数据:
grep -E 'MemTotal|MemAvailable|Committed_AS|CommitLimit' /proc/meminfo
MemAvailable:不触发 swap 的前提下还能分配多少Committed_AS:所有进程已承诺(但未必实际使用)的内存总量CommitLimit:允许承诺的上限
如果 Committed_AS 接近 CommitLimit,即使 available 看起来很充足,也可能随时 OOM。 这是内存超卖场景下的典型陷阱。
#怎么给容器设合理的内存限制
#原则一:一定要设上限
不设限制的容器会吃掉宿主机所有可用内存,然后拖垮同机器的其他服务。
# docker-compose.yml
services:
api:
image: my-api:latest
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
或者命令行:
docker run -d --memory=256m --memory-swap=256m my-api:latest
--memory-swap 要和 --memory 设成一样,表示禁用 swap。否则容器会把内存压力转成 swap 交换,表现为"服务还在但慢得不能用",比直接 OOM 更难排查。
#原则二:留出安全余量
不要按"实测峰值"设限制,要留 30%–50% 余量:
| 应用类型 | 实测峰值 | 建议限制 |
|---|---|---|
| Node.js 轻服务 | 110 MB | 256 MB |
| Node.js + 构建 | 400 MB | 768 MB |
| Nginx | 30 MB | 128 MB |
| MySQL | 380 MB | 640 MB |
Node.js 的 V8 堆内存和 RSS 是两回事。 V8 默认堆上限在 64 位系统上约 2 GB,容器限制为 256 MB 时,V8 并不知道这个限制,可能在堆远未达到上限时 RSS 就超了。
解决办法是显式告诉 V8:
NODE_OPTIONS="--max-old-space-size=180" node server.js
180 MB 的堆上限配合 256 MB 的容器限制,是留了合理余量的组合。
#原则三:把限制写进编排文件
临时 docker run 设的参数,下次重建就丢了。写进 compose 或 Dockerfile 才持久。
#排查流程
按这个顺序走,基本能定位到真凶:
# 1. 确认是否真的 OOM
dmesg | grep -i oom | tail -20
# 2. 看容器当前内存占用
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}'
# 3. 看容器退出码(137 = 128 + 9,即被 SIGKILL)
docker inspect <container> --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
# 4. 与宿主机 ps 交叉验证
ps -eo pmem,rss,comm,args --sort=-rss | head -12
第 3 步的 OOMKilled 字段是最直接的证据,返回 true 就实锤了。
第 4 步很重要:docker stats 的统计口径包含 page cache,而宿主机 ps 的 RSS 不含。两者差距很大时,说明容器在大量读写文件 —— 这种情况下降的是 cache,实际内存压力没那么大。
#常见误判
#误判一:把 cache 当成泄漏
Node 进程 RSS 缓慢上涨然后趋于平稳,这通常是 V8 的垃圾回收策略,不是泄漏。
判断方法:观察 24 小时,如果 RSS 在高位稳定震荡而不是持续线性上涨,就是正常的。
真泄漏的特征是:每次请求后 RSS 都上一个台阶,且不回落。
#误判二:忽略子进程
Node 的 child_process、PM2 的 cluster 模式,都会让子进程的内存不计入父进程。
# 看整个进程树
ps -eo pid,ppid,rss,comm --forest | grep -A5 node
#误判三:忘记 swap 的影响
swapon --show
cat /proc/sys/vm/swappiness
swappiness 默认 60,意味着内核比较积极地使用 swap。对延迟敏感的服务建议调低:
sysctl -w vm.swappiness=10
# 持久化
echo 'vm.swappiness=10' >> /etc/sysctl.conf
但要注意:swappiness=0 不是"禁用 swap",而是"尽量不用"。 内存极度紧张时仍然会 swap,甚至可能触发 OOM。
#相关资源
以下资料可直接下载:
服务器只读巡检脚本
一条命令采集系统、CPU、内存、磁盘、Docker、端口与防火墙信息,不修改任何系统状态,适合部署前体检。
#小结
| 环节 | 关键动作 |
|---|---|
| 预防 | 容器必须设 --memory,并配 NODE_OPTIONS |
| 判断 | dmesg + docker inspect .State.OOMKilled |
| 定位 | docker stats 与宿主机 ps 交叉看 |
| 优化 | 调 swappiness,禁用容器 swap |
核心认知就一句:free 列的数值会骗人,MemAvailable 和 Committed_AS 才反映真实压力。