Что сравниваем

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 end
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
    end
В FPM приложение обычно поднимается внутри каждого request, а в RoadRunner bootstrap живёт внутри worker и используется повторно.

PHP-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.log

pm.max_requests — простой, но важный предохранитель. Даже если где-то есть утечка памяти в расширении, persistent resource или неудачный код, worker будет периодически пересоздаваться. Это не отменяет диагностику, но ограничивает ущерб.

Быстрое повторение
Почему в PHP-FPM обычно нельзя говорить, что «на каждый HTTP-запрос стартует новый PHP-процесс»?

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, меньше автозагрузки, меньше пересборки контейнера. Но цена — состояние приложения живёт дольше, чем один запрос.

Быстрое повторение
В RoadRunner PHP worker загрузил `bootstrap/app.php`, затем обработал 500 запросов. Сколько раз для этого 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.

Быстрое повторение
Что пойдёт не так, если `CurrentUser` хранится как singleton в RoadRunner и не сбрасывается после request?

Память, лимиты и перезапуск воркеров

В долгоживущем 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 становится оптимизацией, а не источником случайных багов.

См. также

Источники

  1. PHP Manual: FastCGI Process Manager (FPM)
  2. PHP Manual: FPM Configuration
  3. RoadRunner Documentation: Developer mode
  4. RoadRunner Documentation: Worker pool