HardПрактика10 min

PHP-FPM: настройка и оптимизация

Тюнинг PHP-FPM для высоконагруженных приложений

Эволюция: CGI, FastCGI, PHP-FPM

CGI (Common Gateway Interface)

CGI -- первый способ запуска PHP на веб-сервере. Для каждого HTTP-запроса веб-сервер создавал новый процесс PHP-интерпретатора, который обрабатывал скрипт и завершался. Это означало полную инициализацию PHP на каждый запрос: загрузка расширений, парсинг конфигурации, подключение к базе данных.

HTTP Request → Nginx/Apache → fork() → PHP process → Response → process dies
HTTP Request → Nginx/Apache → fork() → PHP process → Response → process dies
HTTP Request → Nginx/Apache → fork() → PHP process → Response → process dies

Проблемы CGI очевидны: fork() системный вызов стоит дорого, инициализация PHP занимает время, соединения с БД создаются заново. При 100 запросах в секунду сервер порождает и убивает 100 процессов -- это колоссальная нагрузка на ОС.

FastCGI

FastCGI решил главную проблему CGI -- процессы стали постоянными. Вместо fork/die на каждый запрос, набор воркеров запускается заранее и обрабатывает запросы последовательно. Соединение между веб-сервером и FastCGI-процессами поддерживается через Unix-сокет или TCP.

                    ┌─ Worker 1 (persistent)
HTTP Request → Nginx ─┼─ Worker 2 (persistent)
                    └─ Worker 3 (persistent)

Однако стандартная реализация FastCGI для PHP (php-cgi) не умела управлять процессами -- не было graceful restart, мониторинга, adaptive spawning. Нужен был менеджер процессов.

PHP-FPM (FastCGI Process Manager)

PHP-FPM -- это продвинутая реализация FastCGI, созданная специально для PHP. Изначально существовал как патч к PHP (автор -- Andrei Nigmatulin), а с PHP 5.3.3 включён в ядро.

Что добавил PHP-FPM поверх FastCGI:

  • Менеджер процессов с тремя режимами (static, dynamic, ondemand)
  • Graceful restart без потери запросов (kill -USR2)
  • Slow log для отладки медленных скриптов
  • Emergency restart при массовых падениях воркеров
  • Раздельные пулы -- разные приложения с изоляцией
  • Страница статуса для мониторинга в реальном времени
  • Контроль таймаутов на уровне процесс-менеджера

Сегодня PHP-FPM -- стандарт де-факто для production-развёртывания PHP-приложений.


Режимы менеджера процессов (pm)

Директива pm определяет стратегию управления воркерами. Выбор режима критически влияет на потребление памяти и отзывчивость приложения.

static -- фиксированное количество воркеров

; /etc/php/8.4/fpm/pool.d/www.conf

[www]
pm = static

; Fixed number of workers -- always running, always consuming memory
pm.max_children = 50

; Restart worker after N requests to prevent memory leaks
pm.max_requests = 1000

При pm = static PHP-FPM запускает ровно pm.max_children воркеров при старте и поддерживает это число постоянно. Процессы не создаются и не уничтожаются в зависимости от нагрузки.

Преимущества:

  • Предсказуемое потребление памяти (всегда одинаковое)
  • Нет задержек на создание процессов при пике нагрузки
  • Нет CPU-оверхеда на fork/kill

Недостатки:

  • Память занята, даже если нагрузки нет (ночью, в выходные)
  • Нужен точный расчёт -- если мало воркеров, запросы будут ждать в очереди

Когда использовать: выделенные серверы со стабильно высокой нагрузкой, где трафик предсказуем и RAM выделена только под PHP.

dynamic -- адаптивное масштабирование

[www]
pm = dynamic

; Upper limit -- never exceed this number of workers
pm.max_children = 50

; Workers to start initially
pm.start_servers = 10

; Minimum idle workers ready to accept new requests
pm.min_spare_servers = 5

; Maximum idle workers (excess killed to free memory)
pm.max_spare_servers = 20

; Kill idle workers above min_spare after this timeout
pm.process_idle_timeout = 10s

; Restart worker after N requests (memory leak protection)
pm.max_requests = 1000

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

Как это работает на временной шкале:

