Skip to content

Запуск в продакшене

Запуск Rapira на сервере требует того, без чего локальная команда rapira serve app/worker.php обходится: старта при загрузке машины, возврата после падения, перезагрузки нового кода без потери запросов и логов, которые потом можно прочитать. Эта страница описывает юнит systemd, место для конфигурации, прокси перед сервером и настройки, которые ставят ограничения долгоживущим воркерам.

Почти ничего из этого в бинарник не вшито. Ничто в Rapira не зависит от того, где лежит ваша конфигурация и что управляет процессом, так что раскладка ниже — соглашение, которое вводит эта страница и на которое опирается остальная документация. Сначала доставьте бинарник на машину, об этом рассказывает Установка.

Юнит systemd

Rapira встаёт на место php-fpm, а за пулом уже присматривает её мастер-процесс: он форкает воркеры, дожидается завершившихся, поднимает замену с нарастающей паузой, обновляет отработавшие своё и масштабирует пул. У systemd остаётся одна задача — следить, чтобы этот единственный мастер-процесс был жив, поэтому отдельному менеджеру процессов вроде supervisord тут делать нечего.

Пакеты .deb и .rpm кладут на машину бинарник и встроенный в него рантайм PHP, и больше ничего: ни юнита службы, ни php.ini (точный список файлов есть в разделе Установка). И то и другое относится к политике конкретной установки, а пакет, который принёс бы их с собой, при каждом обновлении затирал бы ваши правки.

Напишите юнит сами, в файле /etc/systemd/system/rapira.service:

ini
[Unit]
Description=Rapira PHP application server
After=network.target

[Service]
Type=exec
WorkingDirectory=/srv/app
ExecStart=/usr/bin/rapira serve --config /etc/rapira/rapira.toml
ExecReload=/bin/kill -USR2 $MAINPID
KillMode=mixed
Restart=on-failure
RuntimeDirectory=rapira
Environment=PHPRC=/etc/rapira

[Install]
WantedBy=multi-user.target

Дальше остаётся перечитать юниты и включить службу:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now rapira

Шесть строк в нём требуют пояснения:

  • Type=exec — Rapira работает на переднем плане и никогда не уходит в фон сама. Режима демона нет, и он не нужен: процесс, который запускает systemd, и есть мастер-процесс, поэтому в $MAINPID лежит ровно тот pid, которому вы собираетесь слать сигналы.
  • ExecReload — превращает systemctl reload rapira в SIGUSR2 мастер-процессу, то есть в ту самую перезагрузку без простоя, о которой ниже.
  • KillMode=mixed — по умолчанию systemd отправляет сигнал остановки всем процессам в cgroup, а воркер понимает SIGTERM как команду умереть немедленно. С mixed сигнал получает только мастер-процесс, а он уже запускает мягкое завершение через SIGQUIT, описанное ниже; SIGKILL по истечении TimeoutStopSec при этом по-прежнему достаётся всей группе. Без этой строки systemctl stop и systemctl restart обрывают запросы на полпути.
  • Restart=on-failure — при штатной остановке процесс завершается с нулевым кодом и остаётся выключенным, поэтому эта строка поднимает сервер только после падения или неудачного старта.
  • RuntimeDirectory=rapira — systemd создаёт /run/rapira при старте и убирает при остановке. Именно там лежат pid-файл и Unix-сокет из примеров ниже.
  • Environment=PHPRC — здесь PHP ищет свой php.ini, подробности в следующем разделе.

Запуск не от root

Добавьте в блок [Service] строки User= и Group=: systemd передаст RuntimeDirectory этой учётной записи, и pid-файл вместе с Unix-сокетом внутри /run/rapira/ продолжат работать. А вот пути за её пределами, вроде /run/rapira.pid, лежат в каталоге, который принадлежит root, и открыть их не выйдет.

