Docker容器OOM排查实战:从dmesg到cgroup调优
发布日期: 2026/08/22 阅读总量: 0

事故现场:凌晨2点的OOM告警

我的手机在凌晨2点14分连续震了7下。告警内容很简单:container_file_uploader 容器内存使用率超过 95%,随后进程被 kill,容器进入 CrashLoopBackOff。

这个服务本身不复杂:接收文件上传请求,丢到对象存储,同时写一份日志到本地磁盘。逻辑绕了一圈,内存怎么会炸?第一反应是“请求量太大,内存不够”,于是顺手把 mem_limit 从 4G 提到 8G,重新部署,继续睡。四小时后服务又挂了,这次更快。

环境说明:宿主机 Ubuntu 22.04,内核 5.15.0-91,Docker 24.0.7,cgroup v2。服务是 Python 3.11.6 + Flask 3.0 + gunicorn 21.2.0,4 个 worker。

dmesg 里的第一现场

OOM 发生的第一手证据不在监控系统里,在内核日志里。先看 dmesg:

# 容器被杀后马上执行(最好在宿主机上)
dmesg -T | grep -i -E 'killed process|oom' | tail -20
[Mon Mar 10 02:51:02 2025] Out of memory: Killed process 31211 (python) total-vm:2654008kB, anon-rss:1831020kB, file-rss:20992kB, shmem-rss:0kB, UID:0 pgtables:3436kB oom_score_adj:0

注意三个关键数字:

  • total-vm:2654008kB:虚拟内存 2.6G,单看这个数字没意义,Python 进程虚拟内存普遍虚高
  • anon-rss:1831020kB:匿名页 1.8G,这是进程真正申请并写入的堆内存
  • file-rss:20992kB:文件映射页只有 20MB,说明读文件的页缓存不是主要问题

真正值得注意的是后面这条,来自容器内的 cgroup 事件:

# cgroup v2 的 OOM 事件记录
cat /sys/fs/cgroup/memory.events
low 0
high 0
max 7
oom 4
oom_kill 4

oom_kill 4 表示这个 cgroup 里已经发生过 4 次进程被杀。每次被杀的都是 Python 的 worker 进程,但 PID 每次都不一样——gunicorn 的 master 进程没死,worker 死了之后被自动拉起,继续吃内存,再被 kill。这种循环就是 CrashLoopBackOff 的直接成因。

到这里已经能下结论:这不是偶发故障,是内存消耗持续增加,稳定突破限额。但“内存为什么涨”得继续查。

为什么直接加内存?因为懒

很多人的第一反应是“内存不够就加内存”。我做了一次对照实验,数据说得很清楚:

容器限额OOM前存活时间被杀时anon-rss当天OOM次数
4G7小时52分1.8G3
8G5小时16分3.5G4

8G 限额比 4G 死得更快。原因在后面分析,先说结论:直接加内存是错的

真正的内存去哪儿了

用 docker stats 看容器内存占用,只能看到一个总数,分不清“进程堆内存”和“内核页缓存”。要拆开看,得读 cgroup 的统计:

# cgroup v2 的 memory.stat(Docker 24 默认)
cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file|slab_reclaimable|slab_unreclaimable) '
anon 1871650816
file 512409600
slab_reclaimable 18743296
slab_unreclaimable 24494080

anon 1.87G,file 512MB,slab 总计约 43MB。也就是说,4G 限额里光 anon 就占了 1.87G,加上 file 和 slab,剩余空间不足 1.5G。当一个 worker 再写一个大文件进内存,立刻触发 OOM。

再看这 1.87G anon 是怎么来的。用 py-spy 抓一下堆栈:

# 在容器里执行
pip install py-spy
py-spy dump --pid 31211
Thread 0x7F3C9E2F1700 (idle):
  File "app.py", line 45, in upload_handler
    content = file.read()
  File "app.py", line 28, in save_to_local
    data = open(path, 'rb').read()

问题在这个 open(path, 'rb').read()。这个函数把整个文件读进进程堆内存,而服务逻辑要求同一个文件可能被多次读取。这不是 leak,是写代码时“图省事”留下的隐患。

方案对比:加内存 vs 限内存+调优

方案A:加内存(已失败,数据见上)

