Vue d'ensemble de l'architecture

OxPHP est un serveur HTTP mono-binaire qui remplace la traditionnelle pile nginx + PHP-FPM. Il prend en charge l'analyse HTTP, la terminaison TLS, le routage, l'exécution PHP, la compression et l'observabilité dans un seul processus, sans aucune dépendance externe à l'exécution.

Fonctionnement d'OxPHP

OxPHP combine deux couches d'exécution, ainsi qu'un pool asynchrone optionnel, dans un même processus :

  1. Couche HTTP asynchrone. Une couche réseau événementielle accepte les connexions TCP, effectue les handshakes TLS, analyse les requêtes HTTP et envoie les réponses. Elle gère des milliers de connexions concurrentes grâce à des E/S non bloquantes, si bien qu'un client lent n'en bloque jamais un autre.
  2. Pool de workers PHP. Un pool de workers PHP dédiés exécute vos scripts PHP. En mode standard, chaque worker traite une requête à la fois. En mode worker avec le multiplexage par Fibers activé, un seul worker peut servir plusieurs requêtes concurrentes : lorsqu'un script appelle oxphp_sleep() ou oxphp_async_await(), la Fiber cède le thread et le worker passe à la requête suivante.

Les deux couches communiquent au travers d'une file bornée. Lorsqu'une requête HTTP nécessitant une exécution PHP arrive, la couche asynchrone la place dans la file. Un worker PHP disponible la récupère, exécute le script et renvoie la réponse à la couche asynchrone pour livraison au client.

Le pool asynchrone est optionnel. Il exécute les tâches soumises via oxphp_async() sur des threads OS distincts, avec sa propre file de tâches, et s'active en définissant ASYNC_WORKERS > 0. Il est isolé du pool de workers, si bien que les tâches d'arrière-plan ne bloquent pas le traitement des requêtes HTTP.

Grâce à cette séparation, les E/S réseau (acceptation des connexions, lecture des en-têtes, compression des réponses, service des fichiers statiques) n'entrent jamais en concurrence avec l'exécution PHP pour les ressources. Chaque couche monte en charge indépendamment.

Pool de workers

Le pool de workers PHP détermine combien de scripts PHP peuvent s'exécuter simultanément. OxPHP prend en charge deux modes de pool.

Pool statique

Un nombre fixe de workers démarrent au boot et restent actifs pendant toute la durée de vie du serveur. C'est le mode par défaut.

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

Pool dynamique

Les workers montent et descendent en charge selon la demande. Indiquez un nombre minimal et maximal séparés par deux-points :

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

Lorsque tous les workers courants sont occupés, OxPHP démarre de nouveaux workers jusqu'au maximum. Lorsqu'un worker est resté inactif plus longtemps que PHP_WORKERS_IDLE_SECONDS (par défaut : 30 secondes), il est retiré pour revenir vers le minimum.

Le retrait a lieu à un moment où le worker n'a rien en cours, si bien qu'une requête qu'il est encore en train de servir va toujours jusqu'à sa propre réponse. La même règle en fixe la limite : un worker qui détient une requête qui ne se termine jamais (un flux ouvert, une lecture depuis un pair qui cesse de répondre) n'atteint jamais un tel moment et n'est pas retiré tant qu'il la détient.

File et contre-pression

Entre la couche HTTP asynchrone et le pool de workers se trouve une file bornée. Sa capacité vaut par défaut le nombre initial de workers multiplié par 128 et peut être remplacée par QUEUE_CAPACITY. Pour un pool statique, le nombre initial est le nombre de workers configuré. Pour un pool dynamique (MIN:MAX), le nombre initial est le minimum.

Une requête qui arrive alors que la file est pleine n'est pas rejetée d'emblée. Elle attend une place, jusqu'à QUEUE_WAIT_TIMEOUT_MS (par défaut : 1000 ms), et est admise dès qu'un worker prend en charge la requête qui la précède. Ce paramètre est l'attente maximale, pas une durée fixe. Le serveur raccourcit le budget tant que du travail admis depuis l'attente s'achève pour des clients déjà déconnectés, et le rétablit une fois la file résorbée ; sous surcharge, l'échéance estampillée sur une requête qui arrive est donc celle raccourcie. Les requêtes en attente sont admises dans leur ordre d'arrivée. Le budget est une échéance unique estampillée à l'arrivée, et il suit la requête dans la file : lorsque l'échéance passe, la requête reçoit une réponse 529, et un worker qui prend en charge une requête dont l'échéance est dépassée la refuse au lieu de l'exécuter. Si le client a fermé la connexion entre-temps, la réponse est un 499, car plus aucun client n'est là pour recevoir le refus, et le compter comme une 529 ferait mesurer à la série de surcharge le temps que les clients étaient prêts à attendre. Le budget borne donc l'attente entière : l'admission et le temps passé dans la file. Cela compte, car la file est assez profonde pour qu'en atteindre la fin sur un pool lent prenne bien plus longtemps que n'importe quel budget qu'un opérateur choisirait, et parce qu'une file dont aucun worker ne prend les requêtes n'offre aucune prise en charge à laquelle faire respecter une échéance. La charge est délestée selon le temps d'attente écoulé plutôt que selon la profondeur de la file à l'instant où la requête est arrivée : une rafale qui se résorbe en quelques microsecondes est servie, tandis qu'un pool qui, réellement, ne suit pas continue de délester.