Два приложения на одной машине требуют двух конфигураций, двух юнитов и двух адресов прослушивания; для этого возьмите шаблонный юнит systemd (rapira@.service). Каждый экземпляр поднимает свой PHP и свой пул воркеров и не делит с другим экземпляром ничего, кроме машины.

Где лежит конфигурация

Соглашение такое: настройки самой Rapira — в /etc/rapira/rapira.toml, а рядом лежит php.ini, который PHP находит через PHPRC=/etc/rapira. Ни один из этих путей не зашит в бинарник. --config принимает любой путь, а PHPRC вообще не изобретение Rapira: в поиск ini-файлов Rapira не вмешивается, поэтому PHP сначала заглядывает в $PHPRC ровно так же, как под любым другим SAPI. Если ваш дистрибутив или ваша Ansible-роль использует другие пути, перенесите оба.

Rapira работает и вовсе без php.ini: её встроенные ini-умолчания уводят диагностику PHP в лог, а не в ответы клиенту, — об этом рассказывает Логирование. Свой файл в /etc/rapira пишите тогда, когда захотите настроить OPcache, ограничить память или задать часовой пояс; всё, что вы в нём зададите, окажется сильнее.

Относительный pool.entrypoint отсчитывается от каталога самого файла конфигурации, а не от рабочего каталога. При раскладке выше entrypoint = "index.php" означал бы /etc/rapira/index.php, где вашего приложения точно нет. В продакшене задавайте входному скрипту абсолютный путь, и вопрос отпадёт сам собой. supervisor.pidfile живёт по тому же правилу: оба пути из конфигурации отсчитываются от каталога с самим файлом конфигурации. А вот от рабочего каталога отсчитываются позиционный аргумент SCRIPT и любой относительный путь, который ваш PHP-код открывает уже во время работы, — сама же Rapira каталог никогда не меняет: без WorkingDirectory= systemd запустит службу в /, как раз поэтому в юните выше эта строка есть (в свой поиск ini-файлов PHP включает и ., так что заглянет он туда же). Каждый ключ со своим значением по умолчанию описан в разделе Конфигурация.

За обратным прокси

Rapira слушает открытый HTTP, и раздела про TLS в конфигурации нет. Терминируйте TLS на том прокси, который у вас и так работает — nginx, Caddy, HAProxy, облачный балансировщик, — а до Rapira пусть подключается через петлевой интерфейс или Unix-сокет. Привязаться к публичному интерфейсу вы можете, но и там этот слушатель отдаёт открытый HTTP.

toml
[http]
listen = "127.0.0.1:8000"
# listen = "unix:/run/rapira/rapira.sock"

Unix-сокет создаётся с правами 0666, поэтому любой локальный процесс, которому доступен каталог с сокетом, может подключиться и слать запросы вашему приложению. Настройки для этих прав у Rapira нет, так что доступ к сокету ограничивают только права самого каталога. Если для вас это важно, ограничьте сам каталог: в юните выше RuntimeDirectoryMode=0750 и Group=, в которую входит пользователь прокси, закроют /run/rapira для всех остальных.

Служебные поля должны доходить до Rapira в обычном написании через дефис — X-Forwarded-For, а не X_Forwarded_For. Написания с подчёркиванием и точкой схлопываются в тот же ключ $_SERVER, что и правильное, а значит, клиент мог бы переписать то, что только что выставил ваш прокси, — поэтому Rapira удаляет такие поля до того, как их увидит PHP. Само преобразование имён и настройку http.unsafe_field_names, которая им управляет, разбирает раздел HTTP.

Деплой без простоя

Выложите новый код и выполните:

bash
sudo systemctl reload rapira

Это SIGUSR2 мастер-процессу, а тот отвечает плавной перезагрузкой: пул обновляется по одному воркеру, начатые запросы доводятся до конца — и ничего не теряется, пока воркер укладывается в process_control_timeout_secs. Воркер, который не уложился, получает SIGTERM, затем SIGKILL, и его текущий запрос теряется (об этом ниже). Как именно свежий воркер перекрывается со старым, рассказывает Модель процессов.

