Огляд архітектури

OxPHP — це HTTP-сервер в одному бінарному файлі, який замінює традиційний стек nginx + PHP-FPM. Він розбирає HTTP, завершує TLS, виконує маршрутизацію, запускає PHP, стискає відповіді та забезпечує спостережуваність в одному процесі, без жодних зовнішніх залежностей під час роботи.

Як працює OxPHP

OxPHP поєднує в одному процесі два рівні виконання та необовʼязковий асинхронний пул:

  1. Асинхронний HTTP-рівень. Подієво-орієнтований мережевий рівень приймає TCP-зʼєднання, виконує TLS-рукостискання, розбирає HTTP-запити та надсилає відповіді. Завдяки неблокувальному введенню-виведенню він обслуговує тисячі одночасних зʼєднань, тож один повільний клієнт ніколи не блокує іншого.
  2. Пул PHP-воркерів. Пул виділених PHP-воркерів виконує ваші PHP-скрипти. У стандартному режимі кожен воркер обробляє один запит за раз. У режимі воркерів з увімкненим мультиплексуванням файберів один воркер може обслуговувати кілька одночасних запитів: коли скрипт викликає oxphp_sleep() або oxphp_async_await(), файбер звільняє потік, і воркер перемикається на наступний запит.

Два рівні взаємодіють через обмежену чергу. Коли надходить HTTP-запит, який потребує виконання PHP, асинхронний рівень ставить його в чергу. Вільний PHP-воркер забирає його, виконує скрипт і повертає відповідь асинхронному рівню, який доставляє її клієнтові.

Асинхронний пул необовʼязковий. Він виконує завдання, передані через oxphp_async(), в окремих потоках ОС із власною чергою завдань і вмикається значенням ASYNC_WORKERS > 0. Він ізольований від пулу воркерів, тож фонові завдання не блокують обробку HTTP-запитів.

Завдяки такому розподілу мережеве введення-виведення (прийом зʼєднань, читання заголовків, стиснення відповідей, віддача статичних файлів) ніколи не конкурує за ресурси з виконанням PHP. Кожен рівень масштабується незалежно.

Пул воркерів

Пул PHP-воркерів визначає, скільки PHP-скриптів може виконуватися одночасно. OxPHP підтримує два режими пулу.

Статичний пул

Фіксована кількість воркерів запускається під час старту й працює весь час існування сервера. Це режим за замовчуванням.

bash
PHP_WORKERS=8 # exactly 8 workers PHP_WORKERS=0 # auto-detect (default): half of available CPU cores, minimum 1

Динамічний пул

Кількість воркерів зростає та зменшується відповідно до навантаження. Вкажіть мінімальну й максимальну кількість через двокрапку:

bash
PHP_WORKERS=2:16 # start with 2, scale up to 16 under load

Коли всі наявні воркери зайняті, OxPHP запускає нові, аж до максимуму. Коли воркер простоює довше за PHP_WORKERS_IDLE_SECONDS (за замовчуванням 30 секунд), його виводять з роботи, повертаючись до мінімуму.

Виведення відбувається в момент, коли воркер нічого не обробляє, тож запит, який він іще обслуговує, завжди доходить до власної відповіді. Те саме правило задає й межу: воркер, що утримує запит, який ніколи не завершується (відкритий потік, читання від співрозмовника, який перестав відповідати), такого моменту не досягає і не виводиться з роботи, доки цей запит утримує.

Черга і протитиск

Між асинхронним HTTP-рівнем і пулом воркерів розташована обмежена черга. Її місткість за замовчуванням дорівнює початковій кількості воркерів, помноженій на 128, і її можна перевизначити через QUEUE_CAPACITY. Для статичного пулу початкова кількість — це налаштована кількість воркерів. Для динамічного пулу (MIN:MAX) початкова кількість — це мінімум.

