PHP-FPM из коробки настроен для средненагруженного сайта на дешёвой VPS: pm = dynamic, pm.max_children = 5, OPcache занимает 128 МБ. Под реальной нагрузкой такие значения дают либо 502 от Nginx («unable to connect to fastcgi backend»), либо OOM-killer, отстреливающий MySQL. Разберём, как считать параметры пула, что включать в OPcache и как добавить preload — без копипасты конфигов из чужих блогов.

Три менеджера процессов: static, dynamic, ondemand
Поведение пула задаёт директива pm в файле пула (обычно /etc/php/8.3/fpm/pool.d/www.conf). Доступно три режима, и выбор между ними — не вопрос вкуса, а вопрос профиля трафика.
static — фиксированное число воркеров запускается сразу и никогда не меняется. Расход памяти предсказуем, накладных расходов на форк нет. Подходит для серверов, где PHP — основной потребитель RAM, а трафик стабилен (обычный продакшн под высокой постоянной нагрузкой).
dynamic — мастер держит «горячий резерв» воркеров и форкает новых при росте очереди. Это компромисс между латентностью и расходом памяти. Дефолт для большинства дистрибутивов и разумный выбор для смешанной нагрузки (Bitrix, WordPress, Laravel).
ondemand — воркеры спавнятся только когда приходит запрос, и убиваются после pm.process_idle_timeout. Экономит память на простаивающих сайтах, но первый запрос после паузы стоит десятки миллисекунд на форк. Годится для dev-стендов и редко посещаемых доменов в shared-hosting сценарии.
# /etc/php/8.3/fpm/pool.d/www.conf — пример static
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = static
pm.max_children = 20
pm.max_requests = 1000
Считаем max_children по памяти, а не на глаз
Главная ошибка: ставить pm.max_children «с запасом» (например, 200 на сервере с 4 ГБ RAM). FPM поднимет столько воркеров, сколько разрешено, и при пике уйдёт в своп — а вместе с ним и весь сервер.
Правильная формула:
pm.max_children = (свободная_RAM_для_PHP) / (средний_размер_воркера)
Чтобы измерить средний воркер, посмотрите RSS-память живых процессов FPM под реальной нагрузкой:
# Средний размер воркера в мегабайтах
ps --no-headers -o rss,cmd -C php-fpm8.3 \
| awk '/pool www/ {sum+=$1; n++} END {if(n) printf "%.1f MB (%d procs)\n", sum/n/1024, n}'
# Пример вывода: 78.4 MB (12 procs)
Дальше прикидываем бюджет. На сервере 8 ГБ: 1.5 ГБ — система и сервисы, 2 ГБ — MySQL/Redis, остаётся 4.5 ГБ под PHP. При среднем воркере 80 МБ получаем pm.max_children ≈ 56. Округляем вниз до 50 для безопасности.
Параметры spare-воркеров для dynamic привязываем к max_children:
pm = dynamic
pm.max_children = 50
pm.start_servers = 12 ; ~25% от max_children
pm.min_spare_servers = 8 ; ~15%
pm.max_spare_servers = 18 ; ~35%
; перезапуск воркера после N запросов — лечит утечки в legacy-коде
pm.max_requests = 500
pm.max_requests особенно важен для WordPress с тяжёлыми плагинами и для Bitrix: воркер копит память между запросами (статика классов, опкоды, расширения вроде imagick), и периодический перезапуск возвращает RAM системе.
Таймауты, slowlog и автоматический рестарт
Эти настройки не влияют на пропускную способность напрямую, но спасают сервер, когда что-то идёт не так: висит внешний API, MySQL долго отвечает, воркер ушёл в бесконечный цикл.
; убить запрос, если он работает дольше 60 секунд (вместо max_execution_time)
request_terminate_timeout = 60s
; логировать стектрейсы запросов, висящих дольше 5 секунд
slowlog = /var/log/php-fpm/www.slow.log
request_slowlog_timeout = 5s
; перезапустить весь пул, если упало больше 10 воркеров за минуту
emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s
Slowlog — самый недооценённый инструмент. В отличие от Xdebug-профилирования, его можно держать включённым на проде: оверхед нулевой, но при медленном запросе вы получите готовый стектрейс с именами функций и номерами строк. Файл смотрят так:
tail -f /var/log/php-fpm/www.slow.log
# Пример записи:
[21-May-2026 14:22:08] [pool www] pid 28394
script_filename = /var/www/site/public_html/bitrix/admin/index.php
[0x00007f1a2c0a3b40] curl_exec() /var/www/site/public_html/bitrix/modules/main/lib/web/httpclient.php:412
OPcache: 80% производительности — здесь
OPcache кеширует байткод PHP в shared memory: при втором запросе интерпретатор не парсит и не компилирует файлы заново. Это самое дешёвое ускорение, какое можно дать PHP-приложению — х2–х5 на сложных фреймворках.
Настройки лежат в /etc/php/8.3/mods-available/opcache.ini:
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256 ; МБ под кеш байткода
opcache.interned_strings_buffer = 16 ; МБ под интернированные строки
opcache.max_accelerated_files = 20000 ; максимум закешированных файлов
opcache.validate_timestamps = 1 ; на проде можно 0 (см. ниже)
opcache.revalidate_freq = 60 ; раз в N секунд проверять mtime
opcache.save_comments = 1 ; нужно для Doctrine/Symfony-атрибутов
opcache.jit = tracing ; PHP 8.0+
opcache.jit_buffer_size = 64M
Несколько неочевидных моментов:
opcache.max_accelerated_filesдолжен быть больше числа PHP-файлов проекта. Считаем черезfind /var/www -name '*.php' | wc -l. У Bitrix легко набегает 15–18 тысяч, у Laravel с вендорами — 8–12 тысяч.opcache.validate_timestamps = 0отключает проверку mtime — кеш не инвалидируется, пока вы не сделаетеopcache_reset()илиkill -USR2мастеру. Это даёт +5–10% к скорости, но требует дисциплины в деплое.opcache.save_comments = 0ломает Symfony, Doctrine, новые PHP-атрибуты и часть DI-контейнеров. Не выключайте без необходимости.opcache.jitна типичном веб-приложении даёт прирост 5–15% — меньше, чем обещают бенчмарки. На вычислительной нагрузке (картинки, парсинг) разница больше.
Проверить занятость кеша можно через мини-скрипт opcache-status.php в защищённой папке:
<?php
// opcache-status.php — закройте basic-auth или ограничьте по IP!
header('Content-Type: application/json');
$s = opcache_get_status(false);
echo json_encode([
'memory_used_mb' => round($s['memory_usage']['used_memory'] / 1048576, 1),
'memory_free_mb' => round($s['memory_usage']['free_memory'] / 1048576, 1),
'cached_scripts' => $s['opcache_statistics']['num_cached_scripts'],
'max_scripts' => $s['opcache_statistics']['max_cached_keys'],
'hit_rate' => round($s['opcache_statistics']['opcache_hit_rate'], 2),
'oom_restarts' => $s['opcache_statistics']['oom_restarts'],
'hash_restarts' => $s['opcache_statistics']['hash_restarts'],
], JSON_PRETTY_PRINT);
Если oom_restarts или hash_restarts больше нуля — вы упёрлись в memory_consumption или max_accelerated_files. Увеличивайте.
OPcache preload: загрузить фреймворк в память один раз
Начиная с PHP 7.4 появился механизм preload: при старте FPM-мастера выполняется специальный скрипт, который опкомпилирует и положит в shared memory набор классов. Все воркеры получают их бесплатно — без файловых операций, без compile, без autoload.
Эффект заметнее всего на тяжёлых фреймворках: Symfony, Laravel, Bitrix-ядро. Реальный прирост — 10–20% к RPS на CPU-bound нагрузке.
Создаём /var/www/preload.php:
<?php
// preload.php — выполняется один раз при старте FPM-мастера
// Загружаем все классы фреймворка в OPcache навсегда
$dir = '/var/www/site/vendor';
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iter as $file) {
if ($file->getExtension() !== 'php') continue;
$path = $file->getRealPath();
// Пропускаем тесты, бенчмарки, примеры
if (preg_match('#/(tests?|benchmarks?|examples?|docs?)/#i', $path)) continue;
// opcache_compile_file компилирует БЕЗ выполнения кода
try {
opcache_compile_file($path);
} catch (Throwable $e) {
// Некоторые файлы используют declare(strict_types=1) с side-effects
// или зависят от ещё не загруженных классов — пропускаем
}
}
Подключаем в opcache.ini:
opcache.preload = /var/www/preload.php
opcache.preload_user = www-data ; обязательно НЕ root
После рестарта FPM в логе появится NOTICE: PHP Preload: загружено N файлов. Если файлов меньше ожидаемого — смотрите ошибки в php-fpm.log: чаще всего препроцессинг падает на классах, наследующих ещё не загруженные родители. Подгружайте такие зависимости вручную через require_once перед циклом.
Важно: после изменения файлов в препроциях нужен полный рестарт FPM (systemctl restart php8.3-fpm), а не graceful reload. Препроцессинг выполняется только при старте мастера.
Мониторинг: status-страница и связь с Nginx
FPM умеет отдавать собственную страницу со статистикой пула. Включаем в пуле:
pm.status_path = /fpm-status
ping.path = /fpm-ping
ping.response = pong
И пробрасываем в Nginx с ограничением доступа:
location ~ ^/(fpm-status|fpm-ping)$ {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
Теперь curl http://localhost/fpm-status?full покажет состояние каждого воркера, время последнего запроса, потреблённую память и URL. Это особенно полезно во время инцидента: вы сразу видите, какие именно запросы съели пул.
Ключевые метрики, которые имеет смысл забирать в Prometheus/Zabbix через fpm-status?json:
active processes— сколько воркеров сейчас занято. Постоянно близко кmax_children= пора расширять пул.listen queue— длина очереди соединений на сокете. Любое значение больше нуля под нагрузкой = воркеров не хватает.max children reached— счётчик случаев упирания в лимит. Должен быть стабильным; растёт — добавляйтеmax_childrenили память.slow requests— счётчик запросов, попавших в slowlog.
Чеклист настройки FPM под нагрузку
- Замерили средний RSS воркера под реальной нагрузкой, не на глаз.
- Посчитали
pm.max_childrenот свободной памяти, с запасом 15–20%. - Поставили
pm.max_requests = 500..1000— лечит утечки в legacy-коде. - Включили
request_terminate_timeoutиslowlog. - Настроили
emergency_restart_thresholdна случай каскадного падения воркеров. - OPcache:
memory_consumption≥ 256 МБ,max_accelerated_files> реального числа .php-файлов. - На стабильном проде включили
opcache.validate_timestamps = 0и описалиopcache_reset()в деплое. - Подключили
opcache.preloadдля вендор-кода — +10–20% к RPS бесплатно. - Открыли
fpm-statusдля мониторинга, забрали ключевые метрики в систему алертинга.
Эти настройки не превратят PHP в Go, но они закрывают типовые причины тормозов: лишние форки, перерасход памяти, перекомпиляция файлов, висящие запросы без таймаута. На реальных проектах связка dynamic с правильным max_children + OPcache 256 МБ + preload даёт устойчивый прирост 30–50% к пропускной способности без единой строчки нового кода.
