Symfony
Структура Symfony подходит резидентному воркеру: есть ядро, которое вы поднимаете, есть Request, который вы ему отдаёте, и есть Response, который он возвращает. Под Rapira ядро поднимается один раз, при старте воркера, а каждый следующий запрос — это вызов handle() на уже прогретом контейнере. В самом приложении почти ничего не меняется; меняются двадцать строк, которые заменяют public/index.php. Эта страница описывает сам файл, сброс состояния между запросами и то, как значения из .env добираются до контейнера.
Проверено на
- PHP 8.5.8 — NTS, embed SAPI
- Rapira 0.6.0
- Symfony 7.4 (
symfony/framework-bundlev7.4.15) — полный набор проверок вdevи вprod - Symfony 8.1 (
symfony/framework-bundlev8.1.2) — полный набор проверок вdev
Оба приложения — обычный symfony/skeleton под одним процессом воркера, и оба работали с одним и тем же worker.php: байт в байт, без единой ветки под версию. В набор входят маршрутизация, 404, строки запроса, сгенерированные URL, отправка формы, тело в JSON, сессии, живущие между запросами, загрузка файла, непойманное исключение и 200 запросов подряд.
Поведение в режиме воркера
Ядро поднимается в начале скрипта, вне цикла, и живёт столько же, сколько процесс воркера: автозагрузчик, скомпилированный контейнер, маршрутизатор, диспетчер событий и все соединения, открытые вашими бандлами, строятся один раз, а не на каждый запрос. Это и даёт режим SAPI Worker; подробнее — в разделе Режимы выполнения.
На каждом запросе обработчик делает четыре вещи и прибирается за собой:
Request::createFromGlobals()— Rapira заново заполняет$_GET,$_POST,$_SERVER,$_COOKIEи$_FILESперед каждым вызовом вашего обработчика, поэтому обычный конструктор Symfony читает ровно то же самое, что читал бы под php-fpm.$kernel->handle($request)— маршрутизация, контроллер, ответ: всё без изменений.$response->send()— вывод становится HTTP-ответом (как он упаковывается на выходе, разбирает раздел HTTP).$kernel->terminate($request, $response)— отрабатывают слушатели, которым положено сработать после ответа, как и всегда.
После этого обработчик сбрасывает сервисы с состоянием через services_resetter из контейнера — это тот же самый сброс, который Symfony выполняет между сообщениями Messenger, и именно им долгоживущее ядро избавляется от накопленного за запрос.
Сессии работают как обычные сессии PHP, ровно так же, как под php-fpm: session_start() на каждом запросе, кука уходит вместе с ответом, а данные читаются на следующем запросе. Изоляция между клиентами проверена: второй клиент с чистым набором кук получает собственную сессию.
Одно ядро живёт в одном процессе воркера, а воркеры — это отдельные процессы операционной системы, и в пользовательском коде между ними не разделяется ничего. Сколько их и кто за ними присматривает, рассказывает Модель процессов.
Что понадобится
Понадобятся установленная Rapira и приложение на Symfony — свежее composer create-project symfony/skeleton my-app или то, которое у вас уже есть. Специально готовить приложение не нужно: скрипт воркера ложится рядом с composer.json, всё остальное остаётся на своих местах. Ещё на машине нужен обычный PHP CLI — для Composer и bin/console. Rapira поставляет PHP библиотекой (libphp), а не командой php, поэтому эти шаги выполняются системным PHP, который Rapira не использует и не трогает.
Два расширения всё же важны: скелет жёстко требует их в composer.json (ext-ctype, ext-iconv) и объявляет replace для соответствующих полифилов — значит, это должны быть настоящие расширения, а не заглушки на PHP. Нужны они обеим сборкам PHP, включая системный CLI: без них composer create-project и composer install не пройдут проверку платформы ещё до того, как до Rapira вообще дойдёт дело. В PHP, который едет в каждом релизе Rapira, есть оба: ctype и iconv стоят в строке конфигурации сборки, а полный список расширений собран на странице Установка. Если вы собираете Rapira со своим PHP, оставьте оба включёнными — где задаётся этот список, показывает Сборка из исходников.
Скрипт воркера ниже использует ещё и symfony/dotenv — он идёт в комплекте со скелетом. Если у вас на деплое выставлены настоящие переменные окружения и никакого .env нет вовсе, выбросьте эту строку, а вместе с ней и сам компонент. Через symfony/runtime воркер не проходит — .env он поднимает и ядро создаёт сам, — но пакет оставьте установленным, потому что bin/console и public/index.php по-прежнему им пользуются.
Скрипт воркера
Положите это в корень проекта под именем worker.php. Именно этот файл, дословно, и проверялся на обеих мажорных версиях:
<?php
declare(strict_types=1);
use App\Kernel;
use Rapira\Plugin\Http\HttpHandlerConfig;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request;
use function Rapira\create_plugin_handler;
require __DIR__ . '/vendor/autoload.php';
// public/index.php delegates this to symfony/runtime; here we do it once, up front.
(new Dotenv())->usePutenv()->bootEnv(__DIR__ . '/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
$container = $kernel->getContainer();
$http = create_plugin_handler(new HttpHandlerConfig());
$handler = static function () use ($kernel, $container): void {
$request = Request::createFromGlobals();
try {
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
} finally {
// The same reset Symfony runs between Messenger messages: every service
// tagged kernel.reset drops the state it accumulated during the request.
// In finally: handle() turns application exceptions into a response, but a
// failing send() or a throwing kernel.terminate listener escapes the handler,
// and the worker keeps serving — the reset has to run on that path too.
if ($container->has('services_resetter')) {
$container->get('services_resetter')->reset();
}
}
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}Большая часть здесь — обычный бутстрап Symfony. Специфичны для этой схемы четыре строки:
(new Dotenv())->usePutenv()->bootEnv(...). В обычном приложении вы такого не пишете: public/index.php перекладывает эту работу на symfony/runtime. Здесь бутстрапом распоряжается сам воркер, поэтому .env он читает сам — один раз, ещё до появления ядра. usePutenv() обязателен: без него приложение отдаёт 500 в prod, а в dev продолжает работать. Подробнее — в разделе $_ENV и variables_order.
Ядро создаётся и поднимается до цикла. new Kernel(...), boot() и getContainer() выполняются при старте воркера, поэтому $_SERVER['APP_ENV'] читается, пока значения Dotenv ещё на месте, а контейнер прогревается до прихода первого запроса. Дальше всё, что внутри while, работает с этим единственным контейнером.
Проверка $container->has('services_resetter') перед get(). Идентификатор сервиса services_resetter публичен и в 7.4, и в 8.1 — именно поэтому один и тот же файл работает на обеих версиях. А вот класс за этим идентификатором между мажорами переехал в другое пространство имён (Symfony\Component\DependencyInjection\ServicesResetter в 7.4, Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter в 8.1), и обращение к сервису по идентификатору эту разницу стирает. Проверка has() не даёт скрипту упасть с фатальной ошибкой на контейнере, где такого сервиса нет.
Цикл и gc_collect_cycles(). handleRequest() блокируется, пока не придёт запрос, выполняет ваш обработчик и возвращает true — или false, когда сервер останавливается, и на этом цикл заканчивается. Сборка циклов один раз за итерацию удерживает эту работу между запросами, а не посреди одного из них. Полный контракт описан в разделе Режим воркера.
Если сброса через services_resetter не хватает, есть два более тяжёлых варианта: $container->reset() стирает все сервисы, которые успели создаться, а $kernel->reboot(null) выбрасывает контейнер целиком и строит новый. После второго $container, захваченный обработчиком, устаревает, так что при таком варианте перезапрашивайте его через $kernel->getContainer(). И то и другое выбрасывает прогретое состояние, ради которого и нужен режим воркера, поэтому используйте их, пока ищете утечку, а не как настройку по умолчанию.
$_ENV и variables_order
С голым bootEnv(), без usePutenv(), приложение Symfony с APP_ENV=prod отдаёт 500 уже на первом запросе и на каждом следующем тоже, с EnvNotFoundException: Environment variable not found: "DEFAULT_URI". То же самое приложение в dev не падает.
Причина — в PHP. При тех значениях ini, с которыми шла проверка (variables_order = "GPCS", auto_globals_jit = On), PHP на каждом запросе заново взводит JIT-флаг для $_ENV. Первый же файл, скомпилированный в течение этого запроса и упоминающий $_ENV, запускает php_auto_globals_create_env, а тот перечитывает суперглобальную переменную из настоящего окружения процесса — стирая всё, что Dotenv->bootEnv() положил туда при старте воркера. В пробе $_ENV прямо посреди запроса превращался из заполненного массива в пустой.
Почему только prod. В prod контейнер и файлы сервисов лениво компилируются как раз на первом запросе, поэтому стирание случается раньше, чем RequestContext разрешает %env(DEFAULT_URI)%, — а разрешать к этому моменту уже нечего. В dev отладочный контейнер разбирает обращения к переменным окружения заранее, внутри $kernel->boot() при старте, и кеширует значения, так что стирание приходит уже после того, как ответ записан. В dev поведение то же самое, просто там оно ни на что не влияет.
Лечится это одной строкой из скрипта выше:
(new Dotenv())->usePutenv()->bootEnv(__DIR__ . '/.env');usePutenv() заставляет Dotenv записать значения ещё и в настоящее окружение процесса, а именно оттуда повторный импорт их и читает — так что значения его переживают; да и EnvVarProcessor в Symfony всё равно откатывается на getenv(). Rapira выполняет PHP в сборке NTS в модели с предварительным форком, по одному интерпретатору на процесс, поэтому обычные предостережения про потокобезопасность putenv() здесь не действуют.
Второй вариант для продакшена — выставить настоящие переменные окружения (Environment= в юните systemd, средства вашего контейнерного рантайма или оркестратора), а .env оставить удобством для разработки. И в том, и в другом случае значения окажутся там, где повторный импорт посреди запроса их не сотрёт.
Это относится к любому PHP-рантайму с резидентными воркерами: под удар попадает каждый фреймворк, который читает $_ENV лениво. Страница Фреймворки разбирает это вместе с двумя другими особенностями резидентного процесса — деструктором объекта, поднятого на старте, и register_shutdown_function(): и то и другое срабатывает ровно один раз, в конце первого запроса.
Запуск
rapira serve worker.php
curl -i http://127.0.0.1:8000/Режим воркера включён по умолчанию, адрес 127.0.0.1:8000 — тоже. rapira serve остаётся на переднем плане, а Ctrl-C мягко его останавливает, дав доработать начатым запросам.
Входной скрипт называется worker.php, а не index.php, поэтому в $_SERVER['SCRIPT_NAME'] лежит /worker.php. Request в Symfony ищет это имя в начале URI, не находит и сводит базовый URL к "". getPathInfo() возвращает настоящий путь, маршруты совпадают, а generateUrl() выдаёт чистые пути, в которых нигде нет префикса /worker.php. Ни подмен в $_SERVER, ни обходных путей с Request::setTrustedProxies() для этого не нужно.
Выход в продакшен
Выставьте APP_ENV=prod, поставьте зависимости без dev-части и прогрейте кеш до старта сервера. В проверке php bin/console cache:warmup поднимал приложение чисто, и он же уносит компиляцию контейнера из первого запроса:
composer install --no-dev --optimize-autoloader
APP_ENV=prod php bin/console cache:warmupЗаодно проверьте DEFAULT_URI. В скелете config/packages/routing.yaml задаёт router.default_uri как %env(DEFAULT_URI)% во всех окружениях, а в .env оно записано как http://localhost — именно из этого значения собираются URL, которые генерируются вне HTTP-запроса: в консольных командах, в письмах. Укажите там свой настоящий адрес.
Небольшой rapira.toml, чтобы всё это запустить:
[http]
listen = "127.0.0.1:8000"
[pool]
entrypoint = "worker.php"
processes = 4
max_requests = 500
request_terminate_timeout_secs = 30max_requests отправляет воркер на замену после такого числа запросов, чтобы медленная утечка где-нибудь в дереве зависимостей не могла расти бесконечно; это ограничивает утечку, а не устраняет её. request_terminate_timeout_secs ставит предел по реальному времени на один запрос, потому что иначе резидентный воркер будет бесконечно ждать внутри зависшего запроса. Запускается всё это через rapira serve --config rapira.toml. Каждый ключ отсюда, как и все остальные, описан в разделе Конфигурация; относительный entrypoint отсчитывается от каталога самого файла конфигурации.
Что сбрасывается между запросами
services_resetter вызывает reset() у каждого сервиса с тегом kernel.reset. Какие это сервисы — зависит от установленных бандлов: буферизованные обработчики логов, отладочные сборщики данных и прочие накопители на один запрос вешают тег сами, поэтому один вызов достаёт до всех них.
Чего он не покрывает, так это состояния, которое вы держите сами: статические свойства, запомненные глобальные значения, реестр, который какая-нибудь библиотека наполняет лениво, ini_set(), который вы так и не отменили. Всё это переживает запрос в любом резидентном воркере, и сбрасывать его должен ваш собственный код. Таблица того, что переживает запрос, а что нет, есть на странице Фреймворки.
Со сбросом на месте проверка увидела ровную резидентную память на 200 последовательных запросах — одинаково в dev и в prod: ядро держит постоянный рабочий набор, а не растёт от запроса к запросу. Если память в вашем приложении растёт, значит, за запросы цепляется либо ваш собственный код, либо какой-то бандл.
Работа после ответа
Если клиента нужно отпустить раньше, чем отработают слушатели после ответа, вызовите rapira_finish_request() между $response->send() и $kernel->terminate($request, $response): ответ уходит, а terminate() доигрывает на воркере, которого клиент уже не ждёт. Сам воркер при этом занят, пока ваш обработчик не вернёт управление, так что это средство против задержек, а не способ получить конкурентность.
Цикл разработки
rapira serve работает на переднем плане, а приложение поднимается один раз, поэтому изменённый PHP-код не подхватится, пока не сменятся воркеры. Пока вы активно правите код, проще всего перезапускать сервер — или запустить фронт-контроллер в классическом режиме, где скрипт каждый раз выполняется с нуля, а каждая правка видна сразу:
rapira serve --classic public/index.phpЭто то же самое приложение в классическом режиме: оно поднимается на каждом запросе, поэтому правки вступают в силу немедленно — ценой полного подъёма на каждый запрос. На работающем боевом сервере выложенный код подхватывает плавная перезагрузка (SIGUSR2 мастер-процессу), не разрывая соединений. Исключение — opcache.validate_timestamps = 0: сегмент OPcache принадлежит мастер-процессу и переживает пул, поэтому деплою понадобится полный перезапуск. Подробности — в разделах Модель процессов и Запуск в продакшене.
Непойманное исключение обрабатывается внутри Symfony: фреймворк отвечает на него своей 500 — полной страницей исключения в dev, обычной страницей ошибки в prod, — и следующий запрос берёт тот же самый процесс воркера, pid которого после сбоя не изменился. После исключения остаётся утёкшее или испорченное состояние сервисов, и его снимает сброс в конце обработчика. Куда попадёт трейс, зависит от вашего логгера, а в голом скелете его нет вовсе. В лог Rapira на stderr попадает то, что вырывается из самого PHP, например EnvNotFoundException выше. Как поднять уровень логирования, показывает Логирование.