Запит, що надходить до заповненої черги, не відхиляється одразу. Він чекає на вільне місце до QUEUE_WAIT_TIMEOUT_MS (за замовчуванням 1000 мс) і потрапляє в чергу, щойно воркер забере запит, що стоїть перед ним. Це налаштування задає максимальне очікування, а не фіксовану тривалість. Сервер скорочує бюджет, поки робота, допущена з очікування, завершується для клієнтів, які вже відʼєдналися, і відновлює його, коли черга спорожніє, тож під перевантаженням запит, що надходить, отримує вже скорочений дедлайн. Запити, що очікують, допускаються в порядку надходження. Бюджет — це єдиний дедлайн, проставлений під час надходження, і він супроводжує запит у черзі: коли дедлайн минає, на запит відповідають кодом 529, а воркер, що забирає запит із простроченим дедлайном, відмовляє в ньому замість виконання. Якщо клієнт на той момент уже закрив зʼєднання, відповіддю буде 499, бо відмову вже нікому отримати, а якби її рахували як 529, ряд метрик перевантаження вимірював би, скільки клієнти були готові чекати. Отже, бюджет обмежує все очікування: допуск і час у черзі. Це важливо, бо черга достатньо глибока, щоб на повільному пулі дістатися її кінця можна було значно довше, ніж будь-який бюджет, який задав би оператор, і бо в черзі, з якої жоден воркер не забирає запити, немає моменту підбору, у який можна було б застосувати дедлайн. Навантаження скидається за часом очікування, що минув, а не за глибиною черги в момент надходження запиту: сплеск, що розсмоктується за мікросекунди, обслуговується, а пул, який справді не встигає, однаково скидає навантаження.

Бюджет обмежує лише очікування, а не виконання. Щойно воркер забрав запит до настання дедлайну, бюджет не перериває його, хоч би скільки потім працював обробник, бо перевірка стосується очікування, а не загального часу. Час відповіді під навантаженням — це очікування плюс виконання, і бюджет обмежує лише очікування. Зате обмежує його повністю: запит, який усе ще очікує, коли минає його дедлайн, отримує відповідь саме тоді, незалежно від того, чекає він на місце в черзі чи вже стоїть у черзі в очікуванні воркера.

QUEUE_MAX_WAITING обмежує кількість запитів, що можуть очікувати одночасно (за замовчуванням початкова кількість воркерів × 128, але не більше половини MAX_CONNECTIONS); після цієї межі допуск знову відхиляє запити одразу. Очікування не безкоштовне. Запит, що очікує, утримує своє зʼєднання та вже буферизоване тіло, доки його не допустять або не вичерпається бюджет, тож необмежена множина очікувальних запитів за тривалого перевантаження зайняла б усі дозволи на зʼєднання, заблокувала б цикл прийому, і сервер відкидав би нові зʼєднання замість відповідати на них. Значення за замовчуванням приблизно відповідає тому, скільки запитів пул справді може допустити в межах бюджету: очікування понад це лише відкладає відмову, утримуючи ресурси. Стеля MAX_CONNECTIONS / 2 обмежує лише множину очікувальних запитів, і сама по собі не є запасом для циклу прийому: запит у черзі утримує своє зʼєднання, доки до нього не дійде воркер або не вичерпається бюджет, запит, що виконується, теж його утримує (його місце в черзі звільнилося під час підбору), а QUEUE_CAPACITY взагалі не має стелі, повʼязаної зі зʼєднаннями. Тому сума PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING має залишатися меншою за MAX_CONNECTIONS. Щойно сума досягає бюджету, цикл прийому зупиняється, коли всі дозволи зайняті, і клієнт, що приходить у цей момент, не отримує жодної відповіді замість 529. Сервер попереджає про це під час старту, а також повідомляє про саму зупинку, поки вона триває: WARN, коли цикл зупиняється, і INFO з тривалістю, коли він відновлюється (з обмеженням частоти, щоб сервер, який раз у раз упирається в стелю, не засипав власний журнал), а також gauge oxphp_accept_stalled і лічильник oxphp_accept_stalls_total. Обидва оновлюються за кожної зупинки, зокрема й тих, які пригнічує обмеження частоти журналу, і віддаються внутрішнім слухачем, поза цим бюджетом; див. довідник метрик. Позбутися попередження під час старту необхідно, але недостатньо: зʼєднання, які ніколи не доходять до PHP, теж утримують дозволи, і перевірка під час старту не може передбачити зупинку, яку вони спричинять. Зауважте, що доданки розраховано у зʼєднаннях, а бюджет витрачають запити: у HTTP/2 одне зʼєднання несе багато запитів, тож розгортання з переважно h2-трафіком досягає стелі, використавши лише частку свого бюджету зʼєднань, має явно задати QUEUE_MAX_WAITING і може цілком законно перевищувати цю суму.

