«Перейдіть на Octane і буде вдвічі швидше» — порада, яка нічого не варта без відповіді на два питання: скільки у вашому запиті займає бутстрап фреймворку і чи витримає ваш код життя між запитами. Перше вимірюється профайлером за годину. Друге — це аудит, який може з'їсти тижні. Нижче — те, що випливає з самої архітектури трьох моделей: межі паралелізму, поведінка стану, деплой. Цифр з голови не буде: будь-яке порівняння швидкості має сенс тільки на вашому коді й вашому залізі.
Три моделі виконання
PHP-FPM. Майстер-процес тримає пул воркерів, кожен воркер обробляє один запит від початку до кінця, після чого інтерпретатор чистить усе, що запит нажив: змінні, статичні властивості класів, відкриті з'єднання (крім PDO::ATTR_PERSISTENT), зареєстровані shutdown-функції. Наступний запит стартує з чистого аркуша й наново бутстрапить фреймворк. Це модель shared-nothing: між запитами не переживає нічого, крім опкодів у shared memory OPcache. Паралелізм задається pm.max_children — це жорстка стеля одночасних запитів; коли всі воркери зайняті, решта чекає в черзі бекенду.
FrankenPHP. Це вебсервер Caddy зі вбудованим PHP (потрібна ZTS-збірка), тобто один бінарник замість зв'язки nginx + php-fpm. У класичному режимі він поводиться так само, як FPM: запит — повний бутстрап — очищення. Виграш тут не в моделі виконання, а в інфраструктурі: HTTP/2 і HTTP/3, автоматичний TLS, роздача статики самим сервером, 103 Early Hints. Окремо є worker mode: PHP-скрипт бутстрапить застосунок один раз і далі крутиться в циклі, приймаючи запити. Виглядає це так:
// public/frankenphp-worker.php
$handler = static function (): void {
// тіло запиту: сюди FrankenPHP передає керування
};
while (frankenphp_handle_request($handler)) {
gc_collect_cycles();
}
Laravel Octane. Octane — не сервер. Це супервізор і адаптер, який запускає ваш застосунок під одним із бекендів (FrankenPHP, RoadRunner, Swoole) і бере на себе те, чого голий воркер-скрипт не робить: конфіг warm/flush для контейнера, події RequestReceived / RequestTerminated, --max-requests, octane:reload. Тобто Octane — це worker mode плюс набір інструментів для боротьби з наслідками worker mode. Для Symfony аналога Octane немає й не потрібно: там ту саму роль виконує компонент Runtime — composer require runtime/frankenphp-symfony і APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime в .env, після чого public/index.php лишається без змін, а перезбирання стану бере на себе рантайм.
Що ламається, коли застосунок живе між запитами
Перехід у worker mode — це не налаштування, а зміна контракту. Усе, що раніше гарантовано вмирало наприкінці запиту, тепер живе, поки живий процес.
Статичний і синглтонний стан. Класика — мемоізація без ключа життєвого циклу:
final class TenantSettings
{
/** @var array<int, Settings> */
private static array $cache = []; // росте, поки живий воркер
}
У FPM цей масив ніколи не перевищить одного орендаря. У worker mode він накопичує всіх, кого обслужив процес, — це не «витік» у сенсі забутих посилань, це нескінченний кеш, який ніхто не обмежив.
Сервіси, які тримають запит. Найгірший клас багів — не памʼять, а неправильні дані:
final class AuditLogger
{
public function __construct(private readonly Request $request) {}
}
Якщо це синглтон, він отримає Request першого запиту і носитиме його IP і користувача далі. Документація Octane прямо забороняє інжектити контейнер або Request у синглтони; замість цього беріть їх у момент виклику методу. У config/octane.php для решти випадків є масив flush — сервіси, які викидаються з контейнера після кожного запиту, і warm — ті, що резолвляться на старті воркера.
У Symfony це вирішено на рівні контейнера: сервіс зі станом реалізує Symfony\Contracts\Service\ResetInterface, автоконфігурація вішає на нього тег kernel.reset, і рантайм викликає reset() між запитами.
final class TenantContext implements ResetInterface
{
private ?Tenant $tenant = null;
public function reset(): void
{
$this->tenant = null;
}
}
Глобальні побічні ефекти. putenv(), setlocale(), date_default_timezone_set(), ini_set(), Carbon::setTestNow() — усе це процесне, а не запитове. Виклик у середині одного запиту змінює поведінку наступних.
Ресурси й дескриптори. З'єднання з БД тепер довгоживуче: воно впаде за wait_timeout на стороні сервера, і ви отримаєте «server has gone away» на випадковому запиті замість передбачуваного перепідключення. Хендл файлу лога, відкритий один раз, після ротації писатиме у видалений inode. Doctrine EntityManager після винятку закривається назавжди — у shared-nothing це коштувало одного запиту, у worker mode це коштує всіх наступних запитів того воркера, доки його не перезапустять.
Портативність API. fastcgi_finish_request() існує лише у FPM. Код, який на нього спирається, поза FPM просто перестане віддавати відповідь раніше — обгортайте його в function_exists() або замінюйте на terminable middleware чи defer().
Обробка фаталів. У FPM фатальна помилка вбиває один запит. У worker mode вона вбиває воркер: Octane і FrankenPHP його підіймуть, але всі, хто чекав на цей процес, отримають розрив. Тому --max-requests — не косметика, а страховка: перезапуск воркера кожні N запитів обмежує накопичення будь-яких витоків, про які ви ще не знаєте.
Хороший маркер готовності: якщо у вас уже є queue:work, який доводиться перезапускати частіше, ніж «раз на деплой», ті самі проблеми чекають на вас у веб-шарі — просто з користувачами замість джоб.
Памʼять і паралелізм: арифметика до міграції
Дві формули, які корисно порахувати ще до вибору.
Стеля одночасних запитів у FPM — pm.max_children, у worker mode — кількість воркерів. В обох випадках процес блокується на I/O: якщо запит чекає три секунди на зовнішній API, воркер увесь цей час не приймає нічого іншого. Виняток — Swoole з корутинами, але це окрема модель зі своїм списком несумісностей. Тому «зменшимо кількість процесів, бо кожен став швидшим» — небезпечний хід для застосунку, який багато чекає на мережу: латентність відповіді впаде, а стеля одночасності разом із нею.
Памʼять: у FPM ліміт memory_limit застосовується до одного запиту, і після нього все звільняється. У worker mode memory_limit — це стеля для процесу за весь час його життя, і будь-який неочищений кеш з'їдає її поступово. Тому бюджет рахується не як «пік одного запиту», а як «пік одного запиту плюс усе, що воркер накопичив від старту».
Деплой і перезапуск воркерів
У FPM код підхоплюється тим, як налаштований OPcache. Якщо opcache.validate_timestamps=0 (а в контейнері це правильне значення), новий код не з'явиться сам: потрібен SIGUSR2 майстру для graceful reload або новий контейнер. Класична пастка деплою через symlink — старий realpath-кеш і старі опкоди після перемикання посилання; лікується тим самим reload.
У worker mode до цього додається бутстрап. Перезапуск воркера означає повторний бутстрап фреймворку, тобто короткий провал пропускної здатності саме в момент релізу. Octane робить це керовано: php artisan octane:reload дає воркерам догасити поточні запити й піднімає нові. FrankenPHP без Octane перезапускає воркерів на caddy reload; директива watch у блоці worker призначена для локальної розробки — у продакшені вона не потрібна, бо код в образі не змінюється.
Три наслідки, про які згадують уже після першого інциденту:
- Зміни в
.envі конфігах не підхоплюються. У worker mode конфіг прочитаний один раз при старті. Будь-яка зміна змінної оточення вимагає перезапуску воркерів, а не тількиconfig:cache. - Міграції мають бути сумісні в обидва боки. Під час викочування старі й нові воркери працюють одночасно — схема повинна влаштовувати обидві версії коду. Це справедливо і для FPM, але там вікно перекриття вимірюється тривалістю запиту, а не тривалістю життя процесу.
- SIGTERM треба обробити. Воркер має догасити поточний запит, а не обірватися. У Kubernetes це
terminationGracePeriodSeconds, більший за ваш найдовший запит, плюс readiness-проба, яка виводить под із балансування до зупинки.
Як обирати: чекліст
Пройдіть по пунктах згори вниз і зупиніться на першому, що описує вашу ситуацію.
- Ви не знаєте, скільки часу займає бутстрап. Зніміть профіль (Excimer, APM, будь-що семплінгове) на реальному ендпоінті. Якщо бутстрап не в топі — жодна зміна моделі виконання вам не допоможе, ідіть до запитів у БД і зовнішніх викликів. Це найчастіший результат.
- У кодовій базі є код, який ви не контролюєте — старі пакети, спадщина з глобальними змінними, статичні реєстри без очищення, і немає тестів, щоб це перевірити. Лишайтесь на shared-nothing. FPM або FrankenPHP у класичному режимі — і сон спокійніший.
- Вам болить інфраструктура, а не бутстрап: хочеться HTTP/3, автоматичного TLS, роздачі статики й одного бінарника замість двох сервісів. FrankenPHP без worker mode. Модель виконання не змінюється, аудит стану не потрібен.
- Бутстрап справді дорогий, кодова база під контролем, є тести. Тоді worker mode. Для Laravel — Octane з драйвером FrankenPHP: ви отримуєте
flush/warm, події життєвого циклу,octane:reloadі--max-requestsз коробки. Для Symfony —runtime/frankenphp-symfonyплюс ревізія сервісів наResetInterface. - Ваш профіль навантаження — довге очікування на зовнішні сервіси. Спершу порахуйте стелю одночасності за кількістю воркерів; можливо, вам потрібні не швидші запити, а винесення викликів у чергу.
- Перед вмиканням worker mode на проді пройдіть мінімальний аудит: пошук
staticвластивостей-кешів, синглтонів ізRequest/Auth/користувачем у конструкторі, викликівputenv/setlocale/ini_setу рантаймі,fastcgi_finish_request(), довгоживучих з'єднань і файлових хендлів. Виставте--max-requests, увімкніть моніторинг RSS процесів і викочуйте на частину трафіку.
І окремо про порівняння цифр: публічні бенчмарки моделей виконання майже завжди міряють синтетичний ендпоінт, де бутстрап — це 100% роботи. У застосунку з базою й зовнішніми викликами частка бутстрапу інша, тому переносити ці співвідношення на свій продукт не можна. Єдина цифра, яка щось означає, — знята на вашому коді до і після, за однакових умов.