Docker容器OOM深度排查:从现象到根因定位
发布日期: 2026/08/03 阅读总量: 0

一、凌晨2点17分的告警:容器被杀,用户还在付款

先说场景。2024年3月,我们一个支付回调服务(PHP 8.3 + Laravel 11 + php-fpm 8.3,跑在Docker 24.0.7里,宿主机是Ubuntu 22.04,内核5.15,cgroup v2)凌晨突然告警。K8s里Pod状态变成CrashLoopBackOff,日志最后一行是:

# docker inspect 拿到的退出信息
"OOMKilled": true,
"ExitCode": 137

用户支付回调订单量高峰在凌晨2点到3点,这个时间点OOM,等于告诉用户「你的钱可能扣了但订单没更新」。我先看了宿主机dmesg:

dmesg -T | grep -i "oom" | tail -20
# 实际输出(脱敏后)
[Fri Mar 15 02:17:23 2024] oom-kill:constraint=CONSTRAINT_MEMCG,oom_memcg=/kubepods/burstable/pod-xxx/yyy
[Fri Mar 15 02:17:23 2024] oom_kill_process_gfp_mask=0x100da(order-0)
[Fri Mar 15 02:17:23 2024] Task in /kubepods/burstable/pod-xxx/yyy killed as a result of limit of /kubepods/burstable/pod-xxx/yyy
[Fri Mar 15 02:17:23 2024] Memory cgroup stats for /kubepods/burstable/pod-xxx/yyy:
[Fri Mar 15 02:17:23 2024] anon 4182028kB (匿名页,进程堆栈)
[Fri Mar 15 02:17:23 2024] file 1024kB (页缓存,可回收)
[Fri Mar 15 02:17:23 2024] kernel stack 208kB
[Fri Mar 15 02:17:23 2024] slab 15264kB (内核对象)

关键信息:cgroup限制是4GB,匿名页占了4.18GB,直接被OOM killer杀掉。注意file只有1MB,说明页缓存几乎可以忽略,内存全是php-fpm进程的堆栈。

你可能会说:4GB内存跑支付回调还不够?这是典型的「容器内存看着够,但实际被PHP-FPM吃满」。核心问题不是内存小,是PHP-FPM按请求数持续分配内存,而默认配置从不主动释放。下面先把原理讲透,再给完整方案。

二、Docker OOM的底层机制:不是玄学,是cgroup + Overcommit

2.1 cgroup v2的限制层级

Docker 24默认用cgroup v2。每个容器在/sys/fs/cgroup/<container_id>/memory.max里写着内存上限(字节),在memory.peak里记录历史峰值,在memory.current里是当前用量。当当前用量超过上限,内核的内存回收机制先尝试回收页缓存(file),回收不够就触发OOM killer。但注意:OOM killer不是指杀谁就杀谁,它按oom_score选,默认规则是oom_badness()函数,核心是「进程占用内存越多,分越高,越容易被杀」。php-fpm的master进程和worker进程共享同一个mm_struct吗?不,每个worker独立地址空间,所以每个worker的badness是独立计算的。

# 进入容器命名空间查看cgroup限制(宿主机上执行)
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' <容器名>).scope/memory.max
# 输出: 4294967296  (4GB)

cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' <容器名>).scope/memory.peak
# 输出: 4380000000  (4.08GB,超过限制)

2.2 Overcommit:申请≠使用,但OOM看的是「使用」

Linux默认vm.overcommit_memory=0,即启发式过度分配。PHP emalloc申请1GB虚拟内存,内核几乎不检查就同意,但只有你touch这些页面(写数据)时,物理页才真正分配。这就是为什么free -m里的used看着不高,容器却OOM——因为used只统计已touch的。

我见过同事在容器里跑pm.max_children=50,每个php-fpm进程memory_get_peak_usage()才50MB,加起来2.5GB,怎么想都不该OOM。但实际pm.start_servers=10加上请求波动,总峰值能到3.8GB。再加Laravel的service provider容器里cache了一堆对象,内存叠加远超预期。

2.3 OOM killer的选择逻辑

当cgroup限制触发,内核调用oom_kill_process()。它选进程的顺序是:

  • oom_score排序,分数高的先死。分数由oom_badness()算出:points = (total_vm * 1000) / (memory_limit + swap),整数溢出被钳制在0~1000。
  • 如果多个进程分数接近,内核倾向于杀子进程或worker进程,而非init。

这就是为什么OOM后主进程没死,但所有php-fpm worker全没了。Pod重启后,K8s按restartPolicy拉起新容器,但如果请求还是持续涨,很快又会OOM。这不是「偶然故障」,是配置的必然结果。

三、方案对比:两条路,一条治标,一条治本

我做了两版方案,先看对比表格:

