Сервер начинает тормозить под нагрузкой, в логах появляется знакомая строка: WARNING: [pool www] server reached pm.max_children setting. Первый рефлекс - взять число, умножить его на два, перезапустить PHP-FPM. Нагрузка растёт ещё немного - и всё повторяется. Этот цикл можно прервать только одним способом: разобраться, как именно PHP-FPM управляет процессами и от чего на самом деле зависит правильное значение каждого параметра.

Здесь не будет магических формул с множителями "на количество ядер CPU". Будет методика, которая работает на конкретном железе с конкретным приложением.

Три режима управления процессами и когда применять каждый

PHP-FPM предлагает три принципиально разных подхода к управлению пулом: static, dynamic и ondemand. Выбор между ними - это первое решение, которое нужно принять до того, как трогать любые числовые параметры.

Режим static держит ровно pm.max_children процессов запущенными всегда. Никакого порождения и завершения под нагрузкой - процессы сидят в памяти и ждут запросов. Это самый быстрый вариант при пиковой нагрузке: когда приходит запрос, он немедленно попадает к уже готовому воркеру без задержки форка. На серверах с достаточным объёмом RAM и стабильным трафиком - правильный выбор для продакшена. Оборотная сторона очевидна: все pm.max_children процессов держат память даже когда сервер обслуживает один запрос в минуту.

Режим dynamic - дефолт большинства дистрибутивов. Он порождает процессы при необходимости и завершает их при простое, удерживая число между pm.min_spare_servers и pm.max_spare_servers. Кажется разумным компромиссом, но у него есть скрытая стоимость: форк нового процесса занимает несколько миллисекунд, и при резких всплесках трафика эта задержка накапливается. Практические тесты показывают, что при 10 000 запросов с concurrency 100 static выигрывает у dynamic около 24 миллисекунд суммарно - немного, но существенно если приложение само по себе быстрое.

Режим ondemand не держит никаких процессов в простое. Когда запрос приходит - форкается процесс, обрабатывает запрос, завершается через pm.process_idle_timeout секунд бездействия. Это идеальный выбор для shared-хостинга с сотнями пулов, где большинство сайтов получают единичные запросы в день - экономия памяти там огромная. Для высоконагруженного продакшен-сайта ondemand - худший вариант из трёх.

Базовая конфигурация для static на высоконагруженном сервере:

pm = static
pm.max_children = 50
pm.max_requests = 500

Как измерить реальный расход памяти одного процесса

Единственная правильная отправная точка для расчёта pm.max_children - это фактический расход памяти одного PHP-FPM процесса на своём приложении. Не среднее по интернету, не предположение "обычно около 50 МБ". Своё приложение.

Замерить расход памяти при живом трафике можно несколькими способами. Самый надёжный - через RSS (Resident Set Size) из системных данных о процессах:

# Получить PID мастер-процесса PHP-FPM
pgrep -o php-fpm

# Посмотреть память всех дочерних процессов (подставить свою версию PHP)
ps -C php-fpm8.3 --no-headers -o pid,rss | awk '{sum+=$2; n++} END {print sum/n/1024 " MB average, " n " processes"}'

Второй способ - через смапированную память, которая точнее учитывает разделяемые страницы:

# Суммарная и средняя память с учётом shared pages
sudo python3 /usr/lib/python3/dist-packages/ps_mem.py | grep php-fpm

Смотреть на эти числа нужно после нескольких часов работы под реальной нагрузкой, а не сразу после запуска. OPcache, realpath cache, session-данные - всё это со временем занимает своё место в памяти процесса. Свежезапущенный PHP-FPM занимает одни цифры, тот же процесс после тысячи запросов - другие, и разница может быть значительной. Типичные значения варьируются широко: голый PHP с простым приложением - 20-30 МБ, WordPress с плагинами - 60-80 МБ, тяжёлый Magento или Symfony с большим числом сервисов - 100-150 МБ и выше.

Расчёт pm.max_children по памяти сервера

Когда средний расход памяти одного процесса известен, расчёт прямой. Нужно взять объём RAM, доступный для PHP-FPM, разделить на средний размер процесса и умножить на коэффициент безопасности 0.75-0.80.

Доступная RAM для PHP-FPM - это не весь объём памяти сервера. Нужно вычесть:

  • операционную систему и системные процессы (обычно 300-500 МБ),
  • базу данных, если она на том же сервере (MySQL/MariaDB в типичной конфигурации - 1-4 ГБ),
  • Redis, Memcached и другие сервисы,
  • буфер для OPcache (256-512 МБ при стандартных настройках).

Скрипт для автоматического расчёта:

#!/bin/bash
TOTAL_RAM_MB=$(free -m | awk '/^Mem:/{print $2}')
OS_RESERVED_MB=500
DB_RESERVED_MB=1024      # Скорректировать под реальный расход БД
OPCACHE_MB=256
PHP_AVAILABLE_MB=$((TOTAL_RAM_MB - OS_RESERVED_MB - DB_RESERVED_MB - OPCACHE_MB))

