Обзор архитектуры

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

Как работает OxPHP

OxPHP объединяет два слоя выполнения в одном процессе:

  1. Асинхронный HTTP-слой. Событийно-ориентированный сетевой слой принимает TCP-соединения, выполняет TLS-рукопожатия, разбирает HTTP-запросы и отправляет ответы. Он обрабатывает тысячи одновременных соединений с помощью неблокирующего ввода-вывода, так что один медленный клиент никогда не блокирует другого.
  2. Пул PHP-воркеров. Пул выделенных PHP-воркеров выполняет ваши PHP-скрипты. В стандартном режиме каждый воркер обрабатывает по одному запросу за раз. В режиме воркеров с включённым мультиплексированием файберов один воркер может обслуживать несколько одновременных запросов: когда скрипт вызывает oxphp_sleep() или oxphp_async_await(), файбер уступает поток, и воркер переключается на следующий запрос.
  3. Асинхронный пул (опционально). Отдельные потоки ОС для задач, отправленных через oxphp_async(). Включается установкой ASYNC_WORKERS > 0. Изолирован от пула воркеров, поэтому фоновые задачи не блокируют обработку HTTP-запросов.

Два слоя обмениваются данными через ограниченную очередь. Когда приходит HTTP-запрос, требующий выполнения PHP, асинхронный слой помещает его в очередь. Свободный PHP-воркер забирает его, выполняет скрипт и возвращает ответ асинхронному слою для доставки клиенту.

Такое разделение означает, что сетевой ввод-вывод (приём соединений, чтение заголовков, сжатие ответов, отдача статических файлов) никогда не конкурирует за ресурсы с выполнением 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 мс) — и допускается, как только воркер забирает запрос перед ним. Ожидающие запросы допускаются в порядке прибытия. Бюджет — это единый дедлайн, проставляемый в момент прибытия, и он следует за запросом в очередь: запрос, до которого воркер добирается после истечения дедлайна, отклоняется при заборе, а не выполняется. Таким образом, бюджет ограничивает всё ожидание, а не только его половину до допуска, и это важно, потому что очередь достаточно глубока: путь до её хвоста на медленном пуле занимает куда дольше любого бюджета, который выставил бы оператор. Нагрузка сбрасывается по прошедшему времени ожидания, а не по глубине очереди в момент прибытия запроса: всплеск, который рассасывается за микросекунды, обслуживается, тогда как пул, действительно не справляющийся с потоком, всё равно сбрасывает.

Чего бюджет не ограничивает, так это выполнение. Время ответа под нагрузкой — это ожидание (сначала допуска, затем в очереди) плюс сколько бы ни работал обработчик, и о второй части бюджет ничего не говорит.

QUEUE_MAX_WAITING ограничивает, сколько запросов могут ждать одновременно (по умолчанию: начальное число воркеров × 128, но не более половины MAX_CONNECTIONS); за его пределами допуск снова начинает отклонять немедленно. Ожидание не бесплатно. Ожидающий запрос удерживает своё соединение и своё уже буферизованное тело, пока не будет допущен или пока не истечёт бюджет, поэтому неограниченное множество ожидающих при устойчивой перегрузке заняло бы все разрешения на соединения, заблокировало бы цикл приёма и оставило бы сервер сбрасывать новые соединения вместо того, чтобы на них отвечать. Значение по умолчанию приблизительно оценивает, сколько запросов пул реально способен допустить в пределах бюджета: ожидание сверх этого лишь откладывает отказ, удерживая при этом ресурсы. Потолок MAX_CONNECTIONS / 2 ограничивает только множество ожидающих, что само по себе ещё не запас для цикла приёма: запрос в очереди удерживает соединение, пока до него не доберётся воркер, выполняющийся — тоже (его слот в очереди освободился при заборе), а у QUEUE_CAPACITY вообще нет потолка, выведенного из числа соединений. Поэтому сумма PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING должна оставаться ниже MAX_CONNECTIONS. Как только сумма достигает бюджета, цикл приёма встаёт с полностью разобранными разрешениями, и клиент, пришедший в этот момент, не получает никакого ответа вместо 529. Сервер предупреждает об этом при запуске. Устранение предупреждения — условие необходимое, но не достаточное: соединения, которые никогда не доходят до PHP, тоже удерживают разрешения. Обратите внимание, что слагаемые измеряются в соединениях, тогда как бюджет расходуется запросами: по HTTP/2 одно соединение несёт много запросов, поэтому развёртывание с преобладанием h2 достигает потолка, израсходовав лишь долю своего бюджета соединений, — ему стоит задать QUEUE_MAX_WAITING явно, и оно может законно находиться выше суммы.

