Przegląd architektury

OxPHP to serwer HTTP w postaci pojedynczego pliku binarnego, który zastępuje tradycyjny stos nginx + PHP-FPM. W jednym procesie obsługuje parsowanie HTTP, terminację TLS, routing, wykonywanie PHP, kompresję i observability, bez żadnych zewnętrznych zależności w czasie działania.

Jak działa OxPHP

OxPHP łączy dwie warstwy uruchomieniowe w jednym procesie:

  1. Asynchroniczna warstwa HTTP. Sterowana zdarzeniami warstwa sieciowa przyjmuje połączenia TCP, przeprowadza uzgadnianie TLS (handshake), parsuje żądania HTTP i wysyła odpowiedzi. Obsługuje tysiące równoczesnych połączeń dzięki nieblokującemu I/O, więc jeden powolny klient nigdy nie blokuje pozostałych.
  2. Pula workerów PHP. Pula dedykowanych workerów PHP wykonuje Twoje skrypty PHP. W trybie standardowym każdy worker obsługuje jedno żądanie naraz. W trybie worker z włączonym multipleksowaniem Fiberów pojedynczy worker może obsługiwać wiele równoczesnych żądań: gdy skrypt wywołuje oxphp_sleep() lub oxphp_async_await(), Fiber oddaje wątek, a worker przełącza się na kolejne żądanie.
  3. Pula asynchroniczna (opcjonalna). Osobne wątki systemowe dla zadań zgłaszanych przez oxphp_async(). Włączana przez ustawienie ASYNC_WORKERS > 0. Odizolowana od puli workerów, aby zadania w tle nie blokowały obsługi żądań HTTP.

Obie warstwy komunikują się przez ograniczoną kolejkę. Gdy przychodzi żądanie HTTP wymagające wykonania PHP, warstwa asynchroniczna umieszcza je w kolejce. Dostępny worker PHP pobiera je, wykonuje skrypt i zwraca odpowiedź do warstwy asynchronicznej w celu dostarczenia jej klientowi.

Ten podział sprawia, że sieciowe I/O (przyjmowanie połączeń, odczyt nagłówków, kompresja odpowiedzi, serwowanie plików statycznych) nigdy nie rywalizuje o zasoby z wykonywaniem PHP. Każda warstwa skaluje się niezależnie.

Pula workerów

Pula workerów PHP określa, ile skryptów PHP może wykonywać się równocześnie. OxPHP obsługuje dwa tryby puli.

Pula statyczna

Stała liczba workerów uruchamia się przy starcie i działa przez cały czas życia serwera. To tryb domyślny.

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

Pula dynamiczna

Workery skalują się w górę i w dół w zależności od zapotrzebowania. Podaj minimalną i maksymalną liczbę oddzieloną dwukropkiem:

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

Gdy wszystkie bieżące workery są zajęte, OxPHP tworzy nowe workery aż do maksimum. Gdy worker pozostaje bezczynny dłużej niż PHP_WORKERS_IDLE_SECONDS (domyślnie: 30 sekund), zostaje wycofany z powrotem w stronę minimum.

Wycofanie następuje w momencie, gdy worker nie ma nic w toku, więc żądanie, które wciąż obsługuje, zawsze dobiega do własnej odpowiedzi. Ta sama reguła wyznacza granicę: worker trzymający żądanie, które nigdy się nie kończy (otwarty strumień, odczyt od strony, która przestała odpowiadać), nigdy nie osiąga takiego momentu i nie jest wycofywany, dopóki je trzyma.

Kolejka i przeciwciśnienie

Między asynchroniczną warstwą HTTP a pulą workerów znajduje się ograniczona kolejka. Jej pojemność domyślnie wynosi początkową liczbę workerów pomnożoną przez 128 i można ją nadpisać za pomocą QUEUE_CAPACITY. Dla puli statycznej liczbą początkową jest skonfigurowana liczba workerów. Dla puli dynamicznej (MIN:MAX) liczbą początkową jest wartość minimalna.

