一、凌晨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.2GB | 2次 | 280ms |
| 调优后(max_requests=500 + opcache调优) | 30分钟(5万请求,50并发) | 1.35GB | 980MB | 0次 | 265ms |
注意:调优后平均内存下降了约69%。峰值1.35GB离4GB限制还有很大余量。为什么内存反而降了?因为worker每500请求重启,Laravel的静态缓存和日志buffer被清掉,内存回到基线。
5.2 性能影响(没有用性能换稳定)
| 指标 | 调优前 | 调优后 | 变化 |
|---|---|---|---|
| 吞吐量 | 1520 req/s | 1585 req/s | +4.3% |
| 平均响应时间 | 152ms | 148ms | -2.6% |
| P95响应时间 | 230ms | 224ms | -2.6% |
| P99响应时间 | 280ms | 265ms | -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)都适用。通用排查五步:
- 确认OOM发生在哪个cgroup:dmesg里的
oom_memcg=字段,或者docker inspect的State.OOMKilled。 - 区分anon和file:anon高才是真泄漏,file高是页缓存,回收就好了。
- 找历史趋势:用监控脚本每10秒记录memory.current,看是线性上升还是台阶式上升。线性上升=泄漏,台阶式=请求峰值。
- 定位进程:进入容器,按
/proc/*/status的VmRSS排序,找出TOP进程。 - 配置层解决:设置了worker上限/重启策略/内存池,然后用ab压测验证。
最后,你的系统里如果也出现OOMKilled,先别急着加内存。先问三个问题:
- 内存涨到多少触发OOM?峰值还是持续?
- 那个进程的VmRSS最大?
- worker是否有自动重启机制?
把这三个问题查清楚,比盲目加内存有用得多。