5500 个 WordPress 站点里每个 PHP-FPM worker 600MB:一次被当成 DDoS 的 OPcache bug 排障
客户报障:站点全挂,像 DDoS。服务器上约 5500 个 WordPress 站点,nginx、PHP-FPM 8.4、MariaDB,125GB 内存,Debian 13。第一次找到了扫描器,在 nginx 里封掉,站点恢复。两天后客户又来了:每五分钟全崩一次。这次原因不在外面——每个新起的 PHP-FPM worker 从第一个请求起就占约 600MB,OOM-killer 轮流杀数据库和 nginx。撑大 worker 的是 OPcache 的 bug,在负载下每次缓存重置都会触发。重置缓存的是 Yoast SEO 在更新时——不是全站批量更新,而是补更那 467 个之前计划更新没跑成的站点。最后靠两条命令的顺序解决。
服务器配置
一个 PHP-FPM 池:pm.max_children = 200,pm.start_servers = 20,pm.min_spare_servers = 10,pm.max_spare_servers = 30,pm.max_requests = 500。一个 1GB 的 OPcache:opcache.enable=1,opcache.memory_consumption=1024,opcache.max_accelerated_files=200000,opcache.validate_timestamps=1,opcache.revalidate_freq=0。FPM master 只有一个,所以 5500 个站点共用同一块 OPcache 内存。
第一次:扫描器
32 核上 load average 205–235,200 个 worker 全占满,116GB 内存,swap 打满。外部约 34 rps,打到 1536 个域名,一半以上在找 .env、.git 之类的文件。WordPress 上每个这种请求都会走一遍完整的 PHP 404 渲染,所以要在进 PHP 之前拦掉:
map $uri $scanner_block {
default 0;
"~^/\.well-known/" 0;
"~/\." 1;
"~*(phpinfo|serverless\.ya?ml|docker-compose)" 1;
"~*\.(sql|bak|swp|old|orig|env|ya?ml)$" 1;
}
if ($scanner_block) { return 404; }
/.well-known/ 单独放行,避免影响证书签发;返回 404 而不是 444,因为前面还有负载均衡和 Cloudflare。第一分钟 nginx 自己回了 1013 个 404,正常请求 293 个,没有 5xx。
第二次:每五分钟一次
这次换了扫描器,翻的是归档文件:GET /phpinfo.zip、/cvs.zip、/usermanager.zip(status 404/499)。让客户先别动,装了个脚本每五分钟记一次 nginx/PHP/数据库的内存,从内核日志开始查:
# journalctl -k --since 2026-10-01 | grep -c 'Killed process'
82
按进程名:36 个 mariadbd,22 个 systemd,17 个 (sd-pam),7 个 nginx。10 月 12 日 18 次,13 日 48 次,15 日 16 次。在这之前 OOM-killer 一直没动静。
数据库最大,Restart=on-abnormal,RestartSec=5s——systemd 拉起来,数据库再吃内存,循环往复;每次数据库被杀,所有站点报错一到两分钟。
systemd/(sd-pam) 在名单里不是 PID 1,而是用户会话管理器;systemd 给了它更高的优先级:
# systemctl show user@1003.service -p OOMScoreAdjust
OOMScoreAdjust=100
内核会按总内存的十分之一给它加权,所以一个几 MB 的进程能顶得上 12GB+ 的进程。
nginx:内核只杀一个进程,剩下的由 systemd 收尾。服务是 OOMPolicy=stop(默认值)——任一进程被杀后 systemd 停掉整个服务,而 Restart=no,站点一直躺到人工拉起。日志里是:"A process of this unit has been killed by the OOM killer",接着 Stopping timed out、SIGKILL、Failed with result 'oom-kill'。
不是机器人
proxy-audit.log(所有站点的 nginx 访问总日志)和 nginx 错误日志里,按天统计归档请求和 upstream timed out。第一次崩溃前四天有 16573 次归档请求,比任何崩溃日都多,却没有一次超时;超时是在第一次崩溃当天才出现的。走 FPM 的纯 404 耗时 0.29s;34 rps 按 Little 定律只要十来个 worker(34 × 0.29 ≈ 10),实际 200 个全忙,等于每个请求接近 6s。慢是服务器状态造成的。
600MB 的 worker
# ps -o pid,etimes,rss,time -C php-fpm8.4 --sort=rss | head -8
master 11MB;worker 活 5 秒,CPU 不到 1 秒,RSS 分别是 594932、603992、605512 KB;老一点的 648276、698096、704116 KB。
探针测出来的矛盾
直接往 FPM socket 打一个不存在的 .zip 请求,用 cgi-fcgi,小脚本替换 $_SERVER,加载 WordPress 并输出统计:
# SCRIPT_FILENAME=/tmp/wp404-probe.php REQUEST_METHOD=GET PROBE_HOST=example.com PROBE_URI=/probe-20261015-xq7.zip timeout 110 cgi-fcgi -bind -connect /run/php/php8.4-fpm.sock
结果:
{"time_s": 0.29, "peak_mb_real": 36, "peak_mb": 34, "queries": 133, "is_404": true, "included_files": 1464}
PHP 自己不知道那 600MB,memory_get_peak_usage() 看不见。说明内存在更下面。
/proc 里的 508MB 匿名段
# grep -E '^(VmRSS|RssAnon|RssFile|RssShmem)' /proc/2481721/status
VmRSS: 648724 kB
RssAnon: 533656 kB
RssFile: 30196 kB
RssShmem: 84872 kB
RssShmem 是 OPcache 共享内存,所有 worker 共用。521MB 的 RssAnon 是每个 worker 自己的。smaps 里超过 10MB 的段只有两个:/dev/zero (deleted) 84868 KB(OPcache 共享内存)和一个匿名段 7f6cdd686000-7f6cfc6a7000,508032 KB,Rss = Size = Private_Dirty = Anonymous,AnonHugePages 505856。30 个进程(30 个 worker + master)持有这个地址;master 的 RssAnon 是 5MB。也就是说 worker 在 fork 之后、第一个请求时就分配并立刻填满——worker 一出生就背上这笔账。
php-src #23859
"Repeated opcache_reset() under concurrency permanently grows ZCSG(map_ptr_last), inflating every newly spawned php-fpm worker"。报告在排障前三周开的,状态 Needs Triage,没有补丁。报告者那边 worker 一出生就约 960MB,整个 FPM 52GB,graceful reload 后 2.4GB;在 8.1/8.3/8.4/8.5 上都复现,包括 Debian 13 的 8.4。
机制:每个 PHP 进程有一张 map_ptr 表,OPcache 里每个函数、方法、类占一个槽。把脚本放进缓存的进程从自己的 CG(map_ptr_last) 取新槽,并把计数写进共享的 ZCSG(map_ptr_last)(zend_persist.c:1457)。新 worker 从缓存加载第一个脚本时,把自己的表扩到共享计数(zend_accelerator_util_funcs.c:388),而每个请求开始时整张表清零(zend.c:1321)。缓存重启把共享计数归零,但只归执行重置的那个进程(ZendAccelerator.c:2714);其他 worker 察觉到重启,只清路径和正则缓存(2748-2756),自己的计数保留旧值。第一个往缓存里放脚本的 worker 在旧值之上分配槽位,再写回共享计数——计数不会回落,每次重启再涨一代。直到 FPM 重启为止。任何重置都算:opcache_reset()、缓存内存不足、哈希表溢出,三者走同一段代码(2695-2730)。重置等待期间所有进程释放缓存,OPcache 对所有人关闭(3473-3476),新的重置不再排队("don't schedule twice",3459),所以 opcache_reset() 的调用数会明显多于统计里的重启次数。
指纹:表按 4096 个指针、也就是 32KB 一步增长(zend.c:2063);508036 KB = 15876 步 × 32KB,再加 4KB。如果这个匿名段在所有 worker 上大小一致、master 上没有,基本就是它。报告者那边 1800 次重置对应每 worker 约 200MB;这里两百来次重启就够了。两次重启之间往缓存里塞的脚本越多,这一代越长,而缓存里有 45000 个脚本。
额外代价:表在每个请求开始时清零,等于每个请求 memset 496MB——报告者估计每请求几十毫秒 CPU(未实测)。
谁在重置 OPcache
统计要从 FPM 本身取(命令行 php -r 没用——CLI 有独立的 OPcache,通常是关的)。探针结果:used_mb 979,free_mb 0,wasted_pct 4.4,cache_full true,scripts 45037,hit_rate 34.72,oom_restarts 2,hash_restarts 0,manual_restarts 205。缓存塞满,三分之二的请求没有命中。
找调用:
# grep -rn opcache_reset wp-content/plugins wp-content/themes
wp-content/plugins/wordpress-seo/wp-seo-main.php:344: if ( $should_invalidate_opcache && function_exists( 'opcache_reset' ) ) {
wp-content/plugins/wordpress-seo/wp-seo-main.php:346: @opcache_reset();
除 Yoast 外只有 Google Site Kit(通过 upgrader_process_complete 在任意更新后重置,5500 个站点里只有一个装了)。Google 那段旁边有注释:"opcache_reset is prohibited on the WordPress VIP platform due to memory corruption"。
Yoast 的代码(简化):如果保存的版本比代码旧,按 site_url() 的哈希选 50% 的站点调用 @opcache_reset();然后 new WPSEO_Upgrade()。所以重置不是「第一个请求」触发的,而是「第一次启动 WordPress」触发的——而且是在有流量的 PHP-FPM 里。
为什么之前没崩
FPM 每一到四天 reload 一次,每次 reload 把表清零(OPcache 共享内存重建,计数从零开始)。崩溃前最后一次 reload 在第一次崩溃前三天。OPcache 的重置不逐条记录,整个画面是靠更新日志、sudo 日志和 OOM-killer 报告拼出来的。
站点由一个 cron 脚本每五分钟更新一次:2026-09-29 wordpress-seo 28.5 → 28.6,Updated: 5058,FAILED: 469(其中 467 个站点日志里有 [PLUGIN:wordpress-seo] ERROR: update failed);cron 没有再去碰它们。漏掉的在第一次崩溃当天早上手动补跑:对所有站点 wp plugin update --all,外加几处 wp-config.php 修改(journalctl _COMM=sudo 显示 wp option update tfa_administrator 1、wp config set WP_DEBUG false --raw、wp config set WP_DEBUG_LOG false、wp plugin update --all)。WP-CLI 更新完不会加载站点,所以 Yoast 的更新代码连同 opcache_reset() 是由第一个访客执行的,也就是有流量时的 PHP-FPM。那天早上约 467 个站点完成更新,其中大约一半重置了 OPcache;重置分散在几个小时里。
表的增长是事后按 OOM-killer 报告里的 total_vm 算的:刚起、还没接请求的 worker 内存和 master 一致;接完第一个请求后,多出来的正好是表加一点堆。
结果
| 指标 | 之前 | 之后 |
|---|---|---|
| 新 worker 自身内存(RssAnon) | 521 MB | 21 MB |
| 新生 worker RSS | 581–591 MB | 66–95 MB |
| 最大匿名段 | 496 MB | 6 MB |
| 新 worker 比 master 重(total_vm) | 473 MB | 14 MB(19 小时后) |
| OOM 击杀 | 四天 82 次 | 0 |
opcache.restrict_api | 空 | /nonexistent |
process_control_timeout | 0 | 10s |
nginx Restart | no | on-failure,5s |
nginx 与 PHP-FPM 的 OOMPolicy | stop | continue |
缓解手段:用 opcache.restrict_api=/nonexistent 禁止 Web 端调用 opcache_reset;设置 process_control_timeout;systemd 用 OOMPolicy=continue;nginx 用 Restart=on-failure;重启 FPM 清表。该 issue 在成文时仍未修复,属于未打补丁的 bug,只能靠运维约束规避。