Żądanie, które trafia na pełną kolejkę, nie jest odrzucane od razu. Czeka na miejsce — do QUEUE_WAIT_TIMEOUT_MS (domyślnie: 1000 ms) — i zostaje przyjęte, gdy tylko worker pobierze żądanie znajdujące się przed nim. Oczekujące żądania są przyjmowane w kolejności nadejścia. Budżet to pojedynczy termin wyznaczany w chwili nadejścia, który podąża z żądaniem do kolejki: żądanie, do którego worker dotrze po upływie terminu, jest przy pobraniu odrzucane, zamiast zostać wykonane. Budżet ogranicza więc całe oczekiwanie, nie tylko jego połowę związaną z przyjęciem — a to ma znaczenie, bo kolejka jest na tyle głęboka, że dotarcie do jej końca na wolnej puli trwa znacznie dłużej niż jakikolwiek budżet, jaki ustawiłby operator. Zrzucanie obciążenia odbywa się na podstawie upłyniętego czasu oczekiwania, a nie głębokości kolejki w chwili nadejścia żądania: skok ruchu, który rozładowuje się w mikrosekundy, zostaje obsłużony, podczas gdy pula, która naprawdę nie nadąża, wciąż zrzuca.

Czego budżet nie ogranicza, to wykonanie. Czas odpowiedzi pod obciążeniem to oczekiwanie (na przyjęcie, a potem w kolejce) plus czas działania handlera — a o tej drugiej części budżet nic nie mówi.

QUEUE_MAX_WAITING ogranicza, ile żądań może czekać jednocześnie (domyślnie: początkowa liczba workerów × 128, z pułapem połowy MAX_CONNECTIONS); powyżej tej wartości przyjmowanie wraca do natychmiastowego odrzucania. Oczekiwanie nie jest darmowe. Oczekujące żądanie trzyma swoje połączenie — oraz swoje już zbuforowane ciało — dopóki nie zostanie przyjęte albo budżet się nie wyczerpie, więc nieograniczony zbiór oczekujących zużyłby przy trwałym przeciążeniu wszystkie zezwolenia na połączenia, zablokował pętlę akceptującą i zostawił serwer zrywający nowe połączenia zamiast na nie odpowiadać. Wartość domyślna przybliża, ile żądań pula faktycznie zdoła przyjąć w ramach budżetu: czekanie ponad to jedynie odracza odrzucenie, trzymając w tym czasie te zasoby. Pułap MAX_CONNECTIONS / 2 ogranicza sam zbiór oczekujących, co samo w sobie nie stanowi zapasu dla pętli akceptującej: żądanie w kolejce trzyma swoje połączenie, dopóki worker do niego nie dotrze, wykonywane też je trzyma (jego miejsce w kolejce zostało zwolnione przy pobraniu), a QUEUE_CAPACITY nie ma żadnego pułapu wywiedzionego z liczby połączeń. Dlatego PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING musi pozostawać poniżej MAX_CONNECTIONS. Gdy suma osiągnie ten budżet, pętla akceptująca staje z wszystkimi zezwoleniami zajętymi, a klient, który wtedy nadejdzie, nie dostaje żadnej odpowiedzi zamiast 529. Serwer ostrzega o tym przy starcie. Usunięcie ostrzeżenia jest warunkiem koniecznym, lecz niewystarczającym: połączenia, które nigdy nie docierają do PHP, również trzymają zezwolenia. Zwróć uwagę, że składniki tej sumy są liczone w połączeniach, podczas gdy budżet zużywają żądania: w HTTP/2 jedno połączenie niesie wiele żądań, więc wdrożenie o dużym udziale h2 osiąga limit przy ułamku swojego budżetu połączeń, powinno ustawić QUEUE_MAX_WAITING jawnie i może zasadnie znajdować się powyżej tej sumy.

Zbiór oczekujących jest ograniczony podwójnie, ponieważ limit liczony w żądaniach nic nie mówi o pamięci, którą te żądania zajmują: ta sama liczba oczekujących nie kosztuje nic przy GET-ach bez ciała, a gigabajty przy wysyłkach plików. QUEUE_MAX_WAITING_BYTES (domyślnie 64 MiB) ogranicza liczbę bajtów ciał żądań zaparkowanych jednocześnie. Powyżej tej wartości żądanie niosące ciało jest odrzucane natychmiast, zamiast zostać zaparkowane, podczas gdy żądania bez ciała czekają dalej normalnie. Dwóch rzeczy ten limit nie obejmuje — obie istniały przed budżetem oczekiwania: ciał już przekazanych do kolejki, które QUEUE_CAPACITY ogranicza w żądaniach, a nic nie ogranicza w bajtach, oraz pamięci, którą ciało zajmuje, gdy wciąż jest odczytywane z połączenia — tej nie ogranicza żaden limit zbiorczy.

