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 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.
  3. Pool asynchrone (optionnel). Des threads OS distincts pour les tâches soumises via oxphp_async(). Activé en définissant ASYNC_WORKERS > 0. Isolé du pool de workers pour que les tâches d'arrière-plan ne bloquent pas le traitement des requêtes HTTP.

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.

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. 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 : une requête qu'un worker atteint après l'échéance est refusée à la prise en charge au lieu d'être exécutée. Le budget borne donc l'attente entière, et pas seulement sa moitié d'admission — ce qui 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. 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.

Ce que le budget ne borne pas, c'est l'exécution. Le temps de réponse sous charge est l'attente (pour l'admission, puis dans la file) plus la durée d'exécution du gestionnaire, et le budget ne dit rien de cette seconde partie.

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, 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. Faire disparaître l'avertissement est nécessaire plutôt que suffisant : les connexions qui n'atteignent jamais PHP détiennent elles aussi des permis. 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-*. 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 ou SPA). Le résultat est soit un fichier statique, soit un script PHP, soit une 404. Le mode worker, s'il est activé, change la façon dont PHP exécute le script résolu, mais ne modifie pas la résolution de route elle-même.
  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, le client reçoit immédiatement une 529.
  8. Compression. Les réponses textuelles sont compressées avec Brotli avant d'être envoyées lorsque le client envoie Accept-Encoding: br (configurable via COMPRESSION_LEVEL).
  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 processus 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é.

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é. Cela rend /health adapté aux sondes de liveness/readiness de Kubernetes.

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. Le worker qui a planté est automatiquement remplacé par un worker neuf.
  • Redémarrage automatique des workers. OxPHP surveille la santé de tous les workers PHP. Si un worker meurt de façon inattendue, un nouveau worker est démarré à sa place sans intervention manuelle.
  • Protection par contre-pression. La file de requêtes bornée prévient la surcharge. Lorsque le serveur est à pleine capacité, les nouvelles requêtes reçoivent une réponse 529 assortie d'un en-tête Retry-After plutôt que d'être mises 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 d'accepter de nouvelles connexions 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 →