Le budget ne borne que l'attente ; il ne borne pas l'exécution. Une fois qu'un worker a pris en charge une requête avant son échéance, le budget ne l'interrompt pas, quelle que soit la durée d'exécution du gestionnaire ensuite, car la vérification porte sur l'attente et non sur le temps total écoulé. Le temps de réponse sous charge est l'attente plus le temps d'exécution, et le budget ne plafonne que l'attente. Il en plafonne toutefois la totalité : une requête qui attend encore lorsque son échéance passe reçoit sa réponse à ce moment-là, qu'elle attende une place dans la file ou qu'elle patiente dans la file qu'un worker la prenne.

QUEUE_MAX_WAITING plafonne le nombre de requêtes pouvant attendre en même temps (par défaut : workers initiaux × 128, plafonné à la moitié de MAX_CONNECTIONS) ; au-delà, l'admission revient au rejet immédiat. Attendre n'est pas gratuit. Une requête en attente conserve sa connexion, ainsi que son corps déjà mis en mémoire tampon, jusqu'à son admission ou l'épuisement du budget ; un ensemble d'attente non plafonné consommerait donc chaque permis de connexion sous une surcharge soutenue, bloquerait la boucle d'acceptation et laisserait le serveur abandonner les nouvelles connexions au lieu d'y répondre. La valeur par défaut approxime le nombre de requêtes que le pool peut réellement admettre dans le budget : attendre au-delà ne fait que différer un rejet tout en retenant ces ressources. Le plafond MAX_CONNECTIONS / 2 borne le seul ensemble d'attente, ce qui ne constitue pas en soi une marge pour la boucle d'acceptation : une requête en file conserve sa connexion jusqu'à ce qu'un worker l'atteigne ou que son budget s'épuise, une requête en cours d'exécution la conserve aussi (sa place dans la file a été libérée à la prise en charge), et QUEUE_CAPACITY n'a aucun plafond dérivé des connexions. PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING doit donc rester en dessous de MAX_CONNECTIONS. Une fois que la somme atteint le budget, la boucle d'acceptation se met en pause avec tous les permis pris, et un client qui arrive à ce moment-là n'obtient aucune réponse plutôt qu'un 529. Le serveur en avertit au démarrage, et signale la pause elle-même pendant qu'elle a lieu : un WARN lorsque la boucle se met en pause et un INFO avec la durée lorsqu'elle reprend (avec limitation de fréquence, pour qu'un serveur qui heurte ce plafond à répétition n'inonde pas son propre journal), ainsi que la jauge oxphp_accept_stalled et le compteur oxphp_accept_stalls_total. Tous deux sont mis à jour à chaque pause, y compris celles que la limitation du journal supprime, et sont servis par l'écouteur interne, hors de ce budget ; voir la référence des métriques. Faire disparaître l'avertissement de démarrage est nécessaire plutôt que suffisant : les connexions qui n'atteignent jamais PHP détiennent elles aussi des permis, et la vérification au démarrage ne peut pas prévoir une pause qu'elles provoquent. Notez que les termes sont dimensionnés en connexions alors que le budget se dépense en requêtes : en HTTP/2, une connexion transporte de nombreuses requêtes, si bien qu'un déploiement à dominante h2 atteint le plafond avec une fraction de son budget de connexions, devrait définir QUEUE_MAX_WAITING explicitement, et peut légitimement se situer au-dessus de la somme.

L'ensemble d'attente est borné deux fois, car un plafond compté en requêtes ne dit rien de la mémoire que ces requêtes retiennent : le même nombre de requêtes en attente ne coûte rien sur des GET sans corps, et des gigaoctets sur des uploads. QUEUE_MAX_WAITING_BYTES (64 Mio par défaut) borne les octets de corps de requête stationnés en même temps. Au-delà, une requête portant un corps est refusée immédiatement au lieu d'être mise en attente, tandis que celles sans corps continuent d'attendre normalement. Deux choses lui échappent, toutes deux antérieures au budget d'attente : les corps déjà remis à la file, que QUEUE_CAPACITY borne en requêtes et que rien ne borne en octets, et la mémoire qu'un corps occupe pendant qu'il est encore lu depuis la connexion, qu'aucune limite agrégée ne borne.

