适用场景

服务容器偶发或持续重启,docker ps -a 显示退出码为 137,应用日志末尾没有 Java、Python 或 Go 自己抛出的异常。宿主机看起来还有可用内存,但容器仍被杀掉;或者一次流量高峰后多个容器同时不可用。

本文以 Docker / Docker Compose 部署的 API 服务为例,说明如何区分容器内存限额触发与宿主机全局 OOM,定位实际占用者,并把修复落到可验证的限额、并发和监控配置上。

现象与常见误区

常见现场信号包括:

  • 容器的 State.OOMKilledtrue,退出码通常为 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.eventsoomoom_kill 是累积计数。每次复现后 oom_kill 增加,说明确实发生了 cgroup 内核杀进程,而不是应用主动退出。

对于 cgroup v1,文件通常位于 /sys/fs/cgroup/memory/docker/<容器完整ID>/,对应字段是 memory.usage_in_bytesmemory.limit_in_bytesmemory.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 事件;单纯增加内存往往只能延后下一次故障。