Startup:       10 workers (pm.start_servers)
Low traffic:    5 workers (pm.min_spare_servers)
Growing:       15 workers (scaling up)
Peak:          50 workers (pm.max_children -- ceiling)
After peak:    20 workers (pm.max_spare_servers -- excess killed)
Night:          5 workers (pm.min_spare_servers)

Когда использовать: большинство production-серверов. Это самый распространённый и рекомендуемый режим для веб-приложений с переменной нагрузкой.

ondemand -- создание по запросу

[www]
pm = ondemand

; Maximum workers if all are busy
pm.max_children = 50

; Kill idle worker after this timeout
pm.process_idle_timeout = 10s

; Restart worker after N requests
pm.max_requests = 1000

Режим ondemand -- самый экономный по памяти. При старте PHP-FPM не создаёт ни одного воркера. Процесс порождается только когда приходит запрос, а после периода простоя (process_idle_timeout) -- уничтожается.

Преимущества:

  • Минимальное потребление памяти в состоянии покоя (0 воркеров)
  • Идеален для серверов с множеством сайтов (shared hosting)

Недостатки:

  • Первый запрос после простоя получает задержку на fork (~50-100ms)
  • При резком всплеске трафика все запросы ждут создания воркеров

Когда использовать: малопосещаемые сайты, staging-окружения, серверы с десятками пулов.


Ключевые параметры пула с формулами расчёта

pm.max_children -- главный параметр

Это максимальное число одновременно работающих PHP-процессов. Самый критичный параметр: слишком мало -- запросы встают в очередь; слишком много -- сервер уходит в swap и всё замирает.

Формула расчёта:

pm.max_children = (Total_RAM - OS_overhead - DB_memory - Cache_memory - Buffer) / Avg_PHP_process_memory

Как измерить средний расход памяти одного воркера:

# Method 1: Check RSS of FPM workers under real load
ps -eo pid,rss,command | grep 'php-fpm: pool' | awk '{sum += $2; n++} END {print "Average: " sum/n/1024 " MB", "Count: " n}'

# Method 2: Check via FPM status page (see monitoring section)
curl -s http://127.0.0.1/fpm-status?full | grep 'last request memory'

# Method 3: Check peak memory in PHP code
# memory_get_peak_usage(true) -- real allocated memory

Пример расчёта для сервера 8 GB:

Total RAM:                8192 MB
OS reserved:             -1024 MB
PostgreSQL:              -2048 MB
Redis:                    -512 MB
Nginx:                    -128 MB
Safety buffer:            -512 MB
──────────────────────────────────
Available for PHP-FPM:    3968 MB

Average process memory:    80 MB (measured under load!)

pm.max_children = 3968 / 80 = 49 → set to 49

Критическое правило: Всегда ИЗМЕРЯЙТЕ реальное потребление памяти под нагрузкой. Не угадывайте. Laravel-приложение может потреблять 40 MB на простых запросах и 200 MB на запросах с отчётами.

pm.start_servers -- начальное число воркеров

Рекомендуемая формула для dynamic режима:

pm.start_servers = pm.min_spare_servers + (pm.max_spare_servers - pm.min_spare_servers) / 2

Пример: min_spare=5, max_spare=20, тогда start_servers = 5 + (20 - 5) / 2 = 12.

Эта формула обеспечивает, что при старте PHP-FPM количество процессов находится посередине диапазона и не требует немедленного масштабирования ни вверх, ни вниз.

pm.min_spare_servers -- минимум свободных воркеров

Определяет, сколько воркеров всегда должны быть свободны и готовы принять новый запрос. Если свободных меньше -- PHP-FPM немедленно создаёт новые.

; Low traffic site: 2-5
pm.min_spare_servers = 5

; High traffic API: 10-20
pm.min_spare_servers = 10

Слишком низкое значение -- при всплеске нагрузки запросы попадут в listen queue, пока воркеры создаются. Слишком высокое -- лишняя память расходуется впустую.

pm.max_spare_servers -- максимум свободных воркеров

Если свободных воркеров больше этого числа, PHP-FPM убивает лишние. Это экономит память после пиков нагрузки.

; Typical range: 2-4x min_spare_servers
pm.max_spare_servers = 20

pm.max_requests -- защита от утечек памяти

; Restart worker after 1000 requests (prevents memory leaks)
pm.max_requests = 1000

; 0 = never restart (DANGEROUS in production!)
; pm.max_requests = 0

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

