PHP-FPM vs CLI, .env, opcache
PHP в проде: FPM, CLI, окружение, opcache
Встроенный сервер php -S (урок 1) отлично подходит для разработки. В проде PHP работает в одном из двух режимов: PHP-FPM (за nginx) - для веба, или CLI - для очередей и крон-задач. Понимание разницы решает 80% «странных» багов на проде.
CLI vs FPM: главное различие
| Свойство | CLI (php script.php) | FPM (за nginx) |
|---|---|---|
| Сессия | Своя для запуска | Общая (через cookie + сессионный файл/Redis) |
$_SERVER | Минимальный | Полный (REQUEST_URI, HTTP_HOST...) |
| Output buffer | По умолчанию выключен | Часто включён |
| Worker | Свежий процесс на каждый запуск | Долгоживущий процесс, обслуживает много запросов |
max_execution_time | 0 (без лимита) | 30 секунд по дефолту |
| Лимит памяти | Может быть выше | Жёсткий |
| Время старта | ~100мс на каждый запуск | Память грета, реквест ~1-2мс |
| Куда писать логи | stdout/stderr (systemd собирает) | error_log (или stderr контейнера) |
CLI идеален для воркеров, миграций, кронов. FPM - для веб-запросов: nginx принимает HTTP, отдаёт PHP через FastCGI, PHP отвечает.
Как работает FPM
HTTP FastCGI
браузер ────────────────► nginx ────────────────► php-fpm pool
│
├── worker 1 (free)
├── worker 2 (busy: index.php)
├── worker 3 (busy: api.php)
└── worker 4 (free)
PHP-FPM - мастер-процесс + N воркеров. Воркер обслуживает запрос целиком, потом сбрасывает суперглобалы ($_GET, $_POST, $_SESSION сохраняется на диск) и берёт следующий.
Большая разница с Go/Node: между запросами PHP обнуляет статическое состояние. Никаких долгоживущих connection pool в самом приложении - каждый запрос: открыть PDO, обработать, закрыть. (Persistent PDO через PDO::ATTR_PERSISTENT существует, но это уже воркер-уровневое состояние.)
Конфиг FPM pool
/etc/php/8.3/fpm/pool.d/www.conf (Debian/Ubuntu) или php-fpm.d/www.conf в Alpine:
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
pm = dynamic ; dynamic | static | ondemand
pm.max_children = 50 ; верхний потолок воркеров
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500 ; убить воркер после N запросов - защита от утечек
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s ; запросы дольше 5 сек попадают в slowlog
Как считать pm.max_children
max_children = (доступная RAM на PHP) / (средняя память воркера)
Пример: 4 ГБ под PHP, средний воркер 80 МБ → max_children ≈ 50. Считай по proc/status, не на глаз - воркеры могут разрастаться из-за крупных JSON-ответов и Doctrine.
pm = static для прода с предсказуемой нагрузкой
pm = static
pm.max_children = 40
Дороже по памяти (всегда 40 воркеров), но нет скачков start_server/min_spare под нагрузкой - реквест-латентность стабильнее.
nginx + FPM (минимальный конфиг)
server {
listen 80;
server_name api.example.com;
root /var/www/api/public;
index index.php;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 30;
}
location ~ /\. {
deny all;
}
}
Все PHP-запросы летят в index.php (front controller pattern), оттуда - твой роутер.
Переменные окружения и .env
В CLI и FPM окружение приходит по-разному. CLI получает env через getenv()/$_ENV сразу. FPM - только те, что разрешены конфигом:
; pool.d/www.conf
clear_env = no
env[APP_ENV] = $APP_ENV
env[DATABASE_DSN] = $DATABASE_DSN
Лучше не таскать всё через FPM, а грузить .env в код. Композеровский пакет vlucas/phpdotenv:
composer require vlucas/phpdotenv
<?php
// bootstrap.php
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->safeLoad();
$dotenv->required(['DATABASE_DSN', 'APP_ENV']);
$dsn = $_ENV['DATABASE_DSN'];
# .env (НЕ коммитим)
APP_ENV=prod
DATABASE_DSN=pgsql:host=db;dbname=app
LOG_LEVEL=info
# .env.example (коммитим - для коллег)
APP_ENV=dev
DATABASE_DSN=pgsql:host=localhost;dbname=app
LOG_LEVEL=debug
OPcache: главная оптимизация
PHP интерпретирует код при каждом запросе. OPcache (короткое intro - в уроке 1) держит скомпилированный байткод в shared memory - следующий запрос получает уже готовый.
Без OPcache PHP-FPM в 2-5 раз медленнее. На проде он включён всегда.
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; ПРОД: не проверяй mtime файлов
opcache.preload=/var/www/api/preload.php
opcache.preload_user=www-data
validate_timestamps=0на проде - иначе на каждый файл PHP сверяется с диском, минус 20-30% скорости. Цена: после деплоя нужноservice php8.3-fpm reload.preload(PHP 7.4+) - список файлов, загружаемых при старте FPM. Symfony делаетbin/console cache:warmup+ опционально preload-файл - стартовые контроллеры и сервисы уже в памяти.
validate_timestamps=1 для dev
В dev-окружении ставь validate_timestamps=1 и revalidate_freq=0 - иначе будешь дёргать reload после каждой правки.
JIT (PHP 8+)
opcache.jit_buffer_size=128M
opcache.jit=tracing
JIT компилирует горячий код в нативный машинный. Заметный буст для CPU-bound кода (математика, обработка изображений). На веб-приложениях типичный буст 0-10% - узкое место там БД и сеть, а не PHP. Включай, но не жди чудес.
Сигналы и graceful reload
Reload FPM (передёргивает воркеров без падения соединений):
sudo systemctl reload php8.3-fpm
# или
sudo kill -USR2 $(pidof php-fpm: master)
Каждый воркер обработает текущий запрос и завершится. Новые запросы поднимут свежих воркеров - с обновлённым кодом и пере-warmed opcache.
Healthcheck FPM
; pool.d/www.conf
pm.status_path = /status
ping.path = /ping
ping.response = pong
location ~ ^/(status|ping)$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
allow 127.0.0.1;
deny all;
}
/ping → 200 «pong» - k8s/loadbalancer чекает.
/status?json → метрики пула в JSON, удобно сливать в Prometheus через php-fpm_exporter.
CLI: воркеры и кроны
Для очередей: один-два процесса крутят consume-цикл. С Symfony Messenger:
php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
--time-limit и --memory-limit принципиальны - PHP не любит долго жить. Перезапускай раз в час, чтобы избежать утечек.
В systemd-юните Restart=always гарантирует автоматический подъём после крэша.
# /etc/systemd/system/messenger@.service
[Unit]
Description=Symfony Messenger worker %i
[Service]
ExecStart=/usr/bin/php /var/www/api/bin/console messenger:consume async --time-limit=3600
Restart=always
User=www-data
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now messenger@1.service messenger@2.service
Памяти и таймауты
memory_limit = 256M
max_execution_time = 30
max_input_time = 60
post_max_size = 16M
upload_max_filesize = 16M
Запросы дольше 30 сек - это RPC, а не HTTP. Если нужно дольше - выноси в очередь (CLI воркер) и отвечай 202 + ссылку на статус.
Как это в Symfony
Symfony 7 в проде:
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
Прогревает контейнер DI, роутер, аннотации/атрибуты, Twig-шаблоны. Это файлы в var/cache/prod/ - после warmup приложение почти не делает работы на старте запроса.
OPcache preload Symfony готовит автоматически - после composer dump-env prod + cache:warmup остаётся только подключить preload-файл в opcache.
Symfony Runtime отделяет «как стартовать» (FPM, Swoole, Roadrunner, ReactPHP) от «что делать». Для большинства проектов остаётся стандартный FPM - стабильно и хорошо изучено.
Типичные ошибки
- Использовать
php -Sв проде. Этот сервер для разработки. Никакой многопоточности, никаких воркеров - упадёт на первой же десятке RPS. memory_limit=-1. Без лимита баг в коде сожрёт всю RAM и сервер ляжет. Лимит должен быть, и довольно жёсткий (128-256M).- Логи в файл из FPM-воркера. В контейнере это бессмысленно - пиши в
php://stdout/php://stderr, systemd/docker/k8s сами агрегируют. - Связки
pm.max_childrenбез расчёта. Слишком большое - OOM-killer. Слишком маленькое - очередь и таймауты в nginx (502 bad gateway). - Долгие фоновые задачи в HTTP-запросе. Запрос >5 сек = плохая идея. Кидай в очередь и возвращай 202 с location.
Best practices
- Не используй
php -Sв проде: однопоточный, без очереди соединений, падает на первой же нагрузке. - Рассчитывай
pm.max_childrenчерезRAM_под_PHP / avg_worker_memory; при слишком большом значении сервер уйдёт в OOM, при слишком малом - 502 от nginx под нагрузкой. - На проде ставь
opcache.validate_timestamps=0и выполняйsystemctl reload php-fpmпосле каждого деплоя - это ускоряет обработку запросов в 2-5 раз. - В контейнерах пиши логи в
php://stdoutиphp://stderr, не в файлы - файлы исчезнут после рестарта контейнера. - Долгие задачи (дольше 5-10 секунд) выноси в CLI-воркер через очередь, возвращай HTTP 202 + ссылку на статус - не держи соединение открытым.
Мини-задание
- Запусти PHP-FPM локально, настрой nginx с FastCGI-проксированием, проверь, что страница открывается
- Включи OPcache, замерь время одной и той же страницы с
opcache.enable=0иopcache.enable=1(используйtime curl ...) - Установи
vlucas/phpdotenv, перенеси DSN в.env. Проверь, что код стартует и в CLI (php script.php), и в FPM - Настрой
pm.status_path = /statusи убедись, что метрики отдаются. Поставьphp-fpm_exporter(опционально) - Создай systemd-юнит для CLI-воркера (любой бесконечный
while(true) { sleep(1); }скрипт) сRestart=always