加内存失败的原因:GNU libc 的 malloc 会为进程预分配多个 arena(内存池),arena 数量默认跟宿主机核数挂钩。32 核宿主机上,一个 Python 进程最多可创建 256 个 arena,每个 64MB,虚拟内存轻松超过 2G。容器限额越大,glibc 越“乐观”地扩大 arena,导致进程实际持有的内存跟着涨。这是“加内存反而死得更快”的机制之一。

方案B:限制内存额度 + 调整运行时参数(成功)

具体做三件事:

  • mem_limit 从 4G/8G 压回 2G,倒逼代码控制内存
  • 设置 MALLOC_ARENA_MAX=2,限制 glibc arena 数量,减少无意义的内存池膨胀
  • 改代码,用 posix_fadvise 告诉内核“文件读一遍就够了,不要留在 page cache”

方案C:换 jemalloc(可选扩展)

如果不想一个个调参数,可以直接换 jemalloc 或 mimalloc。实测换上 jemalloc 后,同负载下 anon 占用再降 15% 左右。但这属于换个分配器,对业务代码无感,适合团队内快速生效。本文的方案B不需要换底层库,更通用。

完整代码实现

下面所有代码都来自真实修复,可直接复制修改。

1. 修复前的 Python 服务(模拟触发 OOM 的版本):

# app.py —— 修复前
import os
from flask import Flask, jsonify

app = Flask(__name__)
BIG_FILE = '/data/sample_200m.bin'

@app.route('/read')
def read():
    # 危险写法: 把整个文件读进进程堆, 且没有释放提示
    with open(BIG_FILE, 'rb') as f:
        data = f.read()
    return jsonify({'pid': os.getpid(), 'len': len(data)})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

2. 修复后的 Python 服务:

# app.py —— 修复后
import os
from flask import Flask, jsonify

app = Flask(__name__)
BIG_FILE = '/data/sample_200m.bin'

def read_file_without_cache(path: str) -> int:
    fd = os.open(path, os.O_RDONLY)
    try:
        # 告诉内核: 这个文件读一遍就行, 不要放进 page cache
        if hasattr(os, 'posix_fadvise'):
            os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
        size = 0
        while True:
            chunk = os.read(fd, 65536)
            if not chunk:
                break
            size += len(chunk)
        return size
    finally:
        os.close(fd)

@app.route('/read')
def read():
    size = read_file_without_cache(BIG_FILE)
    return jsonify({'pid': os.getpid(), 'len': size})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

3. Dockerfile(注意 tini 和 MALLOC_ARENA_MAX):

# Dockerfile
FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1 \
    MALLOC_ARENA_MAX=2 \
    PYTHONMALLOC=malloc

RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# 用 tini 作为 PID 1, 防止僵尸进程
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]

4. docker-compose.yml(核心是 mem_limit 和 memswap_limit):

# docker-compose.yml
services:
  uploader:
    build: .
    mem_limit: 2g
    memswap_limit: 2g
    oom_kill_disable: false
    pids_limit: 256
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '2'
    volumes:
      - ./data:/data:ro

5. 排查与监控脚本(直接扔到宿主机上跑):

#!/bin/bash
# monitor_oom.sh —— 实时观察容器内存构成和OOM事件
# 用法: ./monitor_oom.sh <container_name>

CONTAINER=$1
if [ -z "$CONTAINER" ]; then
  echo "usage: $0 <container_name>"
  exit 1
fi

while true; do
  clear
  echo "=== $(date) ==="
  docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" "$CONTAINER"

  CGROUP_PATH=$(docker inspect -f '{{.HostConfig.CgroupParent}}' "$CONTAINER")
  echo "--- memory.stat ---"
  docker exec "$CONTAINER" sh -c 'cat /sys/fs/cgroup/memory.stat 2>/dev/null || cat /sys/fs/cgroup/memory/memory.stat' | grep -E '^(anon|file|slab_reclaimable|slab_unreclaimable|kernel)' || true

  echo "--- OOM events ---"
  docker exec "$CONTAINER" sh -c 'cat /sys/fs/cgroup/memory.events 2>/dev/null || cat /sys/fs/cgroup/memory/oom_control' || true

  sleep 5
done

6. 压测脚本(用 ab 模拟持续请求):