Оптимальные значения:

  • 500-1000 -- стандартное production-значение
  • 200-500 -- если есть подозрения на утечки
  • 5000-10000 -- если приложение стабильно и перезапуски дороги (preloading)

Совет: Для рандомизации перезапусков можно задавать разные значения в пулах. Иначе все воркеры могут перезапуститься одновременно, вызвав кратковременное падение производительности.


Таймауты и логирование

request_terminate_timeout -- убить зависший скрипт

; Kill PHP process if request takes longer than 60 seconds
; Independent of PHP's max_execution_time!
request_terminate_timeout = 60s

; 0 = disabled (NOT recommended -- hung processes consume workers)

Разница с max_execution_time: директива PHP считает только CPU-время, а request_terminate_timeout считает реальное время (wall clock). Если скрипт ждёт ответа от внешнего API 5 минут -- max_execution_time не сработает, но request_terminate_timeout убьёт процесс.

request_slowlog_timeout -- обнаружение медленных запросов

; Log stack trace if request takes longer than 5 seconds
request_slowlog_timeout = 5s

; Path to slow log file
slowlog = /var/log/php/fpm-slow.log

Slow log -- бесценный инструмент для диагностики. Когда запрос превышает порог, PHP-FPM записывает полный stack trace выполняющегося кода. Вы увидите, какая именно функция или запрос к БД вызывает задержку.

Пример вывода slow log:

[27-Feb-2026 14:32:11] [pool www] pid 1234
script_filename = /var/www/app/public/index.php
[0x00007f1234567890] PDOStatement->execute() /var/www/app/src/Repository/OrderRepository.php:89
[0x00007f1234567891] App\Repository\OrderRepository->findWithFilters() /var/www/app/src/Service/OrderService.php:45
[0x00007f1234567892] App\Service\OrderService->getFilteredOrders() /var/www/app/src/Controller/OrderController.php:31

Из этого стека сразу видно: проблема в OrderRepository::findWithFilters() -- медленный SQL-запрос.

access.log -- журнал доступа FPM

; FPM access log (separate from Nginx access log!)
access.log = /var/log/php/fpm-access.log

; Custom format:
; %R - remote IP, %u - user, %t - time, %m - method
; %r - request URI, %s - status, %d - duration (ms)
; %M - peak memory (KB), %C - CPU usage (%)
access.format = "%R - %u %t \"%m %r\" %s %d ms %{mega}M MB %C%%"

Пример строки лога:

127.0.0.1 - - 27/Feb/2026:14:32:11 +0000 "GET /api/orders" 200 245 ms 42.5 MB 12%

В Docker: направляйте slowlog и access.log в /dev/stderr и /dev/stdout соответственно.


Конфигурации для реальных сценариев

Сценарий 1: VPS 2 GB (WordPress/простой сайт)

; /etc/php/8.4/fpm/pool.d/wordpress.conf

[wordpress]
user = www-data
group = www-data
listen = /run/php/php-fpm.sock

; 2 GB total: OS ~512MB, MySQL ~512MB, Nginx ~64MB
; Available: ~900 MB, avg process ~60 MB
; max_children = 900 / 60 = 15

pm = dynamic
pm.max_children = 15
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
pm.process_idle_timeout = 10s

; Kill hung scripts after 30 seconds
request_terminate_timeout = 30s

; Log slow requests (>3 seconds)
request_slowlog_timeout = 3s
slowlog = /var/log/php/slow.log

; PHP overrides for this pool
php_admin_value[memory_limit] = 128M
php_admin_value[max_execution_time] = 30
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php/error.log

Сценарий 2: Dedicated 8 GB (Laravel API)

; /etc/php/8.4/fpm/pool.d/laravel-api.conf

[laravel-api]
user = www-data
group = www-data
listen = /run/php/php-fpm.sock
listen.backlog = 1024

; 8 GB total: OS ~1GB, PostgreSQL ~2GB, Redis ~512MB, buffer ~512MB
; Available: ~4 GB, avg process ~80 MB
; max_children = 4096 / 80 = 50

pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000
pm.process_idle_timeout = 10s

request_terminate_timeout = 300s
request_slowlog_timeout = 5s
slowlog = /var/log/php/slow.log

; Enable FPM status for monitoring
pm.status_path = /fpm-status
pm.status_listen = 127.0.0.1:9001

