PHP-FPM vs CLI, .env, opcache

PHP в проде: FPM, CLI, окружение, opcache

Встроенный сервер php -S (урок 1) отлично подходит для разработки. В проде PHP работает в одном из двух режимов: PHP-FPM (за nginx) - для веба, или CLI - для очередей и крон-задач. Понимание разницы решает 80% «странных» багов на проде.

CLI vs FPM: главное различие

PHP-FPM за nginx vs CLI скрипты: разные режимы исполнения

СвойствоCLI (php script.php)FPM (за nginx)
СессияСвоя для запускаОбщая (через cookie + сессионный файл/Redis)
$_SERVERМинимальныйПолный (REQUEST_URI, HTTP_HOST...)
Output bufferПо умолчанию выключенЧасто включён
WorkerСвежий процесс на каждый запускДолгоживущий процесс, обслуживает много запросов
max_execution_time0 (без лимита)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
В Kubernetes/Docker-окружении секреты идут через `secrets`/`environment`, не `.env` файлы. `.env` - это удобство локалки, а не прод-механизм.

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 - стабильно и хорошо изучено.

Типичные ошибки

  1. Использовать php -S в проде. Этот сервер для разработки. Никакой многопоточности, никаких воркеров - упадёт на первой же десятке RPS.
  2. memory_limit=-1. Без лимита баг в коде сожрёт всю RAM и сервер ляжет. Лимит должен быть, и довольно жёсткий (128-256M).
  3. Логи в файл из FPM-воркера. В контейнере это бессмысленно - пиши в php://stdout/php://stderr, systemd/docker/k8s сами агрегируют.
  4. Связки pm.max_children без расчёта. Слишком большое - OOM-killer. Слишком маленькое - очередь и таймауты в nginx (502 bad gateway).
  5. Долгие фоновые задачи в 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

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.