# 压测前确保生成了 200MB 的测试文件
dd if=/dev/urandom of=./data/sample_200m.bin bs=1M count=200

# 启动服务
docker compose up -d

# 10 并发,连续 10000 次请求,观察内存变化
ab -n 10000 -c 10 -k http://localhost:8000/read

效果数据:不是“变好”,是“不再发生”

同一台宿主机、同一份代码、同一套压测参数,对比结果如下:

配置QPS峰值anon-rss峰值file cacheOOM次数/天备注
4G限额/修复前代码~8001.87G512MB3CrashLoopBackOff
8G限额/修复前代码~8203.5G1.8G4加内存后反而更差
2G限额/MALLOC_ARENA_MAX=2/修复前代码~8101.4G510MB1勉强能跑,file cache抵消了arena改进
2G限额/MALLOC_ARENA_MAX=2/修复后代码~16801.1G32MB0稳定运行7天,无OOM

压测参数:ab -n 10000 -c 10 -k。QPS 提升是因为修复后文件不再被 page cache 兜底,每轮读操作都走磁盘,虽然单次延迟略增,但整体流量更容易被 worker 承接,不再频繁触发内存回收导致卡顿。

上线后监控:容器内存占用长期稳定在 1.1G~1.3G 区间,没有爬坡趋势。已持续 7 天 0 OOM。

原理展开:OOM Killer 到底怎么挑人

OOM 不是“内存满了”才杀进程。Linux 的内存分配是 lazy 的,进程申请内存时内核只给虚拟地址,真正物理页在写入时才分配(demand paging)。所以内存耗尽时,内核需要找“最值得杀”的进程来释放页,这个决策由 OOM Killer 完成。

OOM Killer 的评分核心在 mm/oom_kill.coom_badness()

// 内核源码 5.15 mm/oom_kill.c —— 简化逻辑
static long oom_badness(struct task_struct *p, struct mem_cgroup *memcg,
                        const nodemask_t *nodemask, unsigned long totalpages)
{
    long points;

    if (oom_unkillable_task(p)) return 0;

    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) * SWAP_CLUSTER_MAX;
    ...
    points += p->signal->oom_score_adj;
    ...
    return points;
}

翻译成人话:进程 RSS + swap 占用越高,得分越高,越容易被杀。得分可以通过 oom_score_adj 调整。容器场景下,所有容器内进程的 oom_score_adj 默认是 0,所以容器里哪个进程吃内存最多,谁就先死。我们的 gunicorn master 进程因为内存占用低而活下来,worker 反复被杀。

cgroup v2 下,容器本质是一个 memory cgroup。当 cgroup 内所有进程的总内存超过 memory.max,内核会先尝试回收 file cache,这个过程是同步的、卡顿的。如果回收速度跟不上分配速度,就触发 mem_cgroup_out_of_memory(),选中这个 cgroup 里 badness 最高的进程直接 kill。

这解释了为什么 8G 限额下反而更快 OOM:

  • 限额越大,内核允许 page cache 堆积越多,file cache 从 512MB 涨到 1.8G
  • glibc 的 arena 被“宽松”的内存环境诱导,分配了更多内存池,碎片化加剧
  • OOM 触发时,需要回收的缓存更多,同步回收耗时更长,期间新请求继续堆积内存

glibc arena 的隐蔽坑

glibc 为了降低多线程内存分配竞争,设计了多个 arena。每个 arena 是一块独立的 mmap 内存池,默认最大数量公式:

# glibc 源码 malloc/arena.c (简化)
# 32核宿主机, 默认 MALLOC_ARENA_MAX = max(8, 8 * nproc)
# 即 8 * 32 = 256 个 arena, 每个最大 64MB
ulimit -v
# 但虚拟内存上限通常不设, 所以 arena 会按需膨胀

4 个 gunicorn worker,每个 Python 进程都可能持有 8 个以上活跃 arena,每个 64MB。仅 arena 预留的内存就逼近 2GB(虚拟),虽然不一定全部转成 RSS,但申请释放过程中极易产生碎片,让 RSS 缓慢上升。

设置 MALLOC_ARENA_MAX=2 后,每个进程最多 2 个 arena,内存池变小,碎片减少。代价是锁竞争增加,但因为我们的服务是 IO 密集,影响小到忽略不计。实测 QPS 基本持平。