; PHP settings for API
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 60
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php/error.log
php_admin_value[open_basedir] = /var/www/app:/tmp

Сценарий 3: High-load 32 GB (микросервисы, несколько пулов)

; === Pool 1: API Gateway (high throughput, fast responses) ===
[api-gateway]
user = www-data
group = www-data
listen = /run/php/php-fpm-api.sock
listen.backlog = 4096

; Dedicated 10 GB for this pool
; avg process ~50 MB (lightweight API proxy)
; max_children = 10240 / 50 = 200

pm = static
pm.max_children = 200
pm.max_requests = 5000

request_terminate_timeout = 10s
request_slowlog_timeout = 1s
slowlog = /var/log/php/api-slow.log

php_admin_value[memory_limit] = 64M
php_admin_value[max_execution_time] = 10

; === Pool 2: Background Workers (heavy processing) ===
[workers]
user = www-data
group = www-data
listen = /run/php/php-fpm-workers.sock

; Dedicated 8 GB for this pool
; avg process ~200 MB (report generation, data processing)
; max_children = 8192 / 200 = 40

pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 15
pm.max_requests = 200

request_terminate_timeout = 600s
request_slowlog_timeout = 30s
slowlog = /var/log/php/workers-slow.log

php_admin_value[memory_limit] = 512M
php_admin_value[max_execution_time] = 300

Ключевой принцип: разделяйте пулы по характеру нагрузки. Быстрые API-запросы не должны конкурировать с тяжёлыми отчётами за одни и те же воркеры.


Мониторинг и диагностика

Страница статуса PHP-FPM

; Enable in pool config
pm.status_path = /fpm-status

