Что сравниваем
PHP-FPM и RoadRunner решают одну внешнюю задачу: принять HTTP-запрос и выполнить PHP-код. Но lifecycle у них разный, и именно из-за lifecycle меняются правила проектирования приложения.
PHP-FPM — это FastCGI Process Manager: отдельный менеджер процессов, который держит пул PHP worker-процессов и получает запросы от Nginx или Apache через FastCGI. Это классическая модель для PHP-приложений: web server принимает HTTP, FPM выполняет PHP, приложение отдаёт response. Связь с Версии PHP и режимы выполнения здесь прямая: FPM — один из SAPI/режимов выполнения PHP.
RoadRunner — application server: обычно Go-процесс принимает HTTP, управляет пулом PHP CLI workers и передаёт им запросы по внутреннему протоколу. PHP worker не завершается после одного request, а остаётся жить и ждёт следующий. Поэтому RoadRunner ближе к теме долгоживущих процессов, как и Swoole и OpenSwoole, но без PHP-расширения, встроенного HTTP-сервера на стороне PHP и coroutine runtime.
flowchart LR
subgraph FPM[PHP-FPM]
N1[Nginx/Apache] --> F1[FastCGI request]
F1 --> FW[FPM child worker]
FW --> A1[Bootstrap приложения]
A1 --> R1[Handle request]
R1 --> C1[Request state очищается]
C1 --> FW
end
subgraph RR[RoadRunner]
N2[RoadRunner HTTP server] --> P[Worker pool]
P --> W1[PHP CLI worker]
W1 --> B[Bootstrap один раз]
B --> L[waitRequest loop]
L --> R2[Handle request]
R2 --> S[Явный reset request state]
S --> L
endPHP-FPM: request isolation без запуска процесса на каждый request
Частая неточность: «в PHP-FPM на каждый запрос стартует новый PHP-процесс». Обычно нет. FPM держит пул child-процессов: static, dynamic или ondemand. Один child может обработать много запросов за свою жизнь.
Но для userland-кода важнее другое: каждый HTTP request получает свежий request context. Переменные, $_GET, $_POST, $_SERVER, большинство объектов приложения, контейнер, контроллеры и middleware заново создаются в рамках обработки запроса. После завершения request это состояние должно быть очищено движком. OPcache может хранить скомпилированный байткод, persistent connections могут переживать запрос, FPM child живёт дальше, но обычная бизнес-логика не должна видеть старого пользователя из предыдущего запроса.
Типовая FPM-конфигурация выглядит так:
; www.conf
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/www-slow.logpm.max_requests — простой, но важный предохранитель. Даже если где-то есть утечка памяти в расширении, persistent resource или неудачный код, worker будет периодически пересоздаваться. Это не отменяет диагностику, но ограничивает ущерб.
RoadRunner: bootstrap один раз, request много раз
В RoadRunner PHP worker обычно запускается как CLI-скрипт. Он загружает vendor/autoload.php, поднимает приложение, создаёт PSR-7 worker и входит в цикл ожидания запросов.
Упрощённо это выглядит так:
<?php
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunner\Http\PSR7Worker;
use Nyholm\Psr7\Factory\Psr17Factory;
require __DIR__ . '/vendor/autoload.php';
$app = require __DIR__ . '/bootstrap/app.php'; // один раз на worker
$factory = new Psr17Factory();
$worker = new PSR7Worker(
Worker::create(),
$factory,
$factory,
$factory
);
while ($request = $worker->waitRequest()) {
try {
$response = $app->handle($request); // много раз
$worker->respond($response);
} catch (Throwable $e) {
$worker->getWorker()->error((string) $e);
} finally {
$app->terminateRequest(); // условный сброс request-scoped состояния
}
}Ключевая строка здесь не handle(), а bootstrap/app.php. В FPM она обычно выполняется на каждый request. В RoadRunner — один раз на worker. Это даёт выигрыш: меньше повторного bootstrap, меньше автозагрузки, меньше пересборки контейнера. Но цена — состояние приложения живёт дольше, чем один запрос.
Stale state: главный риск worker model
Stale state — это старое состояние, которое случайно пережило request и повлияло на следующий. В PHP-FPM такой баг часто маскируется, потому что контейнер или сервис создаётся заново. В RoadRunner он становится настоящей production-проблемой.
Плохой пример:
final class CurrentUser
{
private ?User $user = null;
public function set(User $user): void
{
$this->user = $user;
}
public function get(): ?User
{
return $this->user;
}
}Если CurrentUser зарегистрирован как singleton и не сбрасывается после request, следующий запрос может увидеть пользователя предыдущего запроса. Та же проблема бывает с locale, timezone, tenant ID, feature flags, correlation ID, временными DTO, кешем permissions и объектами Request/Response.
Рабочее правило: singleton в долгоживущем worker должен быть либо stateless, либо явно resettable. Request-specific данные должны жить в request scope, а не в глобальном сервисе, статическом свойстве или случайном кеше.
interface ResetAfterRequest
{
public function reset(): void;
}
final class CurrentUser implements ResetAfterRequest
{
private ?User $user = null;
public function set(User $user): void
{
$this->user = $user;
}
public function get(): ?User
{
return $this->user;
}
public function reset(): void
{
$this->user = null;
}
}В зрелом приложении такой reset обычно делает middleware, kernel terminator или интеграция фреймворка с RoadRunner.
Память, лимиты и перезапуск воркеров
В долгоживущем PHP-процессе память ведёт себя иначе, чем в привычной FPM-модели. Небольшая утечка, рост статического массива или накопление ссылок могут быть незаметны на одном request, но проявятся после тысяч запросов.
RoadRunner поэтому настраивают как supervised worker pool:
http:
address: 0.0.0.0:8080
pool:
num_workers: 8
max_jobs: 1000
supervisor:
watch_tick: 5s
ttl: 1h
max_worker_memory: 128
exec_ttl: 60sСмысл этих лимитов не в том, чтобы «чинить» плохой код рестартами. Они дают верхнюю границу риска: worker не должен бесконечно расти по памяти, зависать на одном request или жить неделями без обновления. Похожая идея есть и в FPM через pm.max_requests, но в RoadRunner она важнее, потому что именно долгоживущий bootstrap является частью модели производительности.
Preload, bootstrap и контейнер приложения
OPcache preload в FPM и bootstrap в RoadRunner часто путают, потому что оба уменьшают накладные расходы старта. Но это разные вещи.
Preload загружает PHP-файлы в OPcache при старте PHP/FPM master, чтобы классы и функции были заранее скомпилированы и доступны worker-процессам. Он не превращает обычное FPM-приложение в долгоживущий application server.
RoadRunner bootstrap реально создаёт runtime-состояние внутри PHP worker: контейнер, роутер, конфиг, клиенты, кеши, middleware graph. Поэтому контейнер должен различать application scope и request scope. Конфигурация, роуты, stateless-сервисы и клиенты могут жить долго. Текущий пользователь, request body, выбранный tenant, flash-сообщения и временные validation errors — нет.
Graceful reload и деплой
В PHP-FPM graceful reload обычно означает: master перечитывает конфигурацию, старые workers завершают текущие запросы, новые workers стартуют уже с обновлённым кодом или настройками. На практике это связывают с reload FPM, сбросом OPcache и аккуратным переключением релиза.
В RoadRunner reload должен учитывать два слоя: сам RoadRunner-процесс и PHP workers. При деплое нужно добиться, чтобы старые workers перестали принимать новые запросы, завершили текущие, а новые поднялись с новым кодом и новым bootstrap. Если просто заменить файлы на диске, уже запущенный worker не обязан «увидеть» новый контейнер или новые классы так, как ожидается.
Отсюда эксплуатационное правило: для RoadRunner, Swoole и OpenSwoole и любых долгоживущих воркеров деплой должен включать управляемый reload/restart, health checks и понятную стратегию отката.
Когда что выбирать
PHP-FPM остаётся хорошим дефолтом для большинства web-приложений: CRUD, админки, личные кабинеты, API со средней нагрузкой, проекты на shared hosting, команды без отдельной эксплуатации application server. Он предсказуем, хорошо документирован, привычен для Nginx/Apache и forgiving к неидеальному application state.
RoadRunner имеет смысл, когда bootstrap дорогой, нужна высокая пропускная способность, приложение уже готово к request scope, команда понимает memory leaks и умеет наблюдать workers в production. Он хорошо ложится на PSR-7/PSR-15-подход, микросервисы, gRPC, jobs и приложения, где выигрыш от долгоживущего runtime окупает дисциплину.
Проверочный вопрос для выбора простой: «Наш код корректен, если один и тот же PHP-процесс обработает 10 000 разных пользователей подряд?» Если ответ неуверенный, сначала нужен аудит state boundaries, логирования, метрик и сброса контейнера. Только после этого RoadRunner становится оптимизацией, а не источником случайных багов.
См. также
- Версии PHP и режимы выполнения — SAPI, CLI, FPM/CGI и жизненный цикл PHP-процесса.
- Swoole и OpenSwoole — другой подход к долгоживущим PHP-серверам через расширение и coroutine runtime.
- Асинхронный PHP и event loop — cooperative concurrency, event loop и отличие async I/O от долгого worker lifecycle.
- PSR-7, middleware и HTTP-клиенты — модель HTTP message, на которую часто опираются RoadRunner-интеграции.
- Очереди, фоновые задачи и воркеры — похожие риски retry, idempotency, memory limits и supervisor-процессов.
- Наблюдаемость и эксплуатация PHP-сервисов — logs, metrics, tracing, health checks и рестарты в production.