Множество ожидающих ограничено дважды, потому что предел, считаемый в запросах, ничего не говорит о памяти, которую эти запросы удерживают: одно и то же число ожидающих ничего не стоит на GET без тела и обходится в гигабайты на загрузках файлов. QUEUE_MAX_WAITING_BYTES (по умолчанию 64 МиБ) ограничивает байты тел запросов, припаркованных одновременно. За его пределами запрос, несущий тело, отклоняется немедленно, а не паркуется, тогда как запросы без тела продолжают ждать как обычно. Двух вещей он не покрывает, и обе существовали до появления бюджета ожидания: тела, уже переданные в очередь, которые 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-*. Разрешённый IP используется на всех последующих шагах, включая ограничение частоты запросов и журналирование доступа. См. Доверенные прокси.
  4. Ограничение частоты запросов. Если задан RATE_LIMIT, IP клиента проверяется по счётчику запросов на каждый IP. Запросы, превышающие лимит, немедленно получают ответ 429 Too Many Requests.
  5. Разрешение маршрута. URL сопоставляется с настроенным режимом маршрутизации (традиционный, framework или SPA). Результатом становится либо статический файл, либо PHP-скрипт, либо 404. Режим воркеров, если он включён, меняет то, как PHP выполняет разрешённый скрипт, но не меняет само разрешение маршрута.
  6. Статические файлы. Отдаются напрямую из кэша в памяти (для часто запрашиваемых файлов) или потоково с диска. OxPHP автоматически добавляет заголовки ETag, Last-Modified и Cache-Control.
  7. Выполнение PHP. Запрос помещается в ограниченную очередь и забирается свободным воркером. Если очередь заполнена, клиент немедленно получает 529.
  8. Сжатие. Текстовые ответы сжимаются с помощью Brotli перед отправкой, когда клиент присылает Accept-Encoding: br (настраивается через COMPRESSION_LEVEL).
  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 автоматически сбрасывает суперглобальные переменные, буферы вывода и заголовки ответа, сохраняя при этом состояние, полученное при бутстрапе.

Режим воркеров устраняет накладные расходы на запуск при каждом запросе, что может значительно сократить время ответа для приложений на основе фреймворков (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 полностью загружен. Это делает /health пригодным для проб liveness/readiness в Kubernetes.

Подробнее см. в разделе Внутренний сервер.

Безопасность

OxPHP предоставляет несколько гарантий, чтобы ваше приложение надёжно работало в продакшене:

  • Изоляция запросов. Если PHP-скрипт падает или вызывает фатальную ошибку, затрагивается только этот единственный запрос. Сервер продолжает нормально обрабатывать все остальные запросы. Упавший воркер автоматически заменяется свежим.
  • Автоматическое пересоздание воркеров. OxPHP следит за работоспособностью всех PHP-воркеров. Если воркер неожиданно умирает, на его месте запускается новый воркер без ручного вмешательства.
  • Защита противодавлением (backpressure). Ограниченная очередь запросов предотвращает перегрузку. Когда сервер достигает предела, новые запросы получают ответ 529 с заголовком Retry-After, вместо того чтобы бесконечно накапливаться в очереди и вызывать каскадные таймауты.
  • Защита от обхода пути (path traversal). Все URL-пути очищаются перед обращением к файловой системе. Попытки обхода через процентное кодирование, сегменты .. и пути, выходящие за пределы корня документа, блокируются.
  • Корректное завершение работы. По сигналу SIGTERM или SIGINT (Ctrl+C) OxPHP прекращает приём новых соединений и ждёт завершения выполняющихся запросов (вплоть до настраиваемого таймаута дренирования) перед выходом.

См. также

Нашли ошибку? Сообщите →