前言
先说清楚版本关系:Docker 容器的资源限制由内核 cgroup、Docker 引擎和 docker-compose.yml 决定,PHP-FPM 的进程池由www.conf决定,两者都与 PHP 的语言版本无关。PHP 8.5 并没有新增任何容器资源限制配置项。本文之所以仍以 8.5 为例,是因为 8.5 已经发布(2025 年 11 月 20 日),线上新项目大多在用它,而它在容器里确实有两个值得注意的差异点:致命错误默认带回溯(fatal_error_backtraces),以及 JIT / opcache 的常驻内存必须计入容器限额的预算。
容器里 PHP 出问题时的症状很有辨识度,但也特别容易被误判。第一种是容器周期性重启,docker ps显示退出码 137,而 PHP 自己的error_log里干干净净——没有任何"内存不足"的提示,因为进程是被内核直接杀掉的。第二种是接口偶发 502,nginx 日志里写着connect() to unix:/run/php-fpm.sock failed (11: Resource temporarily unavailable),看起来像 PHP 崩了,实际是 FPM 的进程池被占满、请求在队列里溢出了。第三种最迷惑:改小了memory_limit之后容器确实不重启了,但接口开始报Allowed memory size exhausted,业务侧以为是代码问题,其实是三个数字没对齐。
本文把"容器 cgroup 限制、PHP 的memory_limit、FPM 的pm.max_children"这三个数字的关系讲清楚,给出可复制的 compose 与www.conf配置,以及区分"被 OOMKill"和"PHP 报内存超限"的排查手段。
一、三个数字的分工,以及它们必须满足的不等式
| 层级 | 配置项 | 作用域 | 超限后的表现 |
|---|---|---|---|
| 内核 / Docker(cgroup) | mem_limit、--memory、pids_limit | 整个容器(含 nginx、日志缓冲、共享内存) | 进程被 SIGKILL,退出码 137,无 PHP 日志 |
| PHP INI | memory_limit | 单个 PHP 进程的堆内存 | PHP Fatal error: Allowed memory size ... exhausted,可捕获可记录 |
| PHP-FPM | pm.max_children | 进程池的并发上限 | 请求排队,队列溢出后 nginx 返回 502 |
必须满足的不等式是这条:
pm.max_children × memory_limit + 共享内存(opcache 等) + 预留 < 容器内存上限举个最常见的翻车例子:容器内存给 1G,pm.max_children = 20,memory_limit = 256M。20 × 256M 已经是 5G,理论上永远轮不到 PHP 自己报"内存不足"——只要并发上来,内核会先把进程杀掉。此时你看到的是 137 和重启,而不是一条能定位的致命错误。
反过来也要成立:memory_limit不能大于容器的可用内存。否则 PHP 永远来不及抛出可捕获的致命错误,问题就从"能记录、能告警、能定位"退化成"进程凭空消失"。
第三个数是pm.max_children,它决定了并发承载能力。设小了,请求在 FPM 的监听队列里排队,队列满了 nginx 直接 502;设大了,就是上面的 OOM。它必须由实测的单进程峰值倒推,不能凭感觉。
二、Docker 侧怎么写
# docker-compose.yml —— Compose V2 services: php: image: php:8.5-fpm-alpine deploy: resources: limits: cpus: "2.0" memory: 1g reservations: cpus: "0.5" memory: 256m # 传统字段;与 deploy.resources.limits 同时存在时以前者为准,实际项目里二选一 mem_limit: 1g memswap_limit: 1g # 与 mem_limit 相等 = 禁用 swap pids_limit: 256 # 兜底,防止进程数爆炸 ulimits: nofile: soft: 65535 hard: 65535 environment: TZ: Asia/Shanghai volumes: - ./conf/php.ini:/usr/local/etc/php/conf.d/zz-app.ini:ro - ./conf/www.conf:/usr/local/etc/php-fpm.d/zz-app.conf:ro - ./src:/var/www/html healthcheck: test: ["CMD", "php-fpm", "-t"] interval: 30s timeout: 5s retries: 3对应的docker run参数是这样映射的:
| compose 字段 | docker run参数 | 作用 |
|---|---|---|
mem_limit: 1g | --memory=1g | 容器内存上限 |
memswap_limit: 1g | --memory-swap=1g | 内存 + swap 的总上限,等于--memory表示禁用 swap |
cpus: "2.0" | --cpus=2.0 | CPU 配额(CFS 带宽) |
pids_limit: 256 | --pids-limit=256 | 容器内最大进程 / 线程数 |
ulimits.nofile | --ulimit nofile=65535:65535 | 文件句柄上限 |
memswap_limit这一项最容易被忽略:如果只设--memory=1g而不设--memory-swap,容器还能再用一份等量 swap,实际可用内存变成 2G;在 swap 慢速的设备上,这会让"内存不足"表现为长时间卡顿而不是干脆被杀掉,更难排查。要禁用 swap 就显式设成与内存相等。
三、PHP-FPM 侧怎么和容器对齐
FPM 的进程池配置(www.conf)需要显式设置pm.max_children,并配上与容器匹配的资源约束:
; conf/www.conf —— 与上面的容器限制对齐 [www] user = www-data group = www-data listen = 0.0.0.0:9000 listen.backlog = 511 ; 队列长度;太小会在并发时直接 502 ; 固定进程数在容器里比 dynamic 更可预测,内存占用等于 max_children × 单进程常驻 pm = static pm.max_children = 12 pm.max_requests = 500 ; 定期回收,防止内存碎片与缓慢泄漏累积 request_terminate_timeout = 30s ; FPM 强制杀掉跑飞的 worker request_slowlog_timeout = 5s slowlog = /var/log/php-fpm/slow.log rlimit_files = 65535 ; 输出到容器的 stdout/stderr,交给 docker logs 收集 catch_workers_output = yes decorate_workers_output = no php_admin_value[error_log] = /proc/self/fd/2 php_admin_flag[log_errors] = on ; 状态页,供健康检查与监控使用 pm.status_path = /_fpm_status ping.path = /_fpm_pingPHP 的 INI 配置:
; conf/php.ini —— 挂载到 conf.d/zz-app.ini memory_limit = 128M max_execution_time = 30 error_reporting = E_ALL display_errors = Off log_errors = On ; opcache 的共享内存是所有子进程共享的一份,只计一次,别乘以 max_children opcache.enable = 1 opcache.memory_consumption = 128 opcache.max_accelerated_files = 20000 opcache.validate_timestamps = 0 ; PHP 8.5 起致命错误默认带回溯,日志量大时可以关掉以省一点开销 fatal_error_backtraces = Offnginx 侧只需要把请求转给 FPM,并把状态页挡在内网:
location ~ \.php$ { include fastcgi_params; fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name; fastcgi_read_timeout 35s; # 要比 request_terminate_timeout 略大 } location = /_fpm_status { allow 172.16.0.0/12; deny all; include fastcgi_params; fastcgi_pass php:9000; fastcgi_param SCRIPT_NAME /_fpm_status; }四、max_children 应该填多少:用实测值倒推
不要凭感觉,也不要照抄博客。用历史的峰值常驻内存(RSS)来算——Linux 内核已经帮每个进程记好了这个数:读进程的 status 文件里的VmHWM字段即可(脚本见下)。
# 在有代表性流量之后执行,读取每个 FPM worker 的历史峰值 RSS for pid in $(pgrep -f 'php-fpm: pool www'); do printf 'pid %s ' "$pid" grep VmHWM "/proc/$pid/status" done拿到单进程峰值后,用下面这个脚本算建议值。注意memory_limit要大于实测峰值(留出余量,让 PHP 有机会报可捕获的致命错误),而max_children × memory_limit要小于容器内存。
<?php // calc_pool.php —— 需 PHP 8.0+,在宿主机或容器里用 CLI 跑 declare(strict_types=1); $containerBytes = 1024 ** 3; // 容器内存上限 1 GiB $peakPerChild = (int) ($argv[1] ?? 60 * 1024 * 1024); // 实测单进程峰值,默认 60 MiB $opcacheShared = 128 * 1024 * 1024; // opcache 共享内存,只计一次 $reserveRatio = 0.15; // 预留给日志、nginx、内核缓存 $safety = 1.5; // memory_limit 相对峰值的余量系数 // 1) 先决定 memory_limit:比实测峰值高,且不能超过容器的可用内存 $usable = (int) ($containerBytes * (1 - $reserveRatio)) - $opcacheShared; $memoryLimit = (int) (ceil($peakPerChild * $safety / 1048576) * 1048576); // 2) 再让 max_children × memory_limit 落在可用内存之内 $maxChildren = max(1, intdiv($usable, $memoryLimit)); printf("容器内存上限 : %6.0f MiB\n", $containerBytes / 1048576); printf("opcache 共享内存 : %6.0f MiB\n", $opcacheShared / 1048576); printf("可用给进程池 : %6.0f MiB\n", $usable / 1048576); printf("实测单进程峰值 : %6.0f MiB\n", $peakPerChild / 1048576); printf("建议 memory_limit : %6d MiB\n", $memoryLimit / 1048576); printf("建议 max_children : %6d\n", $maxChildren); printf("预算占用 : %6.0f MiB(应小于可用值)\n", ($memoryLimit * $maxChildren) / 1048576);输出示例(数值仅供演示算法,实际请用你自己的实测值):
容器内存上限 : 1024 MiB opcache 共享内存 : 128 MiB 可用给进程池 : 742 MiB 实测单进程峰值 : 60 MiB 建议 memory_limit : 90 MiB 建议 max_children : 8 预算占用 : 720 MiB(应小于可用值)算完之后一定要压测验证:按目标 QPS 打一轮,同时观察docker stats与容器的memory.events。算法只能给出起点,真实的峰值取决于业务里最大的那次查询、最大的一张图片、最深的一次递归。
五、区分"被 OOMKill"和"PHP 报内存超限"
这是排查时最该先做的一步,因为两者的应对动作完全相反:前者要减并发或加限制,后者要改代码或加memory_limit。
| 观测项 | 被 cgroup OOMKill | PHP 触及memory_limit |
|---|---|---|
| PHP 错误日志 | 没有内存相关记录 | 有Allowed memory size of N bytes exhausted |
| 容器退出码 | 137(128 + SIGKILL 9) | 通常 255 或被 FPM 记为 worker 异常退出 |
| 触发统计 | cgroup 的oom_kill计数增加 | PHP 自己抛出致命错误 |
| 覆盖范围 | 整容器(含 nginx、日志、共享内存) | 仅单个 PHP 进程的堆 |
| 首要动作 | 降pm.max_children或加容器内存 | 加memory_limit或优化代码 |
对应的命令:
# 1) 容器是否被 OOM 杀掉、退出码是多少 docker inspect --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' php # 2) cgroup v2 的事件计数:oom_kill 不为 0 就说明有进程被内存杀过 docker exec php cat /sys/fs/cgroup/memory.events # 3) 当前限制与历史峰值 docker exec php cat /sys/fs/cgroup/memory.max docker exec php cat /sys/fs/cgroup/memory.peak # 4) 实时占用(注意这是瞬时值,峰值看不到) docker stats --no-stream php # 5) 从宿主机侧看内核的 OOM 记录 dmesg | grep -i -E 'oom|killed process'一个常见误判是"docker stats里内存用了不到一半,所以不会有 OOM"——docker stats显示的是采样瞬间的值,而 OOM 往往发生在某个大请求的峰值时刻,几秒钟就结束了。判断有没有发生过 OOM,唯一可靠的依据是memory.events里的oom_kill计数和State.OOMKilled。
常见坑点
- ❌
pm.max_children用模板里的默认值或照抄博客,同时把memory_limit设成 256M
✅ 先算不等式:max_children × memory_limit + 共享内存 + 预留 < 容器上限。容器 1G 时,max_children通常是个位数,不是几十
- ❌ 容器
mem_limit比 PHP 的memory_limit还小
✅ 这样 PHP 永远来不及报可捕获的致命错误,进程会被 SIGKILL,问题从"能告警、能定位"退化成"凭空消失"。memory_limit必须小于容器可分给单个进程的上限
- ❌
pm.max_children设得过小、listen.backlog也用默认值
✅ 会看到 nginx 日志里的connect() to ... failed (11: Resource temporarily unavailable),用户侧就是 502。这个报错的意思是"FPM 来不及接连接",不是 PHP 崩了
- ❌ 只设
--memory=1g,不管--memory-swap
✅ 此时容器还能再用一份等量 swap,内存问题的表现会从"干脆被杀"变成"长时间卡顿"。要禁用 swap,把memswap_limit设成与mem_limit相等
- ❌ 容器被限到 1 核,却不管镜像里库的线程数
✅ 部分扩展与图像处理库按宿主机的逻辑核数创建线程,配额只有 1 核时线程争抢反而更慢。用--cpuset-cpus限定核,或设置OMP_NUM_THREADS之类的环境变量对齐
- ❌ 只设内存限制,不设
pids_limit与ulimits.nofile
✅ 进程或句柄耗尽时报的是fork: Cannot allocate memory、Too many open files,和内存问题长得完全不一样。容器里这两个必须显式兜底
- ❌ 用
--oom-kill-disable来"避免容器被杀"
✅ 这会让最该被回收的进程活得最久,把压力转嫁给宿主机。正确做法是降低max_children或提高内存限制
- ❌ 只设
max_execution_time,不设request_terminate_timeout
✅max_execution_time不统计脚本之外的等待时间(数据库查询、流操作、系统调用),慢查询把 worker 挂住时它形同虚设;必须由 FPM 的request_terminate_timeout强制回收
总结
| 关注点 | 配置位置 | 建议值来源 | 失败表现 |
|---|---|---|---|
| 容器内存 | mem_limit/--memory | 按业务峰值的 1.5~2 倍起步 | 退出码 137、OOMKilled=true |
| swap | memswap_limit | 等于mem_limit以禁用 | 卡顿而非重启,更难查 |
| 单进程堆上限 | memory_limit | 实测峰值 × 1.5,且小于容器可用内存 | Allowed memory size exhausted |
| 进程池并发 | pm.max_children | (容器内存 × 0.85 − 共享内存) ÷memory_limit | 排队溢出,nginx 502 |
| 进程数兜底 | pids_limit、ulimits.nofile | 按 worker 数 × 2~4 起步 | fork 失败、句柄耗尽 |
| 请求强制回收 | request_terminate_timeout | 略小于 nginx 的fastcgi_read_timeout | worker 被挂死、接口超时 |
资源限制的本质是"用三个互相约束的数字换取可预测性":容器限制是硬墙,memory_limit是软墙,pm.max_children是并发闸门。把它们的先后顺序排对——先量出单进程峰值,再定memory_limit,最后按容器内存倒推max_children——容器里那些"无缘无故重启""偶发 502""内存报错但改代码没用"的问题,绝大多数都能在配置层面定位清楚。