OxPHP zwraca odpowiedź 529 Site is Overloaded z nagłówkiem Retry-After, gdy żądania nie da się obsłużyć, a nie jedynie opóźnić: budżet oczekiwania się wyczerpał albo jeden z powyższych limitów odmówił mu miejsca do czekania. Kod statusu 529 (niestandardowy, używany m.in. przez Cloudflare) wyraźnie odróżnia przeciążenie od błędów aplikacji (500) i prac serwisowych (503), dzięki czemu łatwiej skonfigurować alerty i load balancery. Ustawienie QUEUE_WAIT_TIMEOUT_MS=0 wyłącza oczekiwanie i odrzuca w chwili, gdy kolejka jest pełna.

Ponieważ oczekujące żądania zajmują połączenie, a nie miejsce w kolejce, pułap współbieżności pod przeciążeniem wyznacza MAX_CONNECTIONS, a nie QUEUE_CAPACITY. W HTTP/2 jest to MAX_CONNECTIONS pomnożone przez H2_MAX_CONCURRENT_STREAMS, bo każdy strumień niesie własne żądanie. Dobieraj budżet oczekiwania z tą świadomością: ciało żądania jest już zbuforowane, zanim żądanie dotrze do kolejki, więc dłuższy budżet oznacza, że naprawdę przeciążony serwer trzyma w pamięci proporcjonalnie więcej żądań — i ich ciał — zanim odpowie.

Przepływ żądań

Każde żądanie przechodzi przez ten sam potok, niezależnie od tego, czy serwuje plik statyczny, czy wykonuje 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. Terminacja TLS. Jeśli skonfigurowano TLS_CERT i TLS_KEY, OxPHP obsługuje TLS bezpośrednio. Nie jest potrzebny osobny reverse proxy.
  2. Parsowanie HTTP i ID żądania. Żądanie zostaje sparsowane, a unikalne ID żądania jest generowane (lub zachowywany jest przychodzący nagłówek X-Request-ID).
  3. Rozpoznanie zaufanych proxy. Jeśli ustawiono TRUSTED_PROXIES, a łączące się IP jest zaufane, OxPHP wyodrębnia prawdziwe IP klienta, protokół i host z nagłówków Forwarded (RFC 7239) lub X-Forwarded-*. Rozpoznane IP jest używane we wszystkich kolejnych krokach, w tym w ograniczaniu liczby żądań i rejestrowaniu dostępu. Zobacz Zaufane proxy.
  4. Ograniczanie liczby żądań. Jeśli ustawiono RATE_LIMIT, IP klienta jest sprawdzane względem licznika żądań na dane IP. Żądania przekraczające limit natychmiast otrzymują odpowiedź 429 Too Many Requests.
  5. Rozpoznanie trasy. URL jest dopasowywany do skonfigurowanego trybu routingu (traditional, framework lub SPA). Wynikiem jest plik statyczny, skrypt PHP albo 404. Tryb worker, jeśli jest włączony, zmienia sposób, w jaki PHP wykonuje rozpoznany skrypt, ale nie zmienia samego rozpoznawania tras.
  6. Pliki statyczne. Serwowane bezpośrednio z pamięci podręcznej w pamięci (dla często używanych plików) lub strumieniowane z dysku. OxPHP automatycznie dodaje nagłówki ETag, Last-Modified i Cache-Control.
  7. Wykonanie PHP. Żądanie zostaje umieszczone w ograniczonej kolejce i pobrane przez dostępny worker. Jeśli kolejka jest pełna, klient natychmiast otrzymuje 529.
  8. Kompresja. Odpowiedzi tekstowe są kompresowane algorytmem Brotli przed wysłaniem, gdy klient przesyła Accept-Encoding: br (konfigurowalne przez COMPRESSION_LEVEL).
  9. Strumieniowanie SSE. Jeśli skrypt ustawia Content-Type: text/event-stream lub wywołuje oxphp_stream_flush(), OxPHP przełącza się w tryb strumieniowania: każde wywołanie flush() natychmiast wysyła fragment do klienta, bez buforowania całej odpowiedzi. W trybie worker SSE działa we współpracy z multipleksowaniem Fiberów.
  10. Wczesna odpowiedź. Wywołanie oxphp_finish_request() natychmiast wysyła odpowiedź HTTP do klienta. Skrypt kontynuuje wykonywanie w tle (zapis logów, aktualizacja pamięci podręcznych, wysyłanie powiadomień), nie utrzymując otwartego połączenia.
  11. Dostarczenie odpowiedzi. Ukończona odpowiedź jest odsyłana przez połączenie, a jeśli włączone jest rejestrowanie dostępu, zapisywany jest wpis w logu.