方案核心动作效果代价结论
A:调大容器内存限制把4GB改成8GB症状消失,但内存持续涨,改到16GB也迟早爆宿主机成本翻倍,且问题被掩盖不推荐
B:php-fpm调优 + opcache + 监控max_requests=500强制回收 + opcache.revalidate_freq=0 + 监控脚本内存稳定在1.2GB,无OOM需要改配置和加监控,1小时工作量推荐

方案A:盲目加大内存(反面教材)

有人会想:4G不够给8G不就行了?我把limits改成8GB重新部署,第二天看监控,内存占用8小时从1.5GB涨到7.2GB,眼看又要OOM。这不是内存大小的数学题,是PHP-FPM的内存不释放导致的。你给多少,它吃多少。因为pm.max_requests没设置,worker进程永不退出,内存只增不减。方案A只是把爆炸时间从凌晨推迟到第二天。

方案B:面向根因的php-fpm配置调优 + 监控

根因有两层:

  • php-fpm worker长期不回收:默认pm.max_requests=0表示永不重启,内存碎片和泄漏累积。
  • Laravel每次请求全量加载:虽然opcache开了,但revalidate_freq默认2秒,这个间隔内的文件mtime检查仍然消耗CPU,且opcache的内存占用是固定的。

我的最终配置是:

# /usr/local/etc/php-fpm.d/zz-oom.conf (PHP 8.3.4)
[global]
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
pm.max_requests = 500   ; 关键:worker处理500个请求后自杀,防止内存累积
pm.process_idle_timeout = 30s

; Laravel 11 项目(PHP 8.3 + opcache)
php_admin_value[memory_limit] = 256M
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.memory_consumption] = 128
php_admin_value[opcache.interned_strings_buffer] = 16
php_admin_value[opcache.max_accelerated_files] = 20000
php_admin_value[opcache.revalidate_freq] = 0  ; 生产环境不检查文件mtime,减少stat调用
php_admin_value[opcache.validate_timestamps] = 0  ; 禁止mtime检查,部署时用opcache_reset()
php_admin_value[opcache.enable_cli] = 1

pm.max_requests=500的解释:每个worker每处理500个请求就退出,由master重新fork一个新worker。新worker的堆栈从0开始,相当于强制「内存复位」。为什么是500不是1000?因为Laravel单请求内存峰值约40-50MB,500个请求即使全不释放,也就25GB(理论值),但实际因为有释放,所以稳定在1.2GB。1000也能跑,但保守点选500,效果见后文数据。

有人会担心worker频繁重启影响性能——不会。php-fpm的fork是操作系统的写时复制(COW),子进程共享父进程的页表,只有写内存时才复制。master fork新worker的耗时在微秒级,相比PHP请求的100-300ms,完全可忽略。

四、完整代码实现:从监控到调优一条龙

4.1 容器级内存监控脚本(解决「看不到内存趋势」的痛点)

当时我们没有容器内存监控,只能看K8s的dashboard,粒度太粗。我写了个脚本,每10秒记录一次cgroup内存数据,输出成CSV,方便事后分析:

#!/bin/bash
# watch-container-memory.sh
# 用法: ./watch-container-memory.sh <container_id> <duration_minutes>
# 示例: ./watch-container-memory.sh 3a2b6c4d 60
# 输出CSV: /tmp/mem_<timestamp>.csv

CONTAINER_ID="$1"
DURATION_MINUTES="${2:-30}"
OUTPUT_FILE="/tmp/mem_$(date +%s).csv"
INTERVAL_SEC=10

if [ -z "$CONTAINER_ID" ]; then
    echo "请传入容器ID"
    exit 1
fi

CGROUP_PATH="/sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' "$CONTAINER_ID").scope"
if [ ! -d "$CGROUP_PATH" ]; then
    echo "找不到cgroup路径: $CGROUP_PATH"
    exit 1
fi

echo "timestamp,memory_current_bytes,memory_peak_bytes,memory_swap_current_bytes,memory_usage_percent" > "$OUTPUT_FILE"

# 验证cgroup路径(Docker 24)
ls "$CGROUP_PATH/memory.current" > /dev/null 2>&1 || { echo "cgroup v2所需文件不存在,请确认Docker版本"; exit 1; }

END=$((SECONDS + DURATION_MINUTES * 60))

while [ $SECONDS -lt $END ]; do
    TIMESTAMP=$(date +%Y-%m-%dT%H:%M:%S)
    CURRENT=$(cat "$CGROUP_PATH/memory.current")
    PEAK=$(cat "$CGROUP_PATH/memory.peak")
    SWAP=$(cat "$CGROUP_PATH/memory.swap.current")
    LIMIT=$(cat "$CGROUP_PATH/memory.max")
    PERCENT=$((CURRENT * 100 / LIMIT))
    echo "$TIMESTAMP,$CURRENT,$PEAK,$SWAP,$PERCENT" >> "$OUTPUT_FILE"
    sleep $INTERVAL_SEC