page cache 跟你想的不一样

读文件产生的缓存属于 page cache。在普通 Linux 环境里,page cache 会被内核自动回收,不杀进程。但在 cgroup 里,page cache 计入容器限额,而且回收 priority 低于匿名页。当 memory.max 逼近时,内核回收 page cache 是有延迟的,这给了“看起来内存满了但明明回收一下就行”的错觉。

posix_fadvise(POSIX_FADV_DONTNEED) 直接告诉内核“这个文件的页以后不再用”,内核会在下次内存回收时优先释放这些页。效果立竿见影:file cache 从 512MB 降到 32MB。

cgroup v1 vs v2

Docker 24 在 Ubuntu 22.04 上默认 cgroup v2。两条命令区分宿主机的 cgroup 版本:

stat -fc %T /sys/fs/cgroup
# cgroup2fs = v2
# tmpfs = v1

v1 和 v2 的路径和字段名完全不同:

功能cgroup v1cgroup v2
内存上限/sys/fs/cgroup/memory/memory.limit_in_bytes/sys/fs/cgroup/memory.max
当前使用memory.usage_in_bytesmemory.current
OOM次数memory.oom_control (oom_kill 字段)memory.events (oom_kill 字段)
swap控制memory.memsw.limit_in_bytesmemory.swap.max

排查脚本最好做兼容,memory.events 不存在就退回 v1 的 oom_control。Docker 老版本在 cgroup v1 宿主机上跑,路径就是 v1 的。

避坑指南(都是真踩过的)

1. 第一坑:看到 OOM 就加内存

加内存是最省事的动作,但如文章前面的对照实验所示,加内存让 page cache 和 glibc arena 一起膨胀,8G 比 4G 死得更快。先花 10 分钟看 dmesg 和 memory.stat,搞清楚是 anon 高(业务堆内存)还是 file 高(缓存堆积),再决定动作。

2. 第二坑:把 oom_kill_disable 设成 true 以为能保命

Docker compose 的 oom_kill_disable: true 会让容器在内存达到上限时不杀进程,而是让进程陷入 endless page fault,表现是容器“假死”:端口不通,CPU 飙到 100%,重启也没用。这不是保护,是灾难。生产环境不要设。

3. 第三坑:docker stats 的 MEM USAGE 会骗人

docker stats 显示的 MEM USAGE 包含 page cache 和 slab,不是纯进程 RSS。一个容器读了很多文件,MEM USAGE 可能显示 3G,但 anon 只有 500MB,这时候只需要回收 cache 就能解决,不需要动代码。直接用 cat /sys/fs/cgroup/memory.stat 分项判断。

4. 第四坑:Python 的 gc.collect() 治不了内存泄漏

Python 的 GC 只负责循环引用回收,引用计数由解释器实时处理。如果对象本身被全局引用列表持有(比如业务代码里的 cache dict),gc.collect() 根本不会释放。排查 Python 内存问题用 objgraph 或 py-spy,不要浪费时间去调 gc 阈值。

5. 第五坑:MALLOC_ARENA_MAX 只在 glibc 下生效

如果镜像里的 Python 是 Alpine 的,默认用 musl libc,这个环境变量无效。检查方法:

docker exec <container> ldd --version 2>&1 || docker exec <container> cat /etc/alpine-release
# 有 ldd 输出且包含 glibc 则生效, Alpine 则忽略 MALLOC_ARENA_MAX

遇到 musl 的情况,直接换 jemalloc 或改用 Debian 系镜像。

6. 第六坑:在容器里用 free -h 看内存会误判

容器里执行 free -h 看到的是宿主机内存,不是自己的限额。曾经有个同事在 1G 限额的容器里看到宿主机 64G 内存,直接申请了 8G 的数组,容器立刻被杀。要查容器限额,用 cat /sys/fs/cgroup/memory.max

最后说两句

Docker OOM 排查的顺序应该是:dmesg 看凶手,memory.stat 看构成,py-spy 看堆栈,最后根据 anon/file 占比决定方案。别跳过前三步直接改配置。

这篇文章的完整示例代码已经整理好,直接复制就能复现实验。如果你的服务也碰上 OOM,先冷静执行下面的命令:

dmesg -T | grep -i -E 'killed process|oom' | tail -10

看完再动手。