Tryb worker a tryb standardowy

OxPHP obsługuje dwa modele wykonywania PHP:

Tryb standardowy (domyślny)

Tworzy świeże środowisko PHP dla każdego żądania. Autoloadery, konfiguracja i połączenia z bazą danych są inicjalizowane przy każdym żądaniu i zwalniane po jego zakończeniu. Ten model jest zgodny ze wszystkimi aplikacjami PHP od razu, bez dodatkowej konfiguracji.

Tryb worker

Utrzymuje procesy PHP przy życiu między żądaniami. Twoja aplikacja bootstrapuje się raz (ładowanie autoloadera, konfiguracji oraz nawiązanie połączeń z bazą danych), a następnie wchodzi w pętlę żądań. Między żądaniami OxPHP automatycznie resetuje superglobalne, bufory wyjścia i nagłówki odpowiedzi, zachowując stan ustanowiony podczas bootstrapu.

Tryb worker eliminuje narzut uruchomieniowy przypadający na każde żądanie, co może znacząco skrócić czasy odpowiedzi w aplikacjach opartych na frameworkach (Laravel, Symfony itd.), gdzie bootstrap jest kosztowny.

Aby włączyć tryb worker, ustaw WORKER_MODE_ENABLED=true i skieruj ENTRY_FILE na skrypt PHP, który wywołuje oxphp_worker():

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

Szczegółowy przewodnik znajdziesz w Tryb worker.

Serwer wewnętrzny

Jeśli ustawiona jest zmienna INTERNAL_ADDR, OxPHP uruchamia osobny serwer HTTP na wskazanym porcie. Udostępnia trzy endpointy:

Endpoint Opis
GET /health Stan zdrowia w formacie JSON (czas działania, liczniki żądań, połączenia, stan workerów). Zwraca 200 podczas normalnej pracy, 503 podczas degradacji.
GET /metrics Metryki w formacie Prometheus — liczniki żądań, czas odpowiedzi, czas oczekiwania w kolejce, statystyki workerów, oszczędności z kompresji.
GET /config Migawka aktywnej konfiguracji w formacie JSON. Ścieżki plików TLS są ukrywane.

Serwer wewnętrzny nie przechodzi przez pulę workerów PHP ani przez ograniczoną kolejkę. Odpowiada bezpośrednio z asynchronicznej warstwy HTTP, dzięki czemu pozostaje dostępny nawet wtedy, gdy pula PHP jest w pełni obciążona. To sprawia, że /health nadaje się do sond liveness/readiness w Kubernetes.

Szczegóły znajdziesz w Serwer wewnętrzny.

Bezpieczeństwo

OxPHP zapewnia kilka gwarancji, dzięki którym Twoja aplikacja działa niezawodnie na produkcji:

  • Izolacja żądań. Jeśli skrypt PHP ulega awarii lub wywołuje błąd krytyczny, dotyczy to tylko tego jednego żądania. Serwer nadal normalnie obsługuje wszystkie pozostałe żądania. Worker, który uległ awarii, jest automatycznie zastępowany nowym.
  • Automatyczne odradzanie workerów. OxPHP monitoruje kondycję wszystkich workerów PHP. Jeśli worker niespodziewanie zginie, na jego miejsce uruchamiany jest nowy worker, bez ręcznej interwencji.
  • Ochrona przez przeciwciśnienie. Ograniczona kolejka żądań zapobiega przeciążeniu. Gdy serwer osiągnie pełną pojemność, nowe żądania otrzymują odpowiedź 529 z nagłówkiem Retry-After, zamiast czekać w kolejce w nieskończoność i powodować kaskadowe przekroczenia limitu czasu.
  • Ochrona przed path traversal. Wszystkie ścieżki URL są sanityzowane przed dostępem do systemu plików. Blokowane są zakodowane procentowo próby traversalu, segmenty .. oraz ścieżki wychodzące poza katalog główny dokumentów.
  • Łagodne zamknięcie. Po otrzymaniu SIGTERM lub SIGINT (Ctrl+C) OxPHP przestaje przyjmować nowe połączenia i czeka na zakończenie żądań w toku (do konfigurowalnego limitu czasu opróżniania) przed zakończeniem pracy.

Zobacz też

Znalazłeś błąd? Zgłoś go →