Множина очікувальних запитів обмежена двічі, бо межа, рахована в запитах, нічого не каже про памʼять, яку ці запити утримують: та сама кількість очікувальних нічого не коштує для GET без тіла і коштує гігабайти для завантажень. QUEUE_MAX_WAITING_BYTES (за замовчуванням 64 MiB) обмежує обсяг тіл запитів, що одночасно очікують. Після цієї межі запит із тілом відхиляється одразу, а не ставиться в очікування, тоді як запити без тіла чекають як звичайно. Дві речі воно не охоплює, і обидві існували ще до бюджету очікування: тіла, уже передані в чергу, які QUEUE_CAPACITY обмежує в запитах, але ніщо не обмежує в байтах, і памʼять, яку займає тіло, поки його ще читають зі зʼєднання, — її не обмежує жоден сукупний ліміт.

OxPHP повертає відповідь 529 Site is Overloaded із заголовком Retry-After, коли запит неможливо обслужити, а не просто відкласти: вичерпався бюджет очікування або одна з наведених вище меж відмовила йому в місці для очікування. Код стану 529 (нестандартний, його використовують Cloudflare та інші) чітко відрізняє перевантаження від помилок застосунку (500) і технічного обслуговування (503), тож сповіщення й балансувальники навантаження легше налаштувати. Значення QUEUE_WAIT_TIMEOUT_MS=0 вимикає очікування: запит відхиляється в ту ж мить, коли черга заповнена.

Оскільки запити, що очікують, займають зʼєднання, а не місце в черзі, межу паралельності під перевантаженням задає MAX_CONNECTIONS, а не QUEUE_CAPACITY. У HTTP/2 вона дорівнює MAX_CONNECTIONS, помноженому на H2_MAX_CONCURRENT_STREAMS, бо кожен потік несе власний запит. Обираючи бюджет очікування, зважайте на це: коли запит доходить до черги, його тіло вже буферизоване, тож довший бюджет означає, що справді перевантажений сервер пропорційно довше тримає в памʼяті більше запитів разом із їхніми тілами, перш ніж відповісти.

Шлях запиту

Кожен запит проходить через той самий конвеєр, незалежно від того, чи віддає він статичний файл, чи виконує PHP:

