Клиент написал: сайты лежат, похоже на 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 = 200pm.start_servers = 20pm.min_spare_servers = 10pm.max_spare_servers = 30pm.max_requests = 500
И один OPcache на 1 ГБ:
opcache.enable=1opcache.memory_consumption=1024opcache.max_accelerated_files=200000opcache.validate_timestamps=1opcache.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 OOMScoreAdjustOOMScoreAdjust=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/KILLOct 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 TIME3532685 1681700 11728 00:08:542655701 5 594932 00:00:002655702 5 603992 00:00:002655700 6 605512 00:00:002551941 305 648276 00:00:102556065 291 698096 00:00:102481721 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/statusVmRSS: 648724 kBRssAnon: 533656 kBRssFile: 30196 kBRssShmem: 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 0Size: 508036 kBRss: 508032 kBPrivate_Dirty: 508032 kBAnonymous: 508032 kBAnonHugePages: 505856 kBSwap: 0 kBSwapPss: 0 kBTHPeligible: 1VmFlags: rd wr mr mw me ac sd
Анонимный кусок на 496 МБ, заполненный целиком: Rss равен Size. Сколько процессов держат область с этим адресом:
# grep -l '7f6cdd686000-7f6cfc6a7000' /proc/[0-9]*/maps | wc -l30# ps -C php-fpm8.4 --no-headers | wc -l31
Все 30 воркеров, а 31-й процесс это мастер. У мастера RssAnon 5 МБ:
# grep -E '^(VmRSS|RssAnon|VmData|VmSize)' /proc/3532685/statusVmSize: 1261320 kBVmRSS: 11728 kBRssAnon: 5344 kBVmData: 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/themeswp-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 ReloadedOct 01 05:23:08 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGIOct 01 05:25:17 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGIOct 02 14:12:44 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGIOct 06 07:01:34 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGIOct 08 05:44:48 srv systemd[1]: Reloaded php8.4-fpm.service - The PHP 8.4 FastCGIOct 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 FastCGIOct 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.log2026-09-29_12-40-01/main.log:[2026-09-29 12:40:03] wordpress-seo: 28.5 -> 28.62026-09-29_12-40-01/main.log:[2026-09-29 12:40:03] [DOWNLOAD] wordpress-seo 28.62026-09-29_12-40-01/main.log:[2026-09-29 12:40:04] [OK] wordpress-seo 28.62026-09-29_12-40-01/main.log:[2026-09-29 13:22:31] Updated: 50582026-09-29_12-40-01/main.log:[2026-09-29 13:22:31] FAILED: 4692026-10-13_11-30-01/main.log:[2026-10-13 11:30:03] wordpress-seo: 28.6 -> 28.72026-10-13_11-30-01/main.log:[2026-10-13 11:30:03] [DOWNLOAD] wordpress-seo 28.72026-10-13_11-30-01/main.log:[2026-10-13 11:30:04] [OK] wordpress-seo 28.72026-10-13_11-30-01/main.log:[2026-10-13 12:16:41] Updated: 54782026-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 1COMMAND=/usr/local/bin/wp config set WP_DEBUG false --rawCOMMAND=/usr/local/bin/wp config set WP_DEBUG_LOG falseCOMMAND=/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 -l2# grep -l '28.6 -> 28.7' 2026-10-13_11-30-01/sites/*.log | wc -l5483
Других обновлений 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 -9Oct 12 14:11:21 srv kernel: [ pid ] uid tgid total_vm rss rss_anon rss_file rss_shmem pgtables_bytes swapents oom_score_adj nameOct 12 14:11:21 srv kernel: [3522917] 33 3522917 315330 3443 2552 763 128 258048 179 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3522923] 33 3522923 315330 3474 2656 818 0 245760 243 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3522941] 33 3522941 315330 3863 2656 1076 131 262144 243 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3522944] 33 3522944 315330 3604 2656 948 0 245760 243 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3532685] 0 3532685 315330 4738 2819 767 1152 290816 64 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3522852] 33 3522852 436387 114225 112350 849 1026 1155072 244 0 php-fpm8.4Oct 12 14:11:21 srv kernel: [3522859] 33 3522859 436387 125260 123359 875 1026 1241088 243 0 php-fpm8.4Oct 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-installedCOMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-installed wordpress-seoCOMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin get wordpress-seo --field=versionCOMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-active wordpress-seoCOMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin is-active wordpress-seo --networkCOMMAND=/usr/local/bin/wp --path=/var/www/<сайт> plugin install wordpress-seo --version=28.7 --forceCOMMAND=/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 CMD3532685 1756541 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf)
ELAPSED двадцать суток, reload его не обнуляет. Через полминуты после reload я посмотрел на новый воркер:
VmRSS: 171236 kBRssAnon: 21220 kBRssShmem: 129092 kB
21 МБ своей памяти вместо 521.
4. nginx должен подниматься сам
# systemctl show nginx -p Restart -p RestartUSec -p OOMPolicy -p DropInPaths -p ActiveState -p NRestartsRestart=noRestartUSec=100msNRestarts=0OOMPolicy=stopActiveState=activeDropInPaths=/etc/systemd/system/nginx.service.d/limits.conf
Я добавил drop-in:
# cat /etc/systemd/system/nginx.service.d/restart.conf[Service]Restart=on-failureRestartSec=5s# systemctl daemon-reload# systemctl show nginx -p Restart -p RestartUSecRestart=on-failureRestartUSec=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 -6Oct 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/KILLOct 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 MainPIDMainPID=1323957OOMPolicy=continueId=nginx.serviceMainPID=3532685OOMPolicy=continueId=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 CMD1023872 8 82848 php-fpm: pool www1019135 32 97032 php-fpm: pool www1019134 32 91956 php-fpm: pool www1019125 36 67316 php-fpm: pool www# free -h total used free shared buff/cache availableMem: 125Gi 33Gi 1.4Gi 966Mi 92Gi 91GiSwap: 1.0Gi 892Mi 131Mi
После reload ни одного OOM. Новорождённые воркеры весят 66-95 МБ, и это вместе с общей памятью OPcache. А так теперь выглядят самые крупные куски памяти у воркера:
# pmap -x 1019135 | sort -k3 -n | tail -400007f7ab9a00000 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.statanon 1225437184file 59738320896shmem 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 | headgrep 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.php -
opcache.restrict_api: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.restrict-api -
process_control_timeout: https://www.php.net/manual/en/install.fpm.configuration.php -
WordPress,
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.html -
systemd 257, когда
Restart=on-failureперезапускает службу: https://github.com/systemd/systemd/blob/v257/src/core/service.c#L2030-L2031
ссылка на оригинал статьи https://habr.com/ru/articles/1095438/