Интеграция с фреймворками
В классическом режиме приложение на фреймворке работает на Rapira без изменений: вы направляете сервер на тот фронт-контроллер, который у вас уже есть. В режиме воркера процесс PHP остаётся живым между запросами, и то, что приложение может держать резидентным, зависит от устройства самого фреймворка. Эта страница описывает механику, одинаковую для любого фреймворка; три руководства по конкретным фреймворкам исходят из того, что вы её прочли, и разбирают только своё.
Проверено на
- PHP 8.5.8, NTS, embed SAPI.
- Rapira 0.6.0.
- Symfony 7.4.15 и 8.1.2, шаблон приложения Yii3 1.4 (yii-runner-http 3.2.1).
Всё, что написано на этой странице, получено запуском этих приложений на Linux с одним процессом воркера. Каждое утверждение ниже опирается на эти замеры.
Классический режим и режим воркера
В классическом режиме не меняется ничего. Входной скрипт — это ваш фронт-контроллер, Rapira выполняет его с нуля на каждый запрос, и здесь работает любой фреймворк, который работает под php-fpm, включая те, чьё состояние никогда бы не пережило второй запрос. Подробнее об этом рассказывает Классический режим; из разделов ниже к нему относятся только статические файлы, TLS и OPcache.
В режиме SAPI Worker процесс остаётся живым. Ваш скрипт один раз поднимает приложение и уходит в цикл, на каждой итерации запрашивая у Rapira следующий запрос. Фреймворк между запросами больше не разбирается. Где этот режим стоит среди четырёх, показывают Режимы выполнения, а справочник по его API — Режим воркера.
Одна и та же кодовая база работает в обоих режимах: оставьте public/index.php как есть и положите рядом worker.php. У проверенных приложений на Symfony и Yii3 оба файла лежат рядом, а какой из них запустится, решает флаг — rapira serve --classic public/index.php или rapira serve worker.php, — так что на время переезда классический режим остаётся под рукой как откат.
Цикл, строка за строкой
Скрипт воркера устроен одинаково, какой бы фреймворк внутри него ни сидел:
<?php
// worker.php
require __DIR__ . '/vendor/autoload.php';
use Rapira\Plugin\Http\HttpHandlerConfig;
use function Rapira\create_plugin_handler;
$http = create_plugin_handler(new HttpHandlerConfig());
$app = new App(); // booted once, reused for every request
$handler = static function () use ($app): void {
header('Content-Type: text/plain');
http_response_code(200);
echo $app->handle($_SERVER['REQUEST_URI']);
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}Разберём сверху вниз:
require .../vendor/autoload.php— автозагрузчик регистрируется один раз на всю жизнь воркера, и каждый разрешённый им класс остаётся загруженным.create_plugin_handler(new HttpHandlerConfig())— просит у Rapira обработчик; плагин выбирается по классу объекта конфигурации. В классическом режиме вызов бросает исключение: там нет резидентного цикла, которому можно было бы отдать обработчик.$app = new App();— здесь приложение поднимается один раз, ещё до начала цикла. Именно с этой строки расходятся два руководства по режиму воркера: Symfony держит здесь резидентное ядро, а Yii3 либо держит здесь резидентный раннер, либо создаёт его внутри обработчика; и у каждого руководства есть свой подъём до цикла и своя уборка на каждый запрос внутри обработчика.$handler = static function () use ($app): void— обработчик не принимает аргументов. Запрос лежит в суперглобальных переменных, а всё остальное, что ему нужно, он захватывает черезuse.header(),http_response_code(),echo— ответ вы формируете ровно так же, как в классическом скрипте. Как всё это превращается в байты на проводе, разбирает раздел HTTP.while ($http->handleRequest($handler))—handleRequest()блокируется до прихода запроса, заполняет под него суперглобальные переменные, вызывает ваш обработчик, закрывает запрос и возвращаетtrue. Когда сервер останавливается, она возвращаетfalse, — так цикл и заканчивается.gc_collect_cycles();— тело цикла выполняется между запросами, и это место для работы, которая должна происходить в предсказуемый момент, а не во время самого запроса. Вызов собирает обычные циклические ссылки и не решает проблему роста памяти, — см. Память и перезапуск воркеров.
Входной скрипт — worker.php, поэтому SCRIPT_NAME — это /worker.php, а DOCUMENT_ROOT — каталог, в котором он лежит, тогда как путь, запрошенный клиентом, приезжает в REQUEST_URI. И Symfony, и Yii3 поверх этого маршрутизировали запросы и строили URL правильно: в готовых URL никакого worker.php не появлялось, и править $_SERVER не пришлось нигде. Фреймворк, который собирает URL из SCRIPT_NAME, а не из REQUEST_URI, — первое, что стоит проверить.
Состояние запроса и резидентное состояние
Всё, что стоит в левой колонке, Rapira собирает заново на каждый запрос, поэтому обычный PHP-код, который это читает, продолжает работать. Всё, что в правой, живёт столько же, сколько сам воркер, и управлять этим должен скрипт воркера.
| Обновляется на каждом запросе | Переживает любой запрос |
|---|---|
$_GET, $_POST, $_SERVER, $_COOKIE — заполнены данными этого запроса | Автозагрузчик Composer и все классы, уже загруженные через него |
php://input — сырое тело этого запроса, а рядом CONTENT_TYPE и CONTENT_LENGTH | Свойства и переменные static — они продолжают считать сквозь запросы |
$_FILES и временные файлы загрузок за ним | Объекты, созданные до цикла: контейнер, ядро, ваше приложение |
Всё, что связано с сессией: session_start(), кука на входе, Set-Cookie на выходе | Открытые ресурсы: соединения с базой, клиенты кеша, потоки |
Состояние ответа: код статуса, заголовки, setcookie(), буферы вывода | Сам процесс — тот же pid, один резидентный интерпретатор PHP на воркер |
| Shutdown-функции, зарегистрированные внутри обработчика | Собственные счётчики воркера: handled и errors продолжают увеличиваться |
Таймер max_execution_time, взводимый заново на каждый запрос |
На Linux (и на FreeBSD), где у Zend есть таймер на запрос, таймер max_execution_time взводится заново для каждого запроса, а время, которое воркер простоял в ожидании следующего, ему никогда не засчитывается: на счётчике оказывается только сам запрос. На остальных платформах, включая macOS, таймаут на запрос не взводится вообще.
Три описанных ниже поведения — свойства резидентного PHP, а не Rapira. Все три проверены, и все три проявляются на подъёме приложения.
Деструктор резидентного объекта срабатывает в конце первого запроса
Напишите пользовательский __destruct у объекта, созданного вне цикла, — и он выполнится ровно один раз, в конце первого запроса, когда PHP на завершении запроса обходит хранилище объектов. С самим объектом при этом ничего не случится: он остаётся объектом, методы по-прежнему вызываются, а деструктор больше не срабатывает никогда — ни на следующих запросах, ни при остановке воркера.
Поэтому класс, который в деструкторе закрывает дескриптор, сбрасывает буфер или пишет в лог прощальную строку, сделает это один раз, в конце первого запроса, и больше никогда за всю жизнь процесса. Не оставляйте завершающие действия в деструкторах у того, что держите резидентным.
register_shutdown_function() на подъёме срабатывает один раз и больше никогда
Зарегистрированный вне обработчика колбэк выполнится в конце первого запроса, после чего будет освобождён: ни один следующий запрос его уже не вызовет. Зарегистрируйте его внутри обработчика — и он поведёт себя ровно так же, как под php-fpm: отработает в конце этого запроса, и так на каждом.
Если ваш bootstrap ставит shutdown-обработчик, чтобы сбросить метрики, поймать фатальную ошибку или что-то закрыть, регистрируйте его внутри обработчика, на каждой итерации цикла.
$_ENV молча переимпортируется посреди запроса
При стандартных настройках ini (variables_order = "GPCS", auto_globals_jit = On) PHP на каждом запросе заново взводит JIT-флаг для $_ENV. Первый же скомпилированный за этот запрос файл, в котором упомянут $_ENV, заставляет PHP пересобрать суперглобальную переменную, — а раз в variables_order нет E, импортировать нечего, и $_ENV возвращается пустым: всё, что записал туда на старте воркера bootstrap в духе Dotenv, пропадает посреди запроса, и PHP не выдаёт никакой диагностики.
Результат зависит от того, когда компилируется файл. Конфигурация, которую фреймворк вычислил жадно ещё на подъёме, уже закеширована и работает нормально, а всё, что вычисляется лениво, на первом запросе, читает $_ENV, опустошённый мгновением раньше. Ровно поэтому одно и то же приложение в одном окружении работает, а в другом отвечает 500 на каждый запрос.
Обходных путей два. Первый проверен: пусть bootstrap заодно пишет значения в настоящее окружение — putenv() переимпорт переживает, и фреймворк, который умеет откатиться на getenv(), их там найдёт. В продакшене предпочтительнее второй: задайте настоящие переменные окружения в unit-файле или в контейнере и перестаньте разбирать .env в рантайме. Ни тот ни другой ничего не возвращают в $_ENV — под GPCS он остаётся пустым, как бы вы окружение ни наполняли, а значения видит getenv(). Конкретный сбой и однострочное исправление разбирает руководство по Symfony.
С этим сталкивается любой рантайм PHP, который держит процесс живым между запросами.
Обработка ошибок
Три формы сбоя, и все три мы наблюдали на одном воркере, следя за его pid:
exitилиdieвнутри обработчика — ответ уходит клиенту вместе со статусом и тем телом, что успело накопиться, а воркер продолжает работать. Фреймворки делают так и в обычной работе — например, проверка режима обслуживания завершает запрос черезexit, — и для процесса это не смертельно.- Непойманное исключение —
500. Если его первым перехватит обработчик ошибок вашего фреймворка, тот нарисует свою страницу ошибки; если не перехватит никто, Rapira отвечает500с пустым телом. И в том и в другом случае воркер продолжает работать. - Непойманная
Error— например, вызов несуществующей функции. PHP пишет в логUncaught Error, а дальше путь тот же, что у любого другого непойманного throwable:500, и воркер продолжает работать под тем же pid.
На двух ошибочных формах у воркера растёт счётчик errors; запрос с exit — обычный 200, он двигает только handled. Во всех трёх случаях recycles и restarts остаются нулями: непойманный throwable не уносит воркер и не задевает следующий запрос. Больше делает только фатальная ошибка класса bailout: она сворачивает резидентный скрипт, поэтому воркер выполняет его заново с самого начала и снова поднимает ваше приложение, — это и считает recycles. Как прочитать эти счётчики из PHP, показывает getInfo() на странице Режим воркера.
Статические файлы
Rapira ничего не отдаёт с диска: нет ни поиска по document root, ни правила «если файл есть, отдай его». С каким бы URL ни пришёл запрос, выполняется ваш входной скрипт, а куда именно хотел попасть клиент, приложение узнаёт из $_SERVER['REQUEST_URI'] — одинаково в классическом режиме и в режиме воркера.
Поэтому статике нужно что-то впереди: сеть доставки контента или обратный прокси, настройку которого разбирает Запуск в продакшене. Иначе собранные JS и CSS, картинки и favicon — каждый из них отдельный запрос к PHP.
TLS и прокси
Слушающий сокет Rapira говорит по открытому HTTP, а раздела про TLS в конфигурации нет. Терминируйте TLS на том прокси, который у вас и так работает, и пустите его к Rapira через петлевой интерфейс или Unix-сокет. Проброшенные поля прокси должен писать через - и никогда через _: оба написания схлопываются в один и тот же ключ $_SERVER. Про это преобразование рассказывает раздел HTTP, а про настройку прокси — Запуск в продакшене.
Память и перезапуск воркеров
Воркер, который пересобирает приложение внутри обработчика — та из двух схем Yii3, что попроще, — держит резидентным меньше, чем ядро в стиле Symfony, но больше, чем классический режим, и цикл лежит в вашем собственном скрипте, поэтому работу можно по кусочку выносить из обработчика по мере того, как вы выясняете, что переживает второй запрос. Чего эта схема не даёт — это контейнера, уже собранного к приходу запроса.
В этой схеме каждый запрос оставляет после себя выброшенный граф объектов. По одному PHP их не освобождает: граф держится на циклических ссылках, поэтому куча растёт от запроса к запросу, пока не отработает сборщик циклов и не заберёт сразу большую партию. Это пилообразный профиль памяти, а не утечка, но его пики заметно выше следа любого отдельного запроса.
Ручной вызов gc_collect_cycles() её не сглаживает — проверено и в цикле, и внутри обработчика. Старые графы остаются под сильными ссылками, пока их не отпустит следующий подъём приложения, так что забирать сборщику пока нечего. Отсюда два вывода. Первый: дайте memory_limit настоящий запас, ведь уместиться должен пик, а не среднее. Второй: задайте воркеру квоту запросов:
[pool]
max_requests = 100Отработав столько запросов (плюс небольшой разброс, чтобы пул не обновлялся весь разом), воркер завершает работу, а мастер-процесс форкает ему замену, начинающую с чистой кучи. Проверено на сотнях последовательных запросов, прошедших через несколько таких смен: воркеры сменяются, память на каждом круге возвращается к исходной, и ни один запрос не был потерян или отвечен чем-то кроме 200. Это детерминированная граница для профиля памяти, который иначе целиком остаётся на усмотрение сборщика.
Резидентные схемы — ядро Symfony, контейнер Yii3 за StateResetter — на этом фоне ровные: на тех же прогонах память держалась на одном уровне. Перезапуск воркеров держите включённым и для них, как подстраховку. Сам ключ описан в Конфигурации, а что перезапуск делает с пулом, рассказывает Модель процессов.
OPcache и изменившийся код
Rapira запускает PHP ровно один раз, в мастер-процессе, ещё до того, как форкнет первого воркера, — поэтому OPcache создаёт свой сегмент разделяемой памяти единственный раз, и каждый воркер наследует то же самое отображение. Скомпилированные скрипты остаются горячими и между запросами, и сразу на весь пул, причём в обоих режимах. Воркер, который заново подключает файлы вашего фреймворка, заново их не разбирает.
В продакшене opcache.validate_timestamps = 0 убирает stat по каждому файлу на каждом запросе. Плата за это — кеш больше ничем не инвалидируется: сегмент принадлежит мастер-процессу и переживает любое поколение воркеров, поэтому плавная перезагрузка продолжит отдавать старые опкоды, и на деплое нужен полный перезапуск. Весь порядок действий расписан в разделе Запуск в продакшене.
При разработке тот же результат имеет другую причину. Резидентный подъём приложения никогда не перечитывает код, загруженный на старте, что бы там ни делал OPcache: правки в сервисе, который контейнер уже собрал, или в самом скрипте воркера до работающего процесса не доходят. Перезапускайте сервер после каждой правки — rapira serve работает на переднем плане и никогда не уходит в демоны, так что это Ctrl-C и запуск заново.
Руководства по фреймворкам
- Symfony — ядро поднимается один раз и остаётся резидентным, а собственный
services_resetterфреймворка между запросами возвращает сервисы с состоянием в то положение, в каком их застал. Один и тот же файл воркера, байт в байт, подходит и для 7.4, и для 8.1. - Laravel — классический режим: штатный
public/index.phpработает без изменений. Режим воркера для Laravel в разработке: резидентному приложению на Laravel нужен тот сброс состояния, который реализует Octane, а драйвера Octane у Rapira пока нет. - Yii3 — резидентный контейнер, который на каждом запросе сбрасывается через
StateResetter: именно так Yii3 сам задумывал работу в долгоживущих процессах, и его раннер для RoadRunner устроен точно так же. Есть и вариант попроще — новый раннер на каждый запрос, если начать хочется с него.
Фреймворк, которого нет ни в одном из этих руководств, запускается тем же самым скриптом воркера, а сможет ли он работать в режиме воркера, решает одно: обработает ли приложение второй запрос в том же процессе. Начинать стоит со схемы, в которой приложение пересобирается внутри обработчика, — она не требует от фреймворка ничего; следующая схема — резидентное приложение со сбросом состояния на каждом запросе. Если не работает ни одна из двух, Классический режим запустит приложение без изменений.