适用场景
服务容器偶发或持续重启,docker ps -a 显示退出码为 137,应用日志末尾没有 Java、Python 或 Go 自己抛出的异常。宿主机看起来还有可用内存,但容器仍被杀掉;或者一次流量高峰后多个容器同时不可用。
本文以 Docker / Docker Compose 部署的 API 服务为例,说明如何区分容器内存限额触发与宿主机全局 OOM,定位实际占用者,并把修复落到可验证的限额、并发和监控配置上。
现象与常见误区
常见现场信号包括:
- 容器的
State.OOMKilled为true,退出码通常为137(收到 SIGKILL)。 docker logs戛然而止,来不及写出应用堆栈。free -h仍显示宿主机有空闲内存,却误以为不可能是 OOM。- 重启策略使容器很快恢复,掩盖了反复被杀的事实。
Docker 为每个设置了 memory limit 的容器建立 cgroup。容器达到自己的 cgroup 上限时,内核可以杀死该 cgroup 内的进程;此时宿主机无需耗尽所有内存。因此仅看 free -h 不足以排除 OOM。
第一阶段:保留现场并确认 OOM 类型
先记录容器状态。不要只执行 docker restart,否则关键计数和日志上下文会被覆盖。
CONTAINER=api
docker inspect "$CONTAINER" \
--format 'name={{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} finished={{.State.FinishedAt}} memory={{.HostConfig.Memory}} memory_swap={{.HostConfig.MemorySwap}}'
docker events --since 30m \
--filter "container=$CONTAINER" \
--filter event=oom
HostConfig.Memory 的单位是字节,0 表示没有容器内存上限。oom=true 能确认 Docker 已感知到 OOM;但仍应查看内核日志,判断是 cgroup 限额还是整机内存压力:
sudo journalctl -k --since '30 min ago' | \
grep -Ei 'out of memory|oom-kill|killed process|memory cgroup'
日志中若同时出现 Memory cgroup out of memory、容器 ID 或 Task in /docker/...,优先按该容器限额排查。若没有 cgroup 路径、多个无关进程都被杀,则检查整机内存、其他容器和宿主机服务。
第二阶段:查看 cgroup 的真实用量
docker stats 适合实时观察,但故障后应读取 cgroup 计数。先确认 cgroup 版本:
stat -fc %T /sys/fs/cgroup
docker stats --no-stream "$CONTAINER"
输出 cgroup2fs 表示 v2。可以用容器进程的 cgroup 路径读取内存文件:
PID=$(docker inspect -f '{{.State.Pid}}' "$CONTAINER")
CGROUP=$(awk -F: '$1=="0" {print $3}' "/proc/$PID/cgroup")
BASE="/sys/fs/cgroup$CGROUP"
printf 'current: '; cat "$BASE/memory.current"
printf 'max: '; cat "$BASE/memory.max"
printf 'events:\n'; cat "$BASE/memory.events"
printf 'top consumers:\n'
ps -eo pid,ppid,rss,cmd --sort=-rss | head -n 15
在 cgroup v2 中,memory.current 是当前字节数,memory.max 是硬上限(max 代表不限),memory.events 的 oom 和 oom_kill 是累积计数。每次复现后 oom_kill 增加,说明确实发生了 cgroup 内核杀进程,而不是应用主动退出。
对于 cgroup v1,文件通常位于 /sys/fs/cgroup/memory/docker/<容器完整ID>/,对应字段是 memory.usage_in_bytes、memory.limit_in_bytes 和 memory.failcnt。不要把 v1 路径硬编码进诊断脚本。
第三阶段:找到内存为何增长
先把“持续泄漏”和“请求尖峰”分开。每 10 秒采样一次,至少跨过一个高峰:
while true; do
date -Is
docker stats --no-stream --format '{{.Name}} mem={{.MemUsage}} pids={{.PIDs}}' "$CONTAINER"
sleep 10
done
缓慢、单调增长通常指向缓存未淘汰、对象引用泄漏或 worker 未回收;只在大请求或批量任务期间突升,则优先检查并发数、请求体大小、解压缩、导出任务和一次性读取大文件。
同时在应用侧补齐三个维度:
- 请求维度:URI、响应状态、请求体长度、处理耗时,避免记录敏感正文。
- 进程维度:worker 数、队列长度、进程 RSS、GC/堆指标。
- 容器维度:memory usage、limit、OOM 计数、重启次数。
例如 Python Web 服务不要按 CPU 数量盲目放大 worker。每个 worker 都会有解释器、连接池和业务缓存,峰值内存近似为“worker 基线内存 × 并发 worker + 单请求峰值”。应先压测测得单 worker 的 P95/P99 内存,再设置并发。
修复方案:先控峰,再设合理的硬限制
以下 Compose 配置为服务保留 1 GiB 硬上限、768 MiB 预警阈值,并限制进程数。mem_limit 适用于常见的 Docker Compose 本地部署;部署前请用当前 Compose 版本验证实际生效的字段。
services:
api:
image: registry.example.com/app-api:2026.08.11
restart: unless-stopped
mem_limit: 1g
mem_reservation: 768m
pids_limit: 256
environment:
WEB_CONCURRENCY: "2"
GUNICORN_MAX_REQUESTS: "2000"
GUNICORN_MAX_REQUESTS_JITTER: "200"
重建后立即确认配置与运行时限制一致:
docker compose up -d --force-recreate api
docker inspect api --format 'memory={{.HostConfig.Memory}} reservation={{.HostConfig.MemoryReservation}} pids={{.HostConfig.PidsLimit}}'
docker stats --no-stream api
mem_reservation 是调度和回收压力下的软目标,不会替代硬限制。硬限制不应简单调大到掩盖泄漏:先降低无界并发、给缓存设最大容量/TTL、将大文件改为流式处理,再根据压测结果保留 20%~30% 的安全余量。
若应用确需处理大任务,推荐把它移到独立 worker,并给队列设置背压。例如限制每批导出行数、分块读取对象存储、拒绝超过上限的请求体;这样 Web 容器不会被低频大任务拖垮。
验证与预防
发布配置后,用与故障相似的并发和数据量压测,观察至少一个业务高峰:
docker inspect api -f 'oom={{.State.OOMKilled}} restarts={{.RestartCount}}'
docker exec api sh -c 'test -r /sys/fs/cgroup/memory.events && cat /sys/fs/cgroup/memory.events || true'
验收标准不是“容器没死一次”,而是内存曲线在预期范围内回落、oom_kill 不再增长、重启次数稳定,并且限流或队列背压在超载时返回可预期的错误。
建议设置以下告警:容器内存使用率连续 5 分钟高于 80%,oom_kill 增量大于 0,容器重启次数突增,以及宿主机 MemAvailable 持续偏低。告警中附带容器名、限制值、当前值和最近部署版本,可大幅缩短首次响应时间。
总结
容器被 OOMKilled 时,先用 docker inspect、内核日志和 cgroup memory.events 确认杀进程的边界,再用时间序列区分泄漏和峰值。治理重点是限制无界工作量、把大任务隔离、按压测结果设置内存限额,并持续监控 OOM 事件;单纯增加内存往往只能延后下一次故障。
Discussion
评论