一、现象与影响面:进程无声消失
OOM(Out of Memory,内存耗尽)在 Linux 上几乎从不敲门。典型症状挑几个:服务跑着跑着突然 502、过一会儿又好了;容器状态变成 Exited (137);某个进程无声消失,日志戛然而止;整台机器卡死几分钟又缓过来——缓过来的那一刻,总有一个进程不见了。共同点是没有应用层报错,因为杀死它的是内核,被杀者没有机会说遗言。
反模式一:发现容器死了就重启,接着干别的。重启掩盖现场,OOM 是慢性病还是急性伤、凶手是泄漏还是配置,重启之后一个都回答不了。正确姿势是先取证再恢复:现场全在内核日志和容器状态里,两分钟就能看完。
这套排查走法在本站这台 1.8G 小机上属于常备工具。小机的经验恰恰最有迁移价值:内存越紧张的环境,OOM 排查越是要快而准。
排查主线三层:内核层(确认是 OOM、谁被杀)→ cgroup 层(是限额杀的还是物理内存杀的)→ 进程层(泄漏还是配置)。一层看正常就往下走。
二、第一层:内核日志,确认”是 OOM”
第一站在宿主机。内核的 OOM killer 每次动手都会留记录:
# 宿主机上;-T 把时间戳转成人类可读
dmesg -T | grep -iE 'out of memory|oom|killed process'
1. [某日 14:02:11] Out of memory: Killed process 4837 (node) total-vm:2900000kB ...
1. anon-rss:1380000kB ... ← rss 是实占物理内存,这个数才是内核杀它的依据
1. dmesg 是环形缓冲区,会被新消息冲掉;查历史用 journalctl
journalctl -k --since "-6 hours" | grep -i oom # 本次开机内的内核日志
journalctl -k -b -1 | grep -i oom # 上一次开机的(机器被 OOM 拖重启过就用它)
1. 顺手看当前水位,判断是"还在流血"还是"已经止住"
free -h
1. available 列才是还能真分出去的内存,看它而不是看 free 列
日志里认准两类行:Killed process 是宿主级全局 OOM,内核按 oom_score 挑了牺牲者;带 Memory cgroup out of memory 字样的是 cgroup 限额 OOM,死的是某个容器/服务组内的进程。这一眼决定了下一层往哪走。
这层查无记录,不代表没发生过——cgroup OOM 在部分运行时配置下只在容器事件里留痕。往下走,用容器状态补证。
三、第二层:cgroup,确认”谁被杀、被谁杀”
容器世界里 OOM 分两种,处理方向完全不同,必须先分清:
# 宿主机上;137 = 128 + 9(SIGKILL),OOM 杀出来的退出码
docker ps -a --filter "status=exited" --format '{{.Names}} {{.Status}}'
1. redis Exited (137) 3 minutes ago ← 高度可疑
1. 铁证:OOMKilled 标记是 docker 替你从 cgroup 事件里读好的
docker inspect redis --format '{{.Name}} OOMKilled={{.State.OOMKilled}} Code={{.State.ExitCode}}'
1. /redis OOMKilled=true Code=137 ← 实锤:限额 OOM
1. 全栈内存水位一眼看齐
docker stats --no-stream
1. NAME MEM USAGE / LIMIT MEM %
1. redis 199.6MiB / 200MiB 99.80% ← 贴着限额顶,就是它
| 维度 | 宿主全局 OOM | cgroup 限额 OOM |
|---|---|---|
| 触发条件 | 物理内存 + swap 全线耗尽 | 单个容器吃破自己的 --memory 限额 |
| 日志特征 | dmesg/journalctl 有 Killed process,无 memcg 字样 |
OOMKilled=true;journalctl 里带 Memory cgroup out of memory |
| 受害者 | 内核挑 rss 最大的进程,无辜邻居常遭殃 | 只死限额内的进程,别人无恙 |
| 处理方向 | 找泄漏大户或砍服务,实在不行加内存 | 调限额,或调该进程的内存配置(见第五节根因表) |
反模式二:一见 OOMKill=true 就加 --memory。限额是护栏,不是病因。MySQL 把 buffer pool 配成了限额的九成、Java 老镜像按宿主内存定堆、Node 没限 V8 堆——这些加多少限额都会再顶破。先去第五节对号入座,再谈动限额。
这层结论出来(谁被杀、属于哪种 OOM),往下走最后一步:看进程内存的形态。
四、第三层:进程级,”泄漏还是配置”
这层在宿主机看最全——容器进程本质上就是宿主机进程,docker exec 进容器再看反而只能看到 PID namespace 内的一小截:
# 宿主机上;按实占物理内存(RSS)排序,前几名就是嫌疑人
ps aux --sort=-rss | head -8
1. top 里按 M 键同理;连续观察形态用 pidstat 每 5 秒采一次
pidstat -r 5 12 | awk '$NF ~ /node|java|mysqld|redis/ || NR<4'
1. minflt/s 持续为正且 RSS 一路爬升 = 还在持续分配,没有回落迹象
1. 需要看容器内部视角时(比如确认容器里就一个主进程):
docker exec app ps aux --sort=-rss | head
1. 注意:容器内的 ps 只显示同 namespace 的进程,不是全貌
RSS 与 VSZ 的区别要分清:VSZ 是虚拟地址空间(预留),RSS 是实占物理内存——OOM 只关心 RSS。Java 进程 VSZ 动辄几十 G 属正常,别被吓到。看曲线形态,比看瞬时值有用得多:
| 曲线形态 | 含义 | 下一步 |
|---|---|---|
| 平稳高位(长期贴着限额 80–90%) | 配置问题:内存池/堆给了那么大,它就真的用那么大 | 去根因表调配置,这不是泄漏 |
| 锯齿上涨、每次回落点比上一次高 | 泄漏嫌疑:GC 在扫,但总有一批对象释放不掉 | 保留趋势采样,转代码层(全局缓存、定时器、监听器) |
| 平时正常、定点尖峰 | 峰值预算问题:fork 写时复制、批量任务、大文件加载 | 把尖峰时刻和 crontab / RDB 保存点对齐 |
五、根因清单:容器世界的高频五类
按小内存机器上的出现频率排序,每条给一行验证:
| 根因 | 特征 | 验证 | 修法 |
|---|---|---|---|
| Node 未显式限堆 | node 进程贴限额,日志无异常;较新的 Node 能读容器限额定默认堆,旧版本按宿主内存算,且堆外(Buffer、线程栈)不在 V8 堆统计里 | node -e "console.log(v8.getHeapStatistics().heap_size_limit)"(应用容器内,对比预期) |
node --max-old-space-size=384 显式给出,并给堆外留量 |
| MySQL buffer pool 配置失当 | mysqld 平稳吃掉限额大头;常见错不是默认 128M,而是小机上配太大不留余量 | SHOW VARIABLES LIKE 'innodb_buffer_pool_size';(mysql 容器内) |
池设为容器限额一半上下,给连接与临时表留 20–30% |
| Java 堆与限额脱节 | 稳定吃满后被 OOMKill;老基础镜像按宿主内存定堆,8u191 起才默认感知容器 | 启动日志里的 heap 大小,或 -XX:+PrintFlagsFinal -version | grep MaxHeapSize |
-Xmx 小于容器限额,或显式 -XX:MaxRAMPercentage |
| redis fork 写时复制 | BGSAVE 瞬间 RSS 接近翻倍、定点尖峰被杀 | 尖峰时刻对照 INFO persistence 的 bgsave 记录(redis 容器内) |
容器限额大于 maxmemory、给数据量留约一倍峰值余量 |
| 真·泄漏(无上限缓存、未清理定时器、对象堆积) | 锯齿上涨不回落,重启后缓慢复现 | 趋势采样 + 二分注释可疑模块 | 代码修:缓存必须有上限或 TTL |
六、预防:限额、swap、告警
| 措施 | 做什么 | 边界与代价 |
|---|---|---|
| 全员限额 | compose 里每个服务 mem_limit;redis 另设 maxmemory |
限额不是免死金牌,配置错的进程照样在限额内撞墙 |
| 小 swap 当缓冲 | 给宿主机 1–2G swap,用卡顿换不被杀 | swap 会让问题从”死”变成”慢”,定位窗口变长但体验变差;容器侧可配 memory-swap 管住 |
| 告警 | 监控 MemAvailable 与 cgroup 的 oom_kill 计数;最朴素的版本是 cron 里巡检 docker inspect 各容器的 OOMKilled |
告警的价值在第二次 OOM 前叫醒你,第一次永远靠人肉发现 |
这套走法收拢成一句:先问内核(是不是 OOM、杀的谁),再问 cgroup(谁的责任田),最后问进程(泄漏还是配置)——顺序反了,就会在应用日志里翻半天什么也翻不到。
延展阅读:分不清是内存打满还是 CPU 打满时,先看《CPU 100% 的定位路径》第一节做分诊;动手前的命令基本功见《Linux 常用命令》,容器化部署的容量背景见《多节点集群架构规划》。
版本核实(以官方文档为准):Node 12 起可读容器内存限额定默认堆、Node 20+ 默认堆约为容器限额一半(4G 内封顶,超出上限 2G);JDK 8u191 起 UseContainerSupport 默认开启、MaxRAMPercentage 默认 25%。以上行为随版本演进,以 Node 与 OpenJDK 官方文档为准。