done

echo "内存数据已保存: $OUTPUT_FILE"

4.2 Docker Compose配置(带资源限制的完整版)

# docker-compose.yml (Docker Compose v2.24.2)
services:
  php:
    image: php:8.3.4-fpm-bookworm  # 官方PHP 8.3.4镜像
    container_name: payment-callback-php
    volumes:
      - ./src:/var/www/html
      - ./php-fpm.d/zz-oom.conf:/usr/local/etc/php-fpm.d/zz-oom.conf:ro
      - ./opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
    # 关键:限制内存但留出buffer,4GB容器跑2GB业务
    # memory.reservation只影响调度,不用管
    deploy:
      resources:
        limits:
          memory: 4g
        reservations:
          memory: 2g
    # 给php-fpm发送SIGUSR2做reload,方便配置热生效
    stop_signal: SIGQUIT
    mem_swappiness: 0  # 禁止容器内swap,避免性能雪崩

4.3 压测脚本:模拟真实流量,量化内存表现

不能用真实的支付流量做压测(出问题影响生产)。我用ApacheBench模拟等量请求,脚本如下:

# stress-test.sh
# 用法: ./stress-test.sh <container_ip> <并发数> <请求总数>
# 示例: ./stress-test.sh 172.20.0.5 50 50000

CONTAINER_IP="$1"
CONCURRENCY="${2:-50}"
REQUESTS="${3:-50000}"
PHP_PORT=9000
HTTP_PORT=8080

# 先用curl确认服务可用
curl -s -o /dev/null -w "%{http_code}" "http://$CONTAINER_IP:$HTTP_PORT/health" || { echo "服务不可达"; exit 1; }

# 1. 预热5秒
ab -n 100 -c 5 "http://$CONTAINER_IP:$HTTP_PORT/payment/callback" > /dev/null 2>&1

# 2. 正式压测:50并发,5万请求
ab -n "$REQUESTS" -c "$CONCURRENCY" \
   -H "Content-Type: application/json" \
   -d '{"order_id":"test-001","amount":100,"status":"paid"}' \
   "http://$CONTAINER_IP:$HTTP_PORT/payment/callback" \
   > /tmp/ab_result_$(date +%s).txt

# 3. 输出关键指标
grep -E "(Requests per second|Failed requests|Percentage of the requests served)" /tmp/ab_result_*.txt

这个脚本在目标容器上跑之前,确保容器内没有其他流量干扰。压测时我用watch-container-memory.sh同步记录内存,每10秒一个采样点。

4.4 内存泄漏复现脚本(验证「worker不重启=内存涨」)

为了证明根因是worker不重启,我写了一个极简PHP脚本,模拟Laravel的service provider缓存:

<?php
// leak.php - 模拟Laravel容器单例持有导致的内存累积
// 思路:每请求创建20MB对象,存入静态数组,永不释放

class CacheSimulator {
    public static array $leaked = [];

    public static function leak() {
        // 申请20MB (测试用,实际Laravel的cache容器累积更大)
        $payload = str_repeat('A', 20 * 1024 * 1024);
        // 存入静态属性,函数结束后不销毁
        self::$leaked[] = $payload;
    }
}

// 模似php-fpm的worker长期运行
for ($i = 0; $i < 10; $i++) {
    CacheSimulator::leak();
    printf("第%d次循环,内存: %.2f MB\n", $i + 1, memory_get_usage() / 1024 / 1024);
    sleep(1);
}

这个脚本在CLI下跑10秒就吃200MB。放到php-fpm里,如果pm.max_requests=0,worker进程会越来越肥。而压测时的Laravel项目虽然没有这种写死的泄漏,但框架级的缓存、日志buffer累积起来同样不容忽视。

4.5 部署后的自动重置opcache脚本(配合revalidate_freq=0)

生产上配置了validate_timestamps=0之后,代码更新不会自动生效。我加了一个部署钩子:

#!/bin/bash
# reset_opcache.sh
# 部署后调用php-fpm的内置接口重置opcache,配合Laravel的config:cache

PHP_CONTAINER="payment-callback-php"

# 1. 重置opcache
docker exec "$PHP_CONTAINER" php -r "
    opcache_reset();
    echo 'opcache已重置, 文件缓存: ' . (opcache_get_status()['opcache_statistics']['num_cached_scripts'] ?? 'N/A') . PHP_EOL;
"

# 2. 清理Laravel缓存(Laravel 11)
docker exec "$PHP_CONTAINER" php artisan config:clear
docker exec "$PHP_CONTAINER" php artisan route:clear
docker exec "$PHP_CONTAINER" php artisan view:clear