; Optionally listen on separate port (no Nginx needed)
pm.status_listen = 127.0.0.1:9001
# Nginx config to expose FPM status (restrict to localhost!)
location = /fpm-status {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

Запрос статуса и интерпретация:

# Short status
curl -s http://127.0.0.1:9001/fpm-status

# Full status with per-process details
curl -s http://127.0.0.1:9001/fpm-status?full

# JSON format (for monitoring systems)
curl -s http://127.0.0.1:9001/fpm-status?json

Ключевые метрики:

pool:                 www
process manager:      dynamic
start time:           27/Feb/2026 10:00:00
start since:          14400        ← seconds since FPM start
accepted conn:        125000       ← total requests handled
listen queue:         0            ← CRITICAL: requests waiting for worker
max listen queue:     12           ← historical max (if >0, was overloaded)
listen queue len:     128          ← OS socket backlog size
idle processes:       8            ← workers waiting for requests
active processes:     12           ← workers currently handling requests
total processes:      20           ← idle + active
max active processes: 48           ← historical peak of active workers
max children reached: 2            ← times max_children hit (should be 0!)
slow requests:        34           ← requests exceeding slowlog threshold

Красные флаги:

Метрика Проблема Решение
listen queue > 0 Все воркеры заняты, запросы ждут Увеличить max_children или оптимизировать код
max children reached > 0 Достигнут потолок воркеров Увеличить max_children (если есть RAM)
slow requests растёт Медленные запросы Анализировать slow log
idle = 0 постоянно Нет свободных воркеров Увеличить max_children

Валидация конфигурации

# Check config syntax before restart
php-fpm -t
# Output: NOTICE: configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful

# Show effective configuration
php-fpm -tt

# Reload FPM without dropping connections (graceful)
kill -USR2 $(cat /run/php/php-fpm.pid)
# or
systemctl reload php8.4-fpm

Мониторинг памяти воркеров

# Real-time memory usage of FPM workers
watch -n 2 'ps -eo pid,rss,vsz,%mem,command | grep "php-fpm: pool" | sort -k2 -rn'

# Summary: total and average RSS
ps -eo rss,command | grep 'php-fpm: pool' | awk '{sum+=$1; n++} END {printf "Workers: %d, Total: %d MB, Average: %.1f MB\n", n, sum/1024, sum/n/1024}'

Безопасность PHP-FPM

Права доступа к сокету

; Unix socket permissions
listen = /run/php/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Only Nginx (running as www-data) can connect to the socket
; TCP alternative (for Docker/remote setups)
; listen = 127.0.0.1:9000
; listen.allowed_clients = 127.0.0.1

chroot -- изоляция процесса

; Restrict FPM workers to a specific directory tree
; Workers cannot access ANYTHING outside this path
chroot = /var/www/app

; After chroot, all paths are relative to chroot directory
; So /var/www/app/public/index.php becomes /public/index.php
chdir = /public

Внимание: chroot требует, чтобы все зависимости (библиотеки, расширения, временные файлы) были доступны внутри chroot-директории. Это сложно в настройке, но даёт сильную изоляцию.

security.limit_extensions -- ограничение расширений

; ONLY allow .php files to be executed via FastCGI
; Prevents executing .jpg, .txt, etc. as PHP (path traversal attacks)
security.limit_extensions = .php

; NEVER set to empty string in production!
; security.limit_extensions =   ← DANGEROUS: any file can be executed as PHP

Раздельные пулы для приложений

; Pool 1: Main application (trusted code)
[app-main]
user = app-main
group = app-main
listen = /run/php/app-main.sock
php_admin_value[open_basedir] = /var/www/app-main:/tmp
php_admin_value[disable_functions] = exec,shell_exec,system,passthru

; Pool 2: Admin panel (higher privileges)
[app-admin]
user = app-admin
group = app-admin
listen = /run/php/app-admin.sock
php_admin_value[open_basedir] = /var/www/app-admin:/tmp
php_admin_value[memory_limit] = 512M

; Pool 3: Third-party integration (untrusted, sandboxed)
[app-external]
user = app-sandbox
group = app-sandbox
listen = /run/php/app-external.sock
php_admin_value[open_basedir] = /var/www/app-external:/tmp
php_admin_value[disable_functions] = exec,shell_exec,system,passthru,proc_open,popen,dl
php_admin_value[memory_limit] = 64M
request_terminate_timeout = 10s

Каждый пул работает под своим пользователем ОС, с собственным open_basedir и ограничениями функций. Компрометация одного пула не даёт доступ к файлам другого.

Глобальные настройки безопасности FPM

; /etc/php/8.4/fpm/php-fpm.conf

[global]
; Emergency restart: if N children die within M seconds, restart FPM
; Protects against segfaults in PHP extensions
emergency_restart_threshold = 10
emergency_restart_interval = 60s

; Graceful stop timeout: how long to wait for children to finish
process_control_timeout = 10s

; Run as non-root (recommended)
daemonize = yes

Диагностика типичных проблем

Проблема: "502 Bad Gateway"

Nginx возвращает 502, когда не может связаться с PHP-FPM.

# Check if FPM is running
systemctl status php8.4-fpm

# Check socket exists and has correct permissions
ls -la /run/php/php-fpm.sock

# Check FPM error log
tail -50 /var/log/php/fpm-error.log

# Common causes:
# 1. FPM crashed (check emergency_restart)
# 2. Socket permissions mismatch (listen.owner != Nginx user)
# 3. All workers busy + listen queue full (increase max_children)

Проблема: "504 Gateway Timeout"

Nginx ждёт ответа от PHP-FPM, но не получает его в отведённое время.

# Check Nginx timeout settings
# fastcgi_read_timeout should be >= request_terminate_timeout

# Check FPM slow log for the culprit
tail -100 /var/log/php/slow.log

# Check if specific endpoints are slow
# access.log with %d (duration) helps identify patterns

Проблема: постоянный рост памяти воркеров

# Monitor RSS over time
while true; do
    echo "$(date): $(ps -eo rss,command | grep 'php-fpm: pool' | awk '{sum+=$1; n++} END {printf "Avg: %.0f MB, Max: ", sum/n/1024}')$(ps -eo rss,command | grep 'php-fpm: pool' | awk '{print $1/1024}' | sort -rn | head -1) MB"
    sleep 60
done

# Solution: reduce pm.max_requests to force more frequent restarts
# pm.max_requests = 500 (or even 200 for leaky apps)

Проверь себя

Что означает метрика `listen queue > 0` на странице статуса PHP-FPM?

На сервере 16 GB RAM, из которых 6 GB занимают ОС и БД. Средний PHP-процесс потребляет 100 MB. Каково оптимальное значение pm.max_children?

Зачем настраивать раздельные пулы PHP-FPM для разных приложений на одном сервере?

Чем request_terminate_timeout в PHP-FPM отличается от max_execution_time в php.ini?

Какой режим pm создаёт воркеры только при поступлении запроса и убивает их после простоя?