PHP-FPM: настройка пулов, OPcache и preload под высокую нагрузку

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

Архитектура пула PHP-FPM: Nginx, FPM master, workers и OPcache shared memory
Как Nginx, FPM master, воркеры и OPcache связаны между собой

Три менеджера процессов: 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% к пропускной способности без единой строчки нового кода.