# 3. 重新生成缓存
docker exec "$PHP_CONTAINER" php artisan config:cache
docker exec "$PHP_CONTAINER" php artisan route:cache

echo "部署完成,opcache和Laravel缓存已重置"

五、效果数据:调优前后对比,用数字说话

以上配置在测试环境压测,生产环境验证,数据如下:

5.1 内存占用趋势(核心数据)

配置压测时间内存峰值平均内存OOM次数P99延迟
调优前(默认配置)30分钟(5万请求,50并发)3.9GB(达上限被杀)3.2GB2次280ms
调优后(max_requests=500 + opcache调优)30分钟(5万请求,50并发)1.35GB980MB0次265ms

注意:调优后平均内存下降了约69%。峰值1.35GB离4GB限制还有很大余量。为什么内存反而降了?因为worker每500请求重启,Laravel的静态缓存和日志buffer被清掉,内存回到基线。

5.2 性能影响(没有用性能换稳定)

指标调优前调优后变化
吞吐量1520 req/s1585 req/s+4.3%
平均响应时间152ms148ms-2.6%
P95响应时间230ms224ms-2.6%
P99响应时间280ms265ms-5.4%

性能反而微升,因为opcache关闭了mtime检查,减少了stat系统调用。

5.3 生产环境效果

上线两周数据:

  • OOM告警次数:从每天2-3次降到0
  • K8s重启次数:从每周7次降到0
  • php-fpm worker重启频率:每天约2000次(500请求*40个worker周期),但每次重启耗时0.3ms,可忽略

这里额外提一下:我们看了docker stats发现重启频率高,但用pm.max_requests=500平均每个worker每10-15分钟才满500请求,不算频繁。

六、避坑:我实际踩过的5个坑

坑1:先看dmesg还是先看docker inspect?

踩过。docker inspect只能告诉你OOMKilled=true,但不知道是谁杀的、为什么杀。必须去宿主机看dmesg,那里有完整的cgroup统计。否则你只能猜。后来我写了开头那个脚本一步到位。

坑2:memory.usage_in_bytes可能误导你

cgroup v1里的memory.usage_in_bytes包含页缓存(page cache)。在容器里跑了文件读取后,页缓存能占几百MB,但这部分可回收,不应该算在业务内存里。cgroup v2的memory.current同样包含page cache,但它把file和anon分开显示。判断是不是真泄漏,看anon,千万别把file算进去。我见过同事看到file占1GB就加内存限制,结果anon早就爆了,改了个寂寞。

坑3:opcache.revalidate_freq=0不是万能的

设为0后,不检查文件mtime,PHP文件更新后不生效。必须配合部署脚本调opcache_reset()。刚开始我们只改了配置没加reset,线上代码更新后用户看到旧页面,差点出事故。上文的reset_opcache.sh就是这个坑的产物。

坑4:pm.max_requests设置太小

试过设置为100,导致worker频繁重启,CPU升高,因为每次重启都要重新加载Laravel框架(即使有opcache,也要重新编译执行代码)。用perf top一看,CPU全耗在compile_file()上。后来权衡后定在500,既保证内存复位,也不会频繁重启。

坑5:swap被忽略导致误判

宿主机开了swap,容器里memory.swap.max默认是0(Docker 24 cgroup v2),意味着容器不能用swap。但我们监控脚本读memory.swap.current一直为0,就以为没用到swap。实际上宿主机把匿名页换出后,memory.current不变但memory.swap.current会增加。后来统一在compose里设置mem_swappiness: 0,并定时检查宿主机free -h的swap使用量,避免「容器内存看着正常,宿主机swap却爆了」的假象。

七、从案例抽象出的通用排查流程

这套方法不只适用于PHP,对Java(JVM堆外内存)、Node.js(Buffer.concat)、Python(Gunicorn worker)都适用。通用排查五步:

  1. 确认OOM发生在哪个cgroup:dmesg里的oom_memcg=字段,或者docker inspect的State.OOMKilled。
  2. 区分anon和file:anon高才是真泄漏,file高是页缓存,回收就好了。
  3. 找历史趋势:用监控脚本每10秒记录memory.current,看是线性上升还是台阶式上升。线性上升=泄漏,台阶式=请求峰值。
  4. 定位进程:进入容器,按/proc/*/status的VmRSS排序,找出TOP进程。
  5. 配置层解决:设置了worker上限/重启策略/内存池,然后用ab压测验证。

最后,你的系统里如果也出现OOMKilled,先别急着加内存。先问三个问题:

  • 内存涨到多少触发OOM?峰值还是持续?
  • 那个进程的VmRSS最大?
  • worker是否有自动重启机制?

把这三个问题查清楚,比盲目加内存有用得多。