
Клиент написал: сайты лежат, похоже на DDoS. На сервере около 5 500 сайтов WordPress, nginx, PHP-FPM 8.4, MariaDB, 125 ГБ памяти, Debian 13. Я нашёл сканер, закрыл его в nginx, сайты поднялись. Через два дня клиент написал снова: всё падает каждые пять минут.
Причина оказалась не снаружи. Каждый новый воркер PHP-FPM с первого же запроса весил около 600 МБ, и OOM-killer по очереди убивал базу и nginx. Воркеры раздувал баг в OPcache, который срабатывает на каждом сбросе кеша под нагрузкой. Сбрасывал кеш 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
И один OPcache на 1 ГБ:
opcache.enable=1 opcache.memory_consumption=1024 opcache.max_accelerated_files=200000 opcache.validate_timestamps=1 opcache.revalidate_freq=0
Мастер FPM один, поэтому общая память OPcache тоже одна на все 5 500 сайтов. Размеры дальше пишу так, как их считают ps и /proc: в мегабайте 1024 КБ.
Первый раз: сканер
Load average 205-235 на 32 ядрах, заняты все 200 воркеров, занято 116 ГБ памяти, своп забит. Снаружи около 34 запросов в секунду на 1 536 доменов. Больше половины искали .env, .git и прочие служебные файлы, адреса сотнями из облаков.
Обычный сканер, такие ходят по любому публичному серверу. Но на WordPress каждый такой запрос превращается в полный рендер страницы 404 через PHP, поэтому быстрее всего не пускать их до PHP: карта в nginx и одна строка в сниппете, который подключают все виртуальные хосты.
Карта и строка в сниппете
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, оборванное соединение они превратили бы в 502 для живого посетителя.
В первую минуту с фильтром nginx сам отдал 1 013 ответов 404, нормальных ответов было 293, пятисотых ни одной. По ходу часть сервисов успела вылететь по OOM, их подняли. Выглядело как обычная история со сканером.
Второй раз: “каждые пять минут”
Через два дня клиент пишет: снова DDoS, теперь падает каждые пять минут. Сканер другой, перебирает архивы:
"GET /phpinfo.zip HTTP/2.0" status=404 "GET /cvs.zip HTTP/2.0" status=404 "GET /usermanager.zip HTTP/2.0" status=404 "GET /fortune.zip HTTP/2.0" status=499
Можно было добавить архивы в ту же карту и разойтись. Но тогда через пару дней пришёл бы третий сканер. Я попросил клиента ничего не трогать на сервере, поставил скрипт, который раз в пять минут пишет в лог память nginx, PHP и базы, и начал с журнала ядра.
Кого убивают
# journalctl -k --since 2026-10-01 | grep -c 'Killed process' 82 # journalctl -k --since 2026-10-01 | grep 'Killed process' | grep -o '([a-z()-]*)' | sort | uniq -c | sort -rn 36 (mariadbd) 22 (systemd) 17 ((sd-pam)) 7 (nginx) # journalctl -k --since 2026-10-01 -o short-iso | grep 'Killed process' | cut -c1-10 | uniq -c 18 2026-10-12 48 2026-10-13 16 2026-10-15
82 убийства с начала месяца, и все за последние четыре дня. До этого OOM-killer молчал. В день после фильтра ни одного, потом снова. Так выглядело утро второго обращения:
# journalctl -k --since 2026-10-15 --until '2026-10-15 13:00' | grep 'Killed process' Oct 15 04:21:01 srv kernel: Out of memory: Killed process 1597116 (mariadbd) total-vm:48713652kB, anon-rss:23905292kB, ... Oct 15 08:23:20 srv kernel: Out of memory: Killed process 2704383 (mariadbd) total-vm:44486476kB, anon-rss:23059620kB, ... Oct 15 08:23:27 srv kernel: Out of memory: Killed process 3285965 (systemd) total-vm:22132kB, anon-rss:2048kB, ... Oct 15 12:19:52 srv kernel: Out of memory: Killed process 3847971 (mariadbd) total-vm:43939700kB, anon-rss:13486656kB, ... Oct 15 12:47:58 srv kernel: Out of memory: Killed process 1661507 (systemd) total-vm:22140kB, anon-rss:2048kB, ... Oct 15 12:47:58 srv kernel: Out of memory: Killed process 1661509 ((sd-pam)) total-vm:22436kB, anon-rss:1032kB, ... Oct 15 12:47:58 srv kernel: Out of memory: Killed process 430332 (mariadbd) total-vm:44793656kB, anon-rss:10777012kB, ... Oct 15 12:48:04 srv kernel: Out of memory: Killed process 3776728 (nginx) total-vm:3247688kB, anon-rss:2518036kB, ...
База самая крупная, поэтому её убивают чаще всех, 36 раз из 82. У MariaDB в юните Restart=on-abnormal и RestartSec=5s: systemd её поднимает, база снова набирает память, и всё повторяется. Каждое убийство базы это минута-две ошибок на всех сайтах, пока она стартует. Отсюда и “падает каждые пять минут” в сообщении клиента.
systemd и (sd-pam) в списке не PID 1, а менеджер сессий пользователя. systemd даёт ему повышенный приоритет на убийство:
# systemctl show user@1003.service -p OOMScoreAdjust OOMScoreAdjust=100
К оценке такого процесса ядро прибавляет десятую часть всей памяти, и процесс на несколько мегабайт для OOM-killer весит как процесс на 12 с лишним ГБ. Поэтому он стоит в очереди рядом с базой, хотя сам почти ничего не занимает.
С nginx интереснее. Ядро убивает один его процесс, остальное доделывает systemd:
# journalctl -u nginx -S '2026-10-15 12:47' -U '2026-10-15 14:00' Oct 15 12:48:04 srv systemd[1]: nginx.service: A process of this unit has been killed by the OOM killer. Oct 15 12:48:09 srv systemd[1]: nginx.service: Stopping timed out. Terminating. Oct 15 12:48:14 srv systemd[1]: nginx.service: State 'stop-sigterm' timed out. Killing. Oct 15 12:48:14 srv systemd[1]: nginx.service: Killing process 1661457 (nginx) with signal SIGKILL. Oct 15 12:48:19 srv systemd[1]: nginx.service: Processes still around after SIGKILL. Ignoring. Oct 15 12:48:24 srv systemd[1]: nginx.service: Main process exited, code=killed, status=9/KILL Oct 15 12:48:24 srv systemd[1]: nginx.service: Failed with result 'oom-kill'. Oct 15 12:48:24 srv systemd[1]: nginx.service: Consumed 1h 20min 47.726s CPU time, 33.1G memory peak, 883.5M memory swap peak.
У службы OOMPolicy=stop, это значение по умолчанию: после убийства любого её процесса systemd останавливает службу целиком. А Restart=no, и сайты лежат, пока nginx не запустят руками.
Это не боты
Сначала я подумал самое простое: боты стали злее. proxy-audit.log здесь общий журнал запросов nginx по всем сайтам (адрес, X-Forwarded-For, хост, запрос, статус), ротируется раз в сутки. Я посчитал в нём запросы к архивам по дням, а в логе ошибок nginx таймауты до PHP:
# zgrep -cE '\.(zip|rar|7z|tar|gz|tgz|bz2) HTTP' /var/log/nginx/proxy-audit.log.[2-9].gz /var/log/nginx/proxy-audit.log.1 /var/log/nginx/proxy-audit.log # zgrep -c 'upstream timed out' /var/log/nginx/error.log.[2-9].gz
Дней назад | Запросов к архивам | upstream timed out |
|---|---|---|
9 | 12 235 | 0 |
8 | 6 669 | 0 |
7 | 16 573 | 0 |
6 | 8 538 | 0 |
5 | 8 941 | 0 |
4 | 415 | 0 |
3, первое падение | 6 140 | 14 |
2, первое обращение | 6 442 | 8 325 |
1 | 8 850 | не считал |
сегодня, до вечера | 14 892 | не считал |
За четыре дня до первого падения было 16 573 запроса к архивам, больше, чем в любой из дней с падениями, и ни одного таймаута. Таймауты появились в день первого падения.
И проверка на пальцах. Чистый 404 через FPM, как покажет замер ниже, занимает 0,29 секунды. При 34 запросах в секунду по закону Литтла на это хватило бы десятка воркеров (34 × 0,29 ≈ 10). А в первый раз заняты были все 200, то есть почти 6 секунд на запрос (200 / 34 ≈ 5,9). Медленными запросы делала не их сложность, а состояние сервера: память кончилась, своп забит, база то и дело перезапускается. Версия про ботов оказалась неверной: снаружи ничего не изменилось, изменилось что-то внутри.
Воркер на 600 МБ с первого запроса
# ps -o pid,etimes,rss,time -C php-fpm8.4 --sort=rss | head -8 PID ELAPSED RSS TIME 3532685 1681700 11728 00:08:54 2655701 5 594932 00:00:00 2655702 5 603992 00:00:00 2655700 6 605512 00:00:00 2551941 305 648276 00:00:10 2556065 291 698096 00:00:10 2481721 519 704116 00:00:18
Первая строка мастер, 11 МБ. Дальше воркеры: живут по 5 секунд, процессорного времени меньше секунды, а весят под 600 МБ. Те, что постарше, 630-690 МБ.
Может, тяжёлый сам запрос? Я прогнал один запрос к несуществующему .zip, ровно как сканер, напрямую в сокет FPM через 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 # cat /tmp/wp404-probe.out { "time_s": 0.29, "peak_mb_real": 36, "peak_mb": 34, "queries": 133, "is_404": true, "included_files": 1464, ... }
wp404-probe.php целиком
<?php $host = getenv('PROBE_HOST') ?: 'example.com'; $uri = getenv('PROBE_URI') ?: '/probe-20261015-xq7.zip'; $root = "/var/www/$host"; foreach (['HTTP_HOST' => $host, 'SERVER_NAME' => $host, 'REQUEST_URI' => $uri, 'REQUEST_METHOD' => 'GET', 'HTTPS' => 'on', 'SERVER_PORT' => '443', 'SCRIPT_NAME' => '/index.php', 'PHP_SELF' => '/index.php', 'SCRIPT_FILENAME' => "$root/index.php", 'DOCUMENT_ROOT' => $root, 'QUERY_STRING' => '', 'HTTP_USER_AGENT' => 'wp404-probe', 'REMOTE_ADDR' => '127.0.0.1'] as $k => $v) { $_SERVER[$k] = $v; } unset($_SERVER['PATH_INFO'], $_SERVER['ORIG_PATH_INFO']); $t0 = microtime(true); register_shutdown_function(function () use ($t0, $host, $uri) { global $wpdb; $st = function_exists('opcache_get_status') ? opcache_get_status(false) : null; $err = error_get_last(); $out = [ 'host' => $host, 'uri' => $uri, 'time_s' => round(microtime(true) - $t0, 2), 'peak_mb_real' => round(memory_get_peak_usage(true) / 1048576, 1), 'peak_mb' => round(memory_get_peak_usage(false) / 1048576, 1), 'queries' => isset($wpdb) ? $wpdb->num_queries : null, 'is_404' => function_exists('is_404') ? is_404() : null, 'included_files' => count(get_included_files()), 'last_error' => $err ? substr($err['message'], 0, 200) . ' @' . $err['file'] . ':' . $err['line'] : null, 'opcache' => $st ? [ 'used_mb' => round($st['memory_usage']['used_memory'] / 1048576), 'free_mb' => round($st['memory_usage']['free_memory'] / 1048576), 'wasted_mb' => round($st['memory_usage']['wasted_memory'] / 1048576), 'wasted_pct' => round($st['memory_usage']['current_wasted_percentage'], 1), 'interned_used_mb' => round($st['interned_strings_usage']['used_memory'] / 1048576), 'interned_free_mb' => round($st['interned_strings_usage']['free_memory'] / 1048576), 'cache_full' => $st['cache_full'], 'restart_pending' => $st['restart_pending'], 'scripts' => $st['opcache_statistics']['num_cached_scripts'], 'keys' => $st['opcache_statistics']['num_cached_keys'], 'max_keys' => $st['opcache_statistics']['max_cached_keys'], 'hits' => $st['opcache_statistics']['hits'], 'misses' => $st['opcache_statistics']['misses'], 'blacklist_misses' => $st['opcache_statistics']['blacklist_misses'], 'hit_rate' => round($st['opcache_statistics']['opcache_hit_rate'], 2), 'oom_restarts' => $st['opcache_statistics']['oom_restarts'], 'hash_restarts' => $st['opcache_statistics']['hash_restarts'], 'manual_restarts' => $st['opcache_statistics']['manual_restarts'], 'start_time' => date('c', $st['opcache_statistics']['start_time']), 'last_restart' => date('c', $st['opcache_statistics']['last_restart_time']), ] : null, ]; file_put_contents('/tmp/wp404-probe.out', json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND); }); ob_start(); chdir($root); require "$root/index.php";
Скрипт лежал в /tmp только на время замера, потом я его удалил.
0,29 секунды и 36 МБ в пике. PHP о своих 600 МБ не знает, memory_get_peak_usage() их не видит. Значит, память живёт где-то ниже.
Где 500 МБ
# grep -E '^(VmRSS|RssAnon|RssFile|RssShmem)' /proc/2481721/status VmRSS: 648724 kB RssAnon: 533656 kB RssFile: 30196 kB RssShmem: 84872 kB
RssShmem это общая память OPcache, её делят все воркеры. А 521 МБ RssAnon у каждого свои. Я посмотрел в /proc/2481721/smaps, какие области у воркера крупнее 10 МБ. Их всего две: /dev/zero (deleted) на 84 868 КБ, это та самая общая память OPcache, и безымянная 7f6cdd686000-7f6cfc6a7000 на 508 032 КБ. Её запись в smaps, без нулевых и служебных полей:
7f6cdd686000-7f6cfc6a7000 rw-p 00000000 00:00 0 Size: 508036 kB Rss: 508032 kB Private_Dirty: 508032 kB Anonymous: 508032 kB AnonHugePages: 505856 kB Swap: 0 kB SwapPss: 0 kB THPeligible: 1 VmFlags: rd wr mr mw me ac sd
Анонимный кусок на 496 МБ, заполненный целиком: Rss равен Size. Сколько процессов держат область с этим адресом:
# grep -l '7f6cdd686000-7f6cfc6a7000' /proc/[0-9]*/maps | wc -l 30 # ps -C php-fpm8.4 --no-headers | wc -l 31
Все 30 воркеров, а 31-й процесс это мастер. У мастера RssAnon 5 МБ:
# grep -E '^(VmRSS|RssAnon|VmData|VmSize)' /proc/3532685/status VmSize: 1261320 kB VmRSS: 11728 kB RssAnon: 5344 kB VmData: 12440 kB
Выходит, воркер выделяет этот кусок уже после fork, на первом же запросе, и сразу заполняет. Это не утечка, которая копится за жизнь процесса: воркер платит налог при рождении.
Баг php-src #23859
Я поискал по симптому и нашёл issue в php-src: #23859, “Repeated opcache_reset() under concurrency permanently grows ZCSG(map_ptr_last), inflating every newly spawned php-fpm worker”. Открыто за три недели до моего разбора, статус Needs Triage, патча на момент, когда я это пишу, нет. У автора воркеры весили около 960 МБ с рождения, весь FPM 52 ГБ, а после graceful reload 2,4 ГБ. Рост он воспроизвёл на 8.1, 8.3, 8.4 и 8.5, в том числе на 8.4 из Debian 13, а путь в коде честно называет гипотезой. Механизм со ссылками на строки расписал первый комментатор. Я сверил его с исходником 8.4.26, который стоит на этом сервере. По шагам:
У каждого процесса PHP есть таблица
map_ptr: по слоту на каждую функцию, метод и класс из кеша OPcache. Процесс, который кладёт скрипт в кеш, берёт новые слоты из своего счётчикаCG(map_ptr_last)и записывает его в общий счётчикZCSG(map_ptr_last)(zend_persist.c:1457).Новый воркер, загружая первый скрипт из кеша, растягивает свою таблицу до общего счётчика (zend_accelerator_util_funcs.c:388). В начале каждого запроса таблица обнуляется целиком (zend.c:1321), поэтому она вся сразу в памяти.
Перезапуск кеша обнуляет общий счётчик, но руками того процесса, который его выполнил (ZendAccelerator.c:2714). Остальные воркеры замечают, что кеш перезапущен, и сбрасывают только кеш путей и регулярных выражений (2748-2756). Свой счётчик они оставляют старым.
Первый же из них, кто кладёт скрипт в кеш, выделяет слоты поверх старого значения и записывает его в общий счётчик. Счётчик не уменьшается и с каждым перезапуском под нагрузкой вырастает ещё на поколение. Так до перезапуска самого FPM.
Перезапуском здесь считается любой: opcache_reset(), нехватка памяти в кеше, переполнение хеш-таблицы. Все три идут одним блоком кода (2695-2730). Пока перезапуск ждёт, когда все процессы отпустят кеш, OPcache выключен для всех (3473-3476), а новые сбросы в это время не ставятся, в исходнике так и написано: “don’t schedule twice” (3459). Поэтому вызовов opcache_reset() бывает заметно больше, чем перезапусков в статистике.
У бага характерный отпечаток. Таблица растёт шагами по 4096 указателей, то есть по 32 КБ (zend.c:2063), и 508 036 КБ это ровно 15 876 шагов по 32 КБ и ещё 4 КБ. Если у вас безымянный кусок такого размера одинаков у всех воркеров и отсутствует у мастера, это почти наверняка он.
У автора в синтетическом тесте 1 800 сбросов дают около 200 МБ на воркер. Здесь хватило двух сотен перезапусков. По коду выходит, что поколение тем длиннее, чем больше скриптов успевает заново лечь в кеш до следующего перезапуска, а в кеше этого сервера 45 тысяч скриптов. Это мой вывод из кода, замером я его не проверял.
И ещё одна цена. Раз таблица обнуляется в начале каждого запроса, каждый запрос делает memset на 496 МБ. По моей оценке это десятки миллисекунд процессора на запрос, замером я это не проверял.
Кто сбрасывает кеш
Статистику OPcache надо брать из самого FPM. Через php -r в консоли бесполезно: у CLI свой OPcache, и обычно он выключен. Её пишет тот же зонд:
"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, "start_time": "2026-10-09T07:39:32+00:00", "last_restart": "2026-10-13T12:15:07+00:00" }
Кеш забит целиком, две трети обращений мимо, и такие скрипты компилируются заново на каждом запросе. manual_restarts: 205 это перезапуски по opcache_reset(), oom_restarts: 2 ещё два из-за нехватки памяти в самом кеше, они идут тем же путём. Всё это считается со start_time, то есть с последнего перезапуска FPM.
Кто зовёт opcache_reset(), я искал обычным grep по одному сайту:
# 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();
Я прошёл так по одной копии каждого плагина, который есть на сервере, по всем трём темам и по mu-plugins. Кроме Yoast нашёлся только Google Site Kit: он сбрасывает кеш после любого обновления, через хук upgrader_process_complete, но стоит на одном сайте из 5 500. Забавно, что рядом с вызовом у них комментарий: “opcache_reset is prohibited on the WordPress VIP platform due to memory corruption”.
Код Yoast (wp-seo-main.php, строки 329-346), сокращённо:
if ( version_compare( WPSEO_Options::get( 'version', 1, [ 'wpseo' ] ), WPSEO_VERSION, '<' ) ) { // Invalidate the opcache in 50% of the cases, randomly staggered based on the site URL. $random_seed = hexdec( substr( hash( 'sha256', site_url() ), 0, 8 ) ); $should_invalidate_opcache = ( $random_seed % 2 ) !== 0; $should_invalidate_opcache = (bool) apply_filters( 'Yoast\WP\SEO\should_invalidate_opcache', $should_invalidate_opcache ); if ( $should_invalidate_opcache && function_exists( 'opcache_reset' ) ) { @opcache_reset(); } new WPSEO_Upgrade();
После обновления первый же запуск WordPress видит, что сохранённая версия старше кода, и на половине сайтов сбрасывает OPcache. Половина выбирается по хешу адреса сайта, то есть каждый раз одна и та же. Для одного сайта на своём сервере это разумно: не жить со смесью старых и новых файлов в кеше. Обратите внимание на слова “первый запуск WordPress”, а не “первый запрос”. Дальше это главное.
Почему раньше не падало
# journalctl -u php8.4-fpm --since 2026-10-01 | grep Reloaded Oct 01 05:23:08 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 01 05:25:17 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 02 14:12:44 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 06 07:01:34 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 08 05:44:48 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 09 07:12:06 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI [...] Oct 09 07:39:31 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI Oct 15 18:14:41 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGI
Раньше PHP-FPM перезагружали раз в один-четыре дня, и каждый reload обнулял таблицу: общая память OPcache создаётся заново, счётчик начинается с нуля, и раздутие никто даже не видит. Последний reload перед падениями был за три дня до первого. Последняя строка в выводе это уже мой, о нём ниже.
Что сбрасывало кеш в эти три дня, по журналам видно не сразу: сбросы OPcache поимённо нигде не пишутся. Картину пришлось собирать по кускам из логов обновлений, журнала sudo и отчётов OOM-killer.
Сайты здесь обновляет скрипт по cron: раз в пять минут проверяет, вышли ли новые версии, и ставит их на все сайты. Его лог за прошлое обновление Yoast, за две недели до падений, и за следующее, на другой день после первого падения:
# grep -E 'wordpress-seo|Updated|FAILED' 2026-09-29_12-40-01/main.log 2026-10-13_11-30-01/main.log 2026-09-29_12-40-01/main.log:[2026-09-29 12:40:03] wordpress-seo: 28.5 -> 28.6 2026-09-29_12-40-01/main.log:[2026-09-29 12:40:03] [DOWNLOAD] wordpress-seo 28.6 2026-09-29_12-40-01/main.log:[2026-09-29 12:40:04] [OK] wordpress-seo 28.6 2026-09-29_12-40-01/main.log:[2026-09-29 13:22:31] Updated: 5058 2026-09-29_12-40-01/main.log:[2026-09-29 13:22:31] FAILED: 469 2026-10-13_11-30-01/main.log:[2026-10-13 11:30:03] wordpress-seo: 28.6 -> 28.7 2026-10-13_11-30-01/main.log:[2026-10-13 11:30:03] [DOWNLOAD] wordpress-seo 28.7 2026-10-13_11-30-01/main.log:[2026-10-13 11:30:04] [OK] wordpress-seo 28.7 2026-10-13_11-30-01/main.log:[2026-10-13 12:16:41] Updated: 5478 2026-10-13_11-30-01/main.log:[2026-10-13 12:16:41] FAILED: 7
На 469 сайтах обновление до 28.6 не прошло, у 467 из них в логе сайта [PLUGIN:wordpress-seo] ERROR: update failed. Повторно cron их не трогал: следующий прогон с Yoast в логах только через две недели, уже на 28.7.
Отставшие сайты догнал ручной прогон утром в день первого падения: по всем сайтам wp plugin update --all и пара правок в wp-config.php. Порядок команд WP-CLI на одном из отставших сайтов, из журнала sudo:
# journalctl _COMM=sudo -S '2026-10-12 06:25:30' -U '2026-10-12 06:26' | grep 'PWD=/var/www/<сайт> ' | grep -o 'COMMAND=/usr/local/bin/wp.*' COMMAND=/usr/local/bin/wp option update tfa_administrator 1 COMMAND=/usr/local/bin/wp config set WP_DEBUG false --raw COMMAND=/usr/local/bin/wp config set WP_DEBUG_LOG false COMMAND=/usr/local/bin/wp plugin update --all
wp plugin update --all стоит последним, после него WP-CLI сайт уже не загружает. Значит, код обновления Yoast вместе с opcache_reset() выполнил первый посетитель сайта, то есть PHP-FPM, под живым трафиком. Что отставшие сайты получили 28.6 именно тогда, видно по логам сайтов из следующего прогона:
# grep -l '28.5 -> 28.7' 2026-10-13_11-30-01/sites/*.log | wc -l 2 # grep -l '28.6 -> 28.7' 2026-10-13_11-30-01/sites/*.log | wc -l 5483
Других обновлений Yoast между этими прогонами в журнале sudo нет, я проверил выборку из десяти отставших сайтов. Получается, около 467 сайтов обновились в то утро, и примерно половина из них, по коду Yoast, сбросила OPcache. Сбросы растянулись на пару часов, по мере того как прогон шёл по сайтам.
Насколько к первому падению выросла таблица, я посчитал задним числом. В отчёте OOM-killer есть total_vm каждого процесса в страницах по 4 КБ. Свежий воркер, который ещё не взял ни одного запроса, по памяти совпадает с мастером, а воркер после первого запроса тяжелее на таблицу и немного кучи:
# journalctl -k -S '2026-10-12 14:11:15' -U '2026-10-12 14:11:22' | grep -E 'tgid|php-fpm8.4$' | sort -k9 -n | head -9 Oct 12 14:11:21 srv kernel: [ pid ] uid tgid total_vm rss rss_anon rss_file rss_shmem pgtables_bytes swapents oom_score_adj name Oct 12 14:11:21 srv kernel: [3522917] 33 3522917 315330 3443 2552 763 128 258048 179 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522923] 33 3522923 315330 3474 2656 818 0 245760 243 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522941] 33 3522941 315330 3863 2656 1076 131 262144 243 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522944] 33 3522944 315330 3604 2656 948 0 245760 243 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3532685] 0 3532685 315330 4738 2819 767 1152 290816 64 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522852] 33 3522852 436387 114225 112350 849 1026 1155072 244 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522859] 33 3522859 436387 125260 123359 875 1026 1241088 243 0 php-fpm8.4 Oct 12 14:11:21 srv kernel: [3522866] 33 3522866 436387 126088 123551 934 1603 1245184 243 0 php-fpm8.4
Мастер (uid 0) и четыре свежих воркера по 315 330 страниц, воркеры после запроса от 436 387. Разница 121 057 страниц, около 473 МБ: таблица была почти полного размера уже при первом падении. По следующим отчётам так же выходит утром другого дня около 476 МБ, а вечером, после массового обновления Yoast 28.7 по 5 478 сайтам, около 496 МБ. Столько потом и показал smaps.
Массовое обновление на 5 478 сайтах добавило к таблице меньше, чем догоняющее на 467. Почему, тоже видно по журналу sudo: cron после установки сразу активирует плагин.
# journalctl _COMM=sudo -S '2026-10-13 11:57:00' -U '2026-10-13 11:57:12' | grep '<сайт>' | grep -o 'COMMAND=.*' COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> core is-installed COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-installed wordpress-seo COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin get wordpress-seo --field=version COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-active wordpress-seo COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-active wordpress-seo --network COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin install wordpress-seo --version=28.7 --force COMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin activate wordpress-seo
wp plugin activate загружает WordPress уже с новым кодом Yoast, и обновление вместе со сбросом выполняется в CLI. А в CLI OPcache выключен (opcache.enable_cli по умолчанию 0), до FPM такой сброс не доходит. В FPM он попадает, только если на сайт пришёл запрос в те несколько секунд между установкой и активацией. У ручного прогона такого шага не было.
Одно и то же обновление одного плагина оказалось безвредным или опасным в зависимости от того, какая команда идёт следом.
Yoast тут не злодей. У них даже есть фильтр, чтобы отключить сброс, а ошибку при запрещённом API они заранее глушат через @. Проблема в PHP: сброс кеша не должен навсегда раздувать каждый будущий процесс.
Как это легло на сервер
В тот же день по сайтам прошли обновление темы и обновление Polylang, это новые версии файлов, их тоже надо компилировать, а кеш забит. Воркеров нужно больше, и каждый новый весит полгигабайта. В момент первого падения в памяти было 144 воркера примерно по 500 МБ своей памяти и база на 29 ГБ, вместе около 100 ГБ. В том же отчёте ядро насчитало 99 ГБ анонимной памяти (active_anon плюс inactive_anon), сходится.
В 200 воркеров пул упирался и раньше, 39 раз с конца июня, и ничего не падало: двести обычных воркеров в память помещались. Двести раздутых это около 100 ГБ на сервере со 125, где ещё база с буфером на 32 ГБ.
Лечение
Сначала я внёс обе правки в конфиги, потом сделал один reload. restrict_api без перезапуска FPM не применится, а process_control_timeout мастер читает при старте. Так перезапуск один, и рвётся только то, что рвётся при нём.
1. Запретить сброс OPcache из веб-запросов
Файл /etc/php/8.4/fpm/conf.d/99-opcache-restrict-api.ini:
; forbid opcache_reset()/opcache_invalidate() from web requests. ; Every opcache_reset() under live traffic permanently inflates every new ; php-fpm worker (php-src issue #23859); updated files are still picked up ; via opcache.validate_timestamps=1 / revalidate_freq=0. opcache.restrict_api = /nonexistent
opcache.restrict_api разрешает функции API OPcache только скриптам, чей путь начинается с указанной строки. Несуществующий путь значит “никому”. Yoast ошибку глушит, ядро WordPress в wp_opcache_invalidate() само проверяет restrict_api и ничего не делает. Обновлённые файлы OPcache всё равно подхватит: при validate_timestamps=1 и revalidate_freq=0 он проверяет время изменения файла при каждом обращении.
У запрета две оговорки. Первая: он закрывает для веб-скриптов ещё и opcache_get_status(), opcache_get_configuration() и opcache_is_script_cached() (проверка validate_api_restriction() в zend_accelerator_module.c). Страница статуса OPcache и мой зонд статистику больше не получат. Если мониторинг нужен, restrict_api можно задать префиксом служебного каталога, например /usr/local/lib/opcache-status/, и держать скрипт статуса там. Вторая: запрет не закрывает перезапуски из-за нехватки памяти и переполнения хеша, а они раздувают таблицу так же. Поэтому reload после массовых обновлений нужен всё равно.
Если запрещать API целиком не хочется, у Yoast есть точечный вариант, mu-plugin в одну строку:
add_filter( 'Yoast\WP\SEO\should_invalidate_opcache', '__return_false' );
Этот вариант я на сервере не ставил, привожу по коду фильтра. Да и Yoast не единственный, кто умеет звать opcache_reset(), а запрет в PHP закрывает всех сразу.
2. Чтобы reload не обрывал запросы
# sed -i 's/^;process_control_timeout = 0$/process_control_timeout = 10s/' /etc/php/8.4/fpm/php-fpm.conf # php-fpm8.4 -t [15-Oct-2026 18:14:14] NOTICE: configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful
На reload мастер шлёт воркерам SIGQUIT: доработай запрос и выходи. Через process_control_timeout он шлёт SIGTERM, ещё через секунду SIGKILL, а потом запускает себя заново (fpm_process_ctl.c). При 0 SIGTERM уходит сразу, и запросы, которые идут в эту секунду, обрываются. С 10 секундами успевают закончить все, кто короче. Тонкость: эту настройку мастер читает при старте, поэтому мой reload шёл ещё со старым значением и один запрос всё-таки оборвал. Следующие reload, ожидаю, будут мягкими, но проверить пока не на чем: других reload с тех пор не было.
3. Reload
# systemctl reload php8.4-fpm
В юните Debian reload это kill -USR2 мастеру. В логе FPM это выглядит так:
[15-Oct-2026 18:14:41] NOTICE: Reloading in progress ... [15-Oct-2026 18:14:41] NOTICE: reloading: execvp("/usr/sbin/php-fpm8.4", {"/usr/sbin/php-fpm8.4", "--nodaemonize", "--fpm-config", "/etc/php/8.4/fpm/php-fpm.conf"}) [15-Oct-2026 18:14:41] NOTICE: using inherited socket fd=8, "/run/php/php8.4-fpm.sock" [15-Oct-2026 18:14:41] NOTICE: fpm is running, pid 3532685 [15-Oct-2026 18:14:41] NOTICE: ready to handle connections
Мастер заменяет себя через execvp, PID остаётся прежним, сокет он передаёт сам себе, а общая память OPcache создаётся заново. На следующий день тот же процесс:
# ps -o pid,etimes,cmd -p 3532685 PID ELAPSED CMD 3532685 1756541 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf)
ELAPSED двадцать суток, reload его не обнуляет. Через полминуты после reload я посмотрел на новый воркер:
VmRSS: 171236 kB RssAnon: 21220 kB RssShmem: 129092 kB
21 МБ своей памяти вместо 521.
4. nginx должен подниматься сам
# systemctl show nginx -p Restart -p RestartUSec -p OOMPolicy -p DropInPaths -p ActiveState -p NRestarts Restart=no RestartUSec=100ms NRestarts=0 OOMPolicy=stop ActiveState=active DropInPaths=/etc/systemd/system/nginx.service.d/limits.conf
Я добавил drop-in:
# cat /etc/systemd/system/nginx.service.d/restart.conf [Service] Restart=on-failure RestartSec=5s # systemctl daemon-reload # systemctl show nginx -p Restart -p RestartUSec Restart=on-failure RestartUSec=5s
Убийство по OOM systemd считает сбоем, в журнале выше это Failed with result 'oom-kill'. По документации после такого результата применяется Restart=, а по коду systemd 257, который стоит на сервере, on-failure срабатывает при любом результате, кроме успешного (service.c). У MariaDB этот путь виден в журнале:
# journalctl -u mariadb -S '2026-10-15 16:27:45' -U '2026-10-15 16:29:50' | head -6 Oct 15 16:27:48 srv systemd[1]: mariadb.service: A process of this unit has been killed by the OOM killer. Oct 15 16:27:49 srv systemd[1]: mariadb.service: Main process exited, code=killed, status=9/KILL Oct 15 16:27:49 srv systemd[1]: mariadb.service: Failed with result 'oom-kill'. Oct 15 16:27:49 srv systemd[1]: mariadb.service: Consumed 4min 59.519s CPU time, 17.4G memory peak, 24K memory swap peak. Oct 15 16:27:54 srv systemd[1]: mariadb.service: Scheduled restart job, restart counter is at 36. Oct 15 16:27:54 srv systemd[1]: Starting mariadb.service - MariaDB 11.8.6 database server...
На nginx живьём это пока не проверено: с момента правки его ни разу не убивали. Лечением это не назовёшь, это страховка.
На следующий день я убрал и вторую половину проблемы. При OOMPolicy=stop убийство одного воркера всё равно останавливает службу целиком, в журнале nginx выше на это ушло 20 секунд, плюс пять до подъёма. Поэтому в тот же restart.conf добавилась строка OOMPolicy=continue, а для PHP-FPM отдельный drop-in:
# cat /etc/systemd/system/php8.4-fpm.service.d/oom-policy.conf [Service] OOMPolicy=continue # systemctl daemon-reload # systemctl show nginx php8.4-fpm -p Id -p OOMPolicy -p MainPID MainPID=1323957 OOMPolicy=continue Id=nginx.service MainPID=3532685 OOMPolicy=continue Id=php8.4-fpm.service
Теперь убийство воркера только записывается в журнал, служба работает дальше, а нового воркера запускает её мастер. Службы для этого перезапускать не нужно, MainPID прежние.
На следующий день
# journalctl -k --since '2026-10-15 18:15' | grep -c 'Killed process' 0 # ps -o pid,etimes,rss,cmd -C php-fpm8.4 --sort=etimes | head -5 PID ELAPSED RSS CMD 1023872 8 82848 php-fpm: pool www 1019135 32 97032 php-fpm: pool www 1019134 32 91956 php-fpm: pool www 1019125 36 67316 php-fpm: pool www # free -h total used free shared buff/cache available Mem: 125Gi 33Gi 1.4Gi 966Mi 92Gi 91Gi Swap: 1.0Gi 892Mi 131Mi
После reload ни одного OOM. Новорождённые воркеры весят 66-95 МБ, и это вместе с общей памятью OPcache. А так теперь выглядят самые крупные куски памяти у воркера:
# pmap -x 1019135 | sort -k3 -n | tail -4 00007f7ab9a00000 6144 6144 6144 rw--- [ anon ] 00007f7abb400000 6144 6144 6144 rw--- [ anon ] 0000556e8d400000 1048576 33196 33196 rw-s- zero (deleted) total kB 1300164 99408 78472
Самый большой это общая память OPcache на 1 ГБ, из которой воркер реально трогал 32 МБ. Анонимных кусков на сотни мегабайт больше нет.
Тут есть ловушка, на которую я сам чуть не попался. Мой скрипт мониторинга по-прежнему показывал у PHP около 70 ГБ:
2026-10-16 10:21:45 ... php=70679MB php_procs=30 db=29002MB avail=93802MB
Он берёт память из cgroup службы, а cgroup считает ещё и кеш файлов, которые читал PHP:
# grep -E '^(anon|file|shmem) ' /sys/fs/cgroup/system.slice/php8.4-fpm.service/memory.stat anon 1225437184 file 59738320896 shmem 1009795072
Своей памяти у PHP 1,1 ГБ. file это 55,6 ГБ кеша страниц, который ядро отдаст по первому требованию, и в него уже входит 0,9 ГБ общей памяти OPcache: в cgroup v2 shmem считается внутри file, складывать их нельзя. Остальная разница с 70 ГБ, по всей видимости, память ядра, её я отдельно не разбирал.
До и после
До | После | |
|---|---|---|
Своя память свежего воркера ( | 521 МБ | 21 МБ |
RSS новорождённых воркеров | 581-591 МБ | 66-95 МБ |
Самый крупный анонимный кусок | 496 МБ | 6 МБ |
Свежий воркер тяжелее мастера ( | на 473 МБ, при первом падении | на 14 МБ, через 19 часов |
Убийства OOM | 82 за четыре дня | 0 |
| пусто |
|
| 0 | 10s |
nginx | no | on-failure, 5 с |
| stop | continue |
Чего я не трогал
Один пул на 5 500 сайтов и забитый OPcache на 1 ГБ с hit rate 35 % это отдельная работа: размер кеша, max_accelerated_files, деление на пулы. Её не делают ночью на падающем сервере, и к раздутию она не относится, хотя каждый запрос из-за неё дороже.
Transparent Huge Pages в режиме always: из 508 032 КБ куска 505 856 КБ лежат в огромных страницах. К причине это не относится. Таблица обнуляется на каждом запросе, так что и без THP она была бы в памяти целиком.
Потолок памяти для PHP через cgroup. С OOMPolicy=continue его уже можно ставить, не боясь, что первый же убитый воркер остановит весь FPM, но это следующая работа.
Проверьте у себя
Касается любого сервера, где много сайтов делят один мастер PHP-FPM, и не только WordPress: подойдёт любое приложение, которое зовёт opcache_reset() под нагрузкой.
Перезапуски кеша. Из самого FPM, не из консоли: небольшой скрипт в пуле, как зонд выше, с
opcache_get_status(false). Смотритеmanual_restarts,oom_restartsиstart_time. Если включёнrestrict_api, скрипт должен лежать под разрешённым префиксом.Своя память молодых воркеров. Именно
RssAnon, а не RSS: в RSS сидит ещё и общая память OPcache.
ps -o pid,etimes,rss -C php-fpm8.4 --sort=etimes | head grep RssAnon /proc/PID/status
Процесс живёт несколько секунд, а своей памяти у него сотни мегабайт? Смотрите дальше.
Что у такого воркера в памяти:
pmap -x PID | sort -k3 -n | tail
Безымянный [ anon ] на сотни мегабайт, одинаковый у всех воркеров и отсутствующий у мастера, размер кратен 32 КБ плюс 4 КБ. Это тот самый случай.
Если сервер уже падал, размер таблицы можно оценить задним числом по отчёту OOM-killer:
total_vmворкеров, обработавших запрос, минусtotal_vmмастера, в страницах по 4 КБ.Кто зовёт сброс. grep по одной копии каждого плагина, по темам и mu-plugins:
grep -rn opcache_reset wp-content/plugins wp-content/themes wp-content/mu-plugins
Что идёт после обновления плагинов. Если последняя команда WordPress не загружает (
wp plugin updateв конце скрипта, обновление файлов через git или rsync), код обновления плагина выполнит первый посетитель, то есть FPM. Если после установки идётwp plugin activateили любая команда, которая загружает сайт, сброс останется в CLI.Как systemd поступит с убитым процессом:
systemctl show nginx php8.4-fpm -p Restart -p OOMPolicy
При OOMPolicy=stop, это умолчание, убийство любого процесса службы останавливает её целиком: в журнале nginx выше от убийства одного процесса до остановки службы прошло 20 секунд. С OOMPolicy=continue убийство только записывается в журнал, служба работает дальше, а нового воркера запускает её мастер. Для nginx и PHP-FPM разумно continue вместе с Restart=on-failure на случай, если убьют сам мастер.
Если нашли:
systemctl reloadдля PHP-FPM сразу, потомopcache.restrict_apiили фильтр в плагине,process_control_timeout, и пока баг в PHP не закрыт, reload после каждого массового обновления плагинов. Это одна строка в конце скрипта обновления.
Итог
Если процесс PHP тяжёлый с первой секунды жизни, ищите не утечку в коде, а то, что он получает при рождении. А когда клиент говорит “DDoS”, сначала проверьте, переварил бы сервер тот же трафик неделю назад.
Ссылки
php-src, issue #23859: https://github.com/php/php-src/issues/23859
Механизм по строкам, комментарий в issue: https://github.com/php/php-src/issues/23859#issuecomment-5793953010
OPcache 8.4.26, перезапуск кеша и “don’t schedule twice”: https://github.com/php/php-src/blob/php-8.4.26/ext/opcache/ZendAccelerator.c
OPcache 8.4.26, публикация счётчика при сохранении скрипта: https://github.com/php/php-src/blob/php-8.4.26/ext/opcache/zend_persist.c#L1457
Zend 8.4.26, memset таблицы в начале запроса: https://github.com/php/php-src/blob/php-8.4.26/Zend/zend.c#L1321-L1322
PHP-FPM 8.4.26, сигналы при reload: https://github.com/php/php-src/blob/php-8.4.26/sapi/fpm/fpm/fpm_process_ctl.c#L179-L200
Yoast SEO 28.7,
wp-seo-main.php: https://plugins.svn.wordpress.org/wordpress-seo/tags/28.7/wp-seo-main.phpopcache.restrict_api: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.restrict-apiprocess_control_timeout: https://www.php.net/manual/en/install.fpm.configuration.phpWordPress,
wp_opcache_invalidate(): https://developer.wordpress.org/reference/functions/wp_opcache_invalidate/systemd,
OOMPolicy=иRestart=: https://man7.org/linux/man-pages/man5/systemd.service.5.htmlsystemd 257, когда
Restart=on-failureперезапускает службу: https://github.com/systemd/systemd/blob/v257/src/core/service.c#L2030-L2031