Без systemd — из entrypoint контейнера или из скрипта деплоя — шлите сигнал мастер-процессу сами. Задайте supervisor.pidfile, и pid всегда будет под рукой. Учтите только, что за пределами systemd каталог /run/rapira никто не создаёт: сделайте его заранее или выберите существующий путь, иначе мастер-процесс просто откажется стартовать, не сумев записать файл.

toml
[supervisor]
pidfile = "/run/rapira/rapira.pid"
process_control_timeout_secs = 30
bash
kill -USR2 "$(cat /run/rapira/rapira.pid)"

Пишет в этот файл только мастер-процесс, воркеры к нему не прикасаются, и он же удаляет файл на каждом пути завершения, который контролирует. Поэтому оставшийся pid-файл означает, что мастер умер, не выполнив собственную остановку: SIGKILL, жёсткое падение или выключение машины.

process_control_timeout_secs — время, которое мастер-процесс даёт воркеру на завершение, прежде чем перейти к более жёстким мерам. Он же ограничивает каждый шаг плавной перезагрузки, так что один зависший воркер не застопорит её целиком: порядок ужесточения и полная таблица сигналов есть в разделе Модель процессов. Держите это значение с хорошим запасом ниже TimeoutStopSec в systemd, иначе таймаут systemd истечёт первым и он убьёт мастер-процесс прямо посреди этой последовательности.

Чего перезагрузка не делает

Мастер-процесс живёт с теми настройками, с которыми стартовал, и разделяемая память OPcache принадлежит тоже ему, поэтому она переживает любое поколение воркеров. Изменили rapira.toml — нужен systemctl restart rapira. И если у вас выставлено opcache.validate_timestamps = 0, после перезагрузки сервер продолжит отдавать старые опкоды: тут тоже нужен перезапуск.

Логи

Каждую запись лога Rapira пишет в stderr, ровно одной операцией записи, поэтому вывод мастер-процесса и воркеров никогда не перемешивается посреди строки. У юнита systemd stderr попадает в журнал вообще без настройки, так что выбрать остаётся только формат. В продакшене используйте JSON:

toml
[log]
level = "info"
format = "json"

Один объект на строку: timestamp в UTC по RFC 3339, а рядом level, message и target. Переводы строк внутри сообщения экранируются, так что запись всегда занимает ровно одну строку. Именно в таком виде логи ждут сборщики, и journald пропускает их через себя без изменений.

bash
journalctl -u rapira -f

Чтобы увезти логи с машины, направьте сборщик на журнал этого юнита или, если journald вам не нужен, запустите Rapira так, чтобы её stderr уходил прямо в агент. В обоих случаях запись уже структурирована, поэтому сборщику не придётся разбирать её регулярными выражениями. Про уровни для отдельных целей и про RUST_LOG, который на одну отладочную сессию заменяет весь фильтр целиком, рассказывает Логирование.

Обновление воркеров и таймауты запросов

В режиме воркера процесс остаётся резидентным, поэтому медленная утечка, незаметная под php-fpm, копится от запроса к запросу. От этого защищают две настройки:

toml
[pool]
max_requests = 500
request_terminate_timeout_secs = 30

max_requests завершает воркер после заданного числа запросов и форкает на его место свежий, добавляя к порогу небольшой разброс, чтобы весь пул не пошёл обновляться одновременно. Утечку это не лечит, зато не даёт ненайденной утечке превратиться в аварию. request_terminate_timeout_secs — потолок по реальному времени на один запрос: воркер, который в него не уложился, убивают и поднимают заново, так что один застрявший запрос не занимает воркер навсегда. По умолчанию выключены оба; включите их до выхода в продакшен.

Остальное про пул рассказывает Модель процессов: размеры пула в режимах static, dynamic и ondemand, нарастающие паузы перед повторным запуском и то, что делает мастер-процесс, когда воркер умирает.