OxPHP renvoie une réponse 529 Site is Overloaded accompagnée d'un en-tête Retry-After lorsqu'une requête ne peut pas être servie, et non simplement retardée : le budget d'attente est épuisé, ou l'un des plafonds ci-dessus lui a refusé une place où attendre. Le code de statut 529 (non standard, utilisé par Cloudflare et d'autres) distingue clairement la surcharge des erreurs applicatives (500) et de la maintenance (503), ce qui facilite la configuration des alertes et des répartiteurs de charge. Définir QUEUE_WAIT_TIMEOUT_MS=0 désactive l'attente et rejette dès que la file est pleine.

Parce que les requêtes en attente occupent une connexion plutôt qu'une place dans la file, le plafond de concurrence sous surcharge est fixé par MAX_CONNECTIONS plutôt que par QUEUE_CAPACITY. En HTTP/2, il vaut MAX_CONNECTIONS multiplié par H2_MAX_CONCURRENT_STREAMS, puisque chaque flux transporte sa propre requête. Dimensionnez le budget d'attente en le sachant : le corps de la requête est déjà en mémoire tampon au moment où une requête atteint la file, si bien qu'un budget plus long signifie qu'un serveur réellement surchargé retient proportionnellement plus de requêtes, et leurs corps, en mémoire avant de répondre.

Flux des requêtes

Chaque requête traverse le même pipeline, qu'elle serve un fichier statique ou exécute du 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. Terminaison TLS. Si TLS_CERT et TLS_KEY sont configurés, OxPHP gère TLS directement. Aucun reverse proxy distinct n'est nécessaire.
  2. Analyse HTTP et ID de requête. La requête est analysée et un ID de requête unique est généré (ou un en-tête X-Request-ID entrant est préservé).
  3. Résolution des proxys de confiance. Si TRUSTED_PROXIES est défini et que l'IP de connexion est de confiance, OxPHP extrait l'IP réelle du client, le protocole et l'hôte à partir des en-têtes Forwarded (RFC 7239) ou X-Forwarded-*. Avec le mot-clé cloudflare, une requête dont la chaîne de transfert montre qu'elle est arrivée par un nœud edge de Cloudflare tire plutôt son IP client de CF-Connecting-IP. L'IP résolue est utilisée pour toutes les étapes suivantes, y compris la limitation de débit et la journalisation des accès. Voir Proxys de confiance.
  4. Limitation de débit. Si RATE_LIMIT est défini, l'IP du client est vérifiée par rapport au compteur de requêtes par IP. Les requêtes qui dépassent la limite reçoivent immédiatement une réponse 429 Too Many Requests.
  5. Résolution de route. L'URL est mise en correspondance avec le mode de routage configuré (traditionnel, framework, SPA ou worker). Le résultat est soit un fichier statique, soit un script PHP, soit une 404. Le mode worker est un mode de routage à part entière, qui remplace les trois autres : il exige un ENTRY_FILE en .php et envoie à ce script toute requête qui n'est pas servie comme fichier statique (voir Routage).
  6. Fichiers statiques. Servis directement depuis un cache en mémoire (pour les fichiers fréquemment consultés) ou diffusés depuis le disque. OxPHP ajoute automatiquement les en-têtes ETag, Last-Modified et Cache-Control.
  7. Exécution PHP. La requête est placée dans la file bornée et récupérée par un worker disponible. Si la file est pleine, la requête attend une place pendant au plus QUEUE_WAIT_TIMEOUT_MS (par défaut : 1000 ms). Le client reçoit une 529 lorsque ce budget est épuisé, ou immédiatement lorsque l'ensemble des requêtes en attente a déjà atteint l'un de ses plafonds (voir File et contre-pression).
  8. Compression. Les réponses textuelles sont compressées avant d'être envoyées, avec celui de Brotli, Zstandard et gzip que l'Accept-Encoding du client classe le plus haut. En cas d'égalité, les fichiers statiques en cache reçoivent Brotli et tout le reste Zstandard (configurable via COMPRESSION_ENCODINGS et les variables de niveau propres à chaque codage).
  9. Streaming SSE. Si le script définit Content-Type: text/event-stream ou appelle oxphp_stream_flush(), OxPHP bascule en mode streaming : chaque appel à flush() envoie immédiatement un fragment au client sans mettre en tampon l'intégralité de la réponse. En mode worker, le SSE fonctionne de façon coopérative avec le multiplexage par Fibers.
  10. Réponse anticipée. L'appel à oxphp_finish_request() envoie immédiatement la réponse HTTP au client. Le script continue de s'exécuter en arrière-plan (écriture de journaux, mise à jour de caches, envoi de notifications) sans maintenir la connexion ouverte.
  11. Livraison de la réponse. La réponse achevée est renvoyée sur la connexion et, si la journalisation des accès est activée, une entrée de journal est écrite.

Mode worker vs mode standard

OxPHP prend en charge deux modèles d'exécution PHP :

Mode standard (par défaut)

Crée un environnement PHP neuf pour chaque requête. Les autoloaders, la configuration et les connexions à la base de données sont initialisés à chaque requête puis démantelés ensuite. Ce modèle est compatible d'emblée avec toutes les applications PHP.

Mode worker

Maintient les workers PHP en vie d'une requête à l'autre. Votre application effectue son bootstrap une seule fois (chargement de l'autoloader, de la configuration et établissement des connexions à la base de données), puis entre dans une boucle de requêtes. Entre les requêtes, OxPHP réinitialise automatiquement les superglobales, les tampons de sortie et les en-têtes de réponse tout en préservant l'état amorcé. $_ENV est l'exception délibérée : avec le réglage par défaut de PHP auto_globals_jit=1, il n'est pas réinitialisé ; c'est donc un état du worker plutôt qu'un état de la requête, et une valeur qui y est écrite pendant une requête est lue par toutes les requêtes suivantes que ce worker sert. Voir Superglobales.

Le mode worker élimine la surcharge de démarrage propre à chaque requête, ce qui peut réduire sensiblement les temps de réponse pour les applications basées sur un framework (Laravel, Symfony, etc.) où le bootstrap est coûteux.

Pour activer le mode worker, définissez WORKER_MODE_ENABLED=true et faites pointer ENTRY_FILE vers un script PHP qui appelle oxphp_worker() :

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

Pour un guide détaillé, voir Mode worker.

Serveur interne

Si la variable INTERNAL_ADDR est définie, OxPHP démarre un serveur HTTP distinct sur le port spécifié. Il sert trois endpoints :

Endpoint Description
GET /health État de santé en JSON (uptime, compteurs de requêtes, connexions, état des workers). Renvoie 200 en fonctionnement normal, 503 en cas de dégradation.
GET /metrics Métriques au format Prometheus — compteurs de requêtes, temps de réponse, temps d'attente en file, statistiques des workers, gains de compression.
GET /config Instantané de la configuration active en JSON. Les chemins des fichiers TLS sont masqués.

Le serveur interne ne passe pas par le pool de workers PHP ni par la file bornée. Il répond directement depuis la couche HTTP asynchrone, ce qui le maintient accessible même lorsque le pool PHP est totalement chargé. Il convient donc aux sondes Kubernetes, pointées vers les endpoints dédiés (/health/liveness, /health/readiness et /health/startup) plutôt que vers le /health agrégé, qui répond 503 pour des états qu'un redémarrage ne corrige pas et ne suit pas l'indicateur d'arrêt qu'un serveur en cours de drainage positionne. Voir Contrôles de santé.

Pour plus de détails, voir Serveur interne.

Sûreté

OxPHP fournit plusieurs garanties pour maintenir votre application en fonctionnement fiable en production :

  • Isolation des requêtes. Si un script PHP plante ou déclenche une erreur fatale, seule cette requête est affectée. Le serveur continue de traiter normalement toutes les autres requêtes, et le worker qui a planté est rétabli comme décrit ci-dessous.
  • Redémarrage automatique des workers. OxPHP surveille la santé de tous les workers PHP et remplace, sans intervention manuelle, celui qui meurt de façon inattendue. Un pool statique se remplit jusqu'à son nombre configuré, si bien que le remplacement se fait un pour un ; un pool dynamique ne se remplit que jusqu'à son minimum, et un worker perdu au-dessus de ce seuil est regagné par la montée en charge ordinaire.
  • Protection par contre-pression. La file de requêtes bornée prévient la surcharge. Lorsque le serveur est à pleine capacité, une nouvelle requête n'attend une place que le temps que permet le budget d'attente, puis reçoit une réponse 529 assortie d'un en-tête Retry-After plutôt que d'être mise en file indéfiniment et de provoquer des délais d'expiration en cascade.
  • Protection contre la traversée de répertoires. Tous les chemins d'URL sont assainis avant tout accès au système de fichiers. Les tentatives de traversée encodées en pourcentage, les segments .. et les chemins qui s'échappent de la racine du document sont bloqués.
  • Arrêt gracieux. À la réception de SIGTERM ou SIGINT (Ctrl+C), OxPHP cesse de prendre de nouvelles connexions (le socket d'écoute reste ouvert jusqu'à la fin du processus ; voir Arrêt gracieux) et attend l'achèvement des requêtes en cours (jusqu'à un délai de drainage configurable) avant de se terminer.

Voir aussi

Une erreur ? Signalez-la →