graph TD
  Client(["Client"]) --> TLS["TLS termination<br/>(if configured)"]
  TLS --> Parse["HTTP parsing + Request ID"]
  Parse --> Proxy["Trusted proxy resolution<br/>(if TRUSTED_PROXIES set)"]
  Proxy --> Rate["Rate limiting check"]
  Rate --> Route{"Route resolution"}
  Route -->|Static file| Cache["File cache / disk read"]
  Cache --> Compress["Compression + Response headers"]
  Route -->|PHP request| Queue["Bounded queue<br/>(529 past the wait budget)"]
  Queue --> Worker["PHP worker executes script"]
  Worker --> Normal["Normal response"]
  Worker --> SSE["SSE streaming (chunked)"]
  Worker --> Early["Early response (finish_request)<br/>+ background work"]
  Compress --> Deliver(["Response to client"])
  Normal --> Deliver
  SSE --> Deliver
  Early --> Deliver
  1. Завершення TLS. Якщо налаштовано TLS_CERT і TLS_KEY, OxPHP обробляє TLS самостійно. Окремий зворотний проксі не потрібен.
  2. Розбір HTTP та ідентифікатор запиту. Запит розбирається, і для нього генерується унікальний ідентифікатор (або зберігається вхідний заголовок X-Request-ID).
  3. Визначення довіреного проксі. Якщо задано TRUSTED_PROXIES і IP, з якого прийшло зʼєднання, є довіреним, OxPHP отримує справжню IP-адресу клієнта, протокол і хост із заголовків Forwarded (RFC 7239) або X-Forwarded-*. З ключовим словом cloudflare запит, ланцюжок пересилання якого показує, що він надійшов з edge-вузла Cloudflare, натомість бере IP-адресу клієнта із CF-Connecting-IP. Визначена IP-адреса використовується на всіх наступних кроках, зокрема для обмеження частоти запитів і журналу доступу. Див. Довірені проксі.
  4. Обмеження частоти запитів. Якщо задано RATE_LIMIT, IP-адреса клієнта перевіряється за лічильником запитів для кожної IP. Запити понад ліміт одразу отримують відповідь 429 Too Many Requests.
  5. Визначення маршруту. URL зіставляється з налаштованим режимом маршрутизації (традиційним, фреймворковим, SPA або режимом воркерів). Результатом є статичний файл, PHP-скрипт або 404. Режим воркерів — це окремий режим маршрутизації, що заступає три інші: йому потрібен ENTRY_FILE з розширенням .php, і до цього скрипта він надсилає кожен запит, який не обслуговується як статичний файл (див. Маршрутизація).
  6. Статичні файли. Віддаються безпосередньо з кешу в памʼяті (для файлів, до яких часто звертаються) або потоком із диска. OxPHP автоматично додає заголовки ETag, Last-Modified і Cache-Control.
  7. Виконання PHP. Запит ставиться в обмежену чергу, і його забирає вільний воркер. Якщо черга заповнена, запит чекає на вільне місце до QUEUE_WAIT_TIMEOUT_MS (за замовчуванням 1000 мс). Клієнт отримує 529, коли цей бюджет вичерпується, або одразу, якщо запити, що очікують, уже досягли однієї зі своїх меж (див. Черга і протитиск).
  8. Стиснення. Текстові відповіді стискаються перед надсиланням тим із алгоритмів Brotli, Zstandard і gzip, якому клієнт у Accept-Encoding надає найвищий пріоритет. За рівного пріоритету кешовані статичні файли отримують Brotli, а все інше — Zstandard (налаштовується через COMPRESSION_ENCODINGS і змінні рівня стиснення для кожного кодування).
  9. SSE-потоковість. Якщо скрипт встановлює Content-Type: text/event-stream або викликає oxphp_stream_flush(), OxPHP переходить у потоковий режим: кожен виклик flush() одразу надсилає клієнтові фрагмент, не буферизуючи всю відповідь. У режимі воркерів SSE працює кооперативно з мультиплексуванням файберів.
  10. Рання відповідь. Виклик oxphp_finish_request() одразу надсилає клієнтові HTTP-відповідь. Скрипт продовжує виконуватися у фоні (записує журнали, оновлює кеші, надсилає сповіщення), не утримуючи зʼєднання відкритим.
  11. Доставка відповіді. Готова відповідь надсилається назад через зʼєднання, і, якщо увімкнено журнал доступу, до нього додається запис.

Режим воркерів і стандартний режим

OxPHP підтримує дві моделі виконання PHP:

Стандартний режим (за замовчуванням)

Створює нове середовище PHP для кожного запиту. Автозавантажувачі, конфігурація та зʼєднання з базою даних ініціалізуються на кожен запит і знищуються після нього. Ця модель одразу сумісна з усіма PHP-застосунками.

Режим воркерів