# Средний RSS одного процесса в МБ
AVG_PROCESS_MB=$(ps -C php-fpm8.3 --no-headers -o rss | \
  awk '{sum+=$1; n++} END {printf "%.0f", sum/n/1024}')

MAX_CHILDREN=$(echo "scale=0; $PHP_AVAILABLE_MB * 0.80 / $AVG_PROCESS_MB" | bc)

echo "Доступно для PHP-FPM: ${PHP_AVAILABLE_MB} МБ"
echo "Средний размер процесса: ${AVG_PROCESS_MB} МБ"
echo "Рекомендуемое pm.max_children: ${MAX_CHILDREN}"

На сервере с 16 ГБ RAM, где MySQL занимает 2 ГБ, а средний PHP-процесс - 80 МБ, результат будет около 120 процессов. На VPS с 4 ГБ и тем же приложением - около 25-30.

Как правильно выставить pm.start_servers и spare-параметры

Параметр pm.start_servers актуален только для режима dynamic - он определяет, сколько процессов создаётся при старте PHP-FPM. Есть одно ограничение, которое документация упоминает вскользь: значение pm.start_servers обязано быть не меньше pm.min_spare_servers и не больше pm.max_spare_servers. Иначе PHP-FPM тихо скорректирует значение по своим правилам.

Широко распространённая формула "start_servers = CPU cores × 4" имеет сомнительное происхождение и ненадёжно работает на практике. Разумная отправная точка - выставить start_servers равным min_spare_servers, а min_spare_servers сделать равным 25% от max_children. Тогда max_spare_servers - 50-75% от max_children. Это даёт достаточно процессов для немедленного ответа без перегрева сервера при старте.

Конфигурация для dynamic на сервере с расчётным max_children = 80:

pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 60
pm.max_requests = 500

Параметр pm.max_requests при этом важен независимо от режима: он перезапускает процессы после заданного числа обработанных запросов. Это страховка от постепенных утечек памяти в стороннем коде, которые иначе обнаруживаются через несколько дней по потреблению RAM. Значение 500-1000 работает для большинства приложений.

Разные пулы для разных задач

Одна из самых недооценённых возможностей PHP-FPM - раздельные пулы для разных частей приложения. Интернет-магазин, например, состоит из публичного фронтенда и административной панели. Их характеристики принципиально разные: фронтенд получает тысячи запросов в минуту от анонимных пользователей, бэкенд - единицы запросов от нескольких администраторов.

Держать одинаковые настройки для обоих - значит либо расходовать лишнюю память на админку, либо недодавать ресурсов магазину. Решение - два пула с разными режимами:

; /etc/php/8.3/fpm/pool.d/frontend.conf
[frontend]
listen = /run/php/php8.3-fpm-frontend.sock
pm = static
pm.max_children = 60
pm.max_requests = 500

; /etc/php/8.3/fpm/pool.d/backend.conf
[backend]
listen = /run/php/php8.3-fpm-backend.sock
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
pm.max_requests = 200

Разные сокеты - разные блоки upstream в Nginx, и каждая часть сайта получает ровно те ресурсы, которых требует её трафик.

Мониторинг через status-страницу и медленные запросы

Расчёт - это только начало. Реальные параметры неизбежно потребуют коррекции после нескольких дней работы под продакшен-нагрузкой. Для этого нужно знать, что именно мониторить.

PHP-FPM отдаёт текущее состояние пула через встроенную status-страницу. Включить её в конфигурации пула:

pm.status_path = /fpm-status
ping.path = /fpm-ping
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s

И ограничить доступ в Nginx только для локальных запросов:

location /fpm-status {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

Ключевые метрики, за которыми нужно следить: active processes (если постоянно близко к max_children - нужно поднимать лимит), listen queue (очередь запросов, ожидающих свободного воркера, значение больше нуля - сигнал тревоги), max active processes (исторический максимум одновременно активных процессов с момента запуска).

Просмотреть все метрики одной командой:

curl -s http://127.0.0.1/fpm-status | grep -E 'active|idle|queue|max'

Slowlog фиксирует каждый запрос, который выполнялся дольше request_slowlog_timeout секунд, с полным стектрейсом PHP. Это ценнее, чем кажется: медленные запросы блокируют воркеры, и именно они чаще всего становятся причиной "server reached pm.max_children" - не потому что запросов слишком много, а потому что каждый из них занимает воркер дольше обычного.

Правильная настройка PHP-FPM не бывает окончательной. Приложение меняется, трафик меняется, железо обновляется. Но если расчёт сделан по реальным замерам памяти, режим управления процессами выбран под характер нагрузки, а мониторинг настроен с первого дня - система сама подскажет, когда и в какую сторону крутить параметры.

Первая неделя в продакшене обычно выявляет то, что не видно при тестировании: неожиданно тяжёлые CRON-задачи, пики трафика в определённые часы, специфику работы OPcache при перезапуске. Именно поэтому slowlog и status-страница должны быть настроены ещё до первого запуска под нагрузку, а не после первого инцидента. Система, за которой наблюдают с самого начала, ведёт себя предсказуемо - и оставляет время на архитектурные решения вместо пожарного тюнинга.