Тримає PHP-воркери живими між запитами. Ваш застосунок ініціалізується один раз (завантажує автозавантажувач і конфігурацію, встановлює зʼєднання з базою даних), а потім входить у цикл обробки запитів. Між запитами OxPHP автоматично скидає суперглобальні змінні, буфери виведення та заголовки відповіді, зберігаючи стан, отриманий під час ініціалізації. Свідомим винятком є $_ENV: за типового для PHP auto_globals_jit=1 він не скидається, тож є станом воркера, а не запиту, і значення, записане туди під час одного запиту, прочитають усі наступні запити, які обслуговує цей воркер. Див. Суперглобальні змінні.

Режим воркерів усуває накладні витрати на запуск для кожного запиту, що може суттєво скоротити час відповіді для застосунків на фреймворках (Laravel, Symfony тощо), де ініціалізація коштує дорого.

Щоб увімкнути режим воркерів, встановіть WORKER_MODE_ENABLED=true і вкажіть в ENTRY_FILE PHP-скрипт, який викликає oxphp_worker():

php
<?php require __DIR__ . '/../vendor/autoload.php'; $app = new MyApp\Application(); oxphp_worker(function () use ($app) { $app->handle(); });

Докладний посібник див. у розділі Режим воркерів.

Внутрішній сервер

Якщо задано змінну INTERNAL_ADDR, OxPHP запускає окремий HTTP-сервер на вказаному порту. Він обслуговує три ендпоінти:

Ендпоінт Опис
GET /health Стан справності у форматі JSON (час роботи, лічильники запитів, зʼєднання, стан воркерів). Повертає 200 у звичайному режимі роботи, 503 — під час деградації.
GET /metrics Метрики у форматі Prometheus — лічильники запитів, час відповіді, час очікування в черзі, статистика воркерів, економія від стиснення.
GET /config Знімок активної конфігурації у форматі JSON. Шляхи до файлів TLS приховано.

Внутрішній сервер не проходить через пул PHP-воркерів чи обмежену чергу. Він відповідає безпосередньо з асинхронного HTTP-рівня, тож залишається доступним навіть тоді, коли пул PHP повністю завантажений. Завдяки цьому він придатний для проб Kubernetes, якщо спрямовувати їх на виділені ендпоінти (/health/liveness, /health/readiness і /health/startup), а не на сукупний /health, який відповідає 503 у станах, що їх перезапуск не виправляє, і не відстежує прапорець завершення, який виставляє сервер під час дренування. Див. Перевірки справності.

Докладніше див. у розділі Внутрішній сервер.

Безпека

OxPHP надає кілька гарантій, які забезпечують надійну роботу вашого застосунку в продакшені:

  • Ізоляція запитів. Якщо PHP-скрипт аварійно завершується або спричиняє фатальну помилку, це стосується лише одного запиту. Сервер і далі нормально обробляє всі інші запити, а воркер, що впав, відновлюється, як описано нижче.
  • Автоматичний перезапуск воркерів. OxPHP стежить за станом усіх PHP-воркерів і замінює той, що несподівано завершився, без ручного втручання. Статичний пул поповнюється до налаштованої кількості, тож заміна відбувається один до одного; динамічний пул поповнюється лише до свого мінімуму, а воркер, втрачений понад нього, повертається звичайним масштабуванням угору.
  • Захист від перевантаження. Обмежена черга запитів запобігає перевантаженню. Коли сервер працює на межі можливостей, новий запит чекає на вільне місце лише стільки, скільки дозволяє бюджет очікування, а потім отримує відповідь 529 із заголовком Retry-After замість нескінченного очікування в черзі, що спричиняло б каскадні тайм-аути.
  • Захист від обходу шляхів. Усі шляхи URL очищаються перед зверненням до файлової системи. Спроби обходу через процентне кодування, сегменти .. і шляхи, що виходять за межі кореневого каталогу документів, блокуються.
  • Коректне завершення. Отримавши SIGTERM або SIGINT (Ctrl+C), OxPHP перестає приймати нові зʼєднання (сокет прослуховування залишається відкритим до завершення процесу; див. Коректне завершення) і чекає, доки завершаться запити в обробці (не довше за налаштовуваний тайм-аут дренування), перш ніж вийти.

Див. також