Un serveur d'application PHP. libphp est lié dans un binaire Rust, avec un SAPI maison et
hyper au-dessus. Pas de FastCGI, pas de reverse proxy, pas de gestionnaire de processus.
Preuve de concept. C'est testé comme du logiciel de production. 31 cas de conformité comparés octet à octet à php-fpm, 21 injections de fautes, 6 vérifications de streaming, 13 tests unitaires, un test d'endurance de 10 minutes. Tout passe. Ça ne suffit pas à en faire du logiciel de production. Un seul contributeur, aucun historique de déploiement, aucun audit de sécurité, pas de HTTP/3. Pour quelque chose de réel, prends FrankenPHP ou php-fpm.
Symfony 7.2, prod, cache chaud, 6 workers, 64 connexions, médiane de 3 runs.
| stack | route | rps | p50 | p99 | mémoire |
|---|---|---|---|---|---|
| stoke worker | / |
59 386 | 0,96 ms | 4,30 ms | 81 Mo |
| frankenphp worker | / |
24 574 | 2,31 ms | 7,59 ms | 196 Mo |
| stoke worker | /json 100 Ko |
8 971 | 6,47 ms | 18,31 ms | 83 Mo |
| frankenphp worker | /json 100 Ko |
7 278 | 8,50 ms | 16,31 ms | 199 Mo |
| stoke classic | / |
4 857 | 13,29 ms | 28,82 ms | 50 Mo |
| php-fpm + nginx | / |
4 046 | 15,53 ms | 25,93 ms | 40 Mo |
CPU par requête : stoke 105 µs, FrankenPHP 252 µs, php-fpm 1 952 µs. La p99 sur les grosses réponses change de camp d'un run à l'autre, donc considère ces deux-là comme à égalité.
Mesuré dans une VM Docker Desktop sur Apple M4. Les chiffres ne valent qu'en relatif. Toutes les stacks ont tourné dans le même conteneur, sur la même application, dans la même session.
PHP tourne sur le thread auquel hyper remet la requête. Pas de canal, pas de réveil. Le moteur n'est pas thread-safe, donc le serveur duplique le processus au lieu d'utiliser des threads.
L'app est bootée avant le fork. Les workers partagent son tas en copie sur écriture : un
worker coûte 38 Mo, seize en coûtent 44.
$_SERVER ne change presque pas d'une requête à l'autre, donc il est copié depuis une table
de hachage persistante au lieu d'être réenregistré entrée par entrée.
docker compose -f docker/compose.yml up -d dev
docker compose -f docker/compose.yml exec dev cargo build --release
STOKE_ROOT=/app/public stoke # classic
STOKE_ROOT=/app/public STOKE_WORKER_SCRIPT=/app/worker.php stoke # workerUn script worker renvoie un callable :
<?php
$kernel = new Kernel('prod', false);
$kernel->boot();
return function () use ($kernel) {
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};Les scripts worker écrits pour FrankenPHP tournent sans modification.
frankenphp_handle_request($handler) est accepté et récupère le callable.
Renvoie un tableau à la place pour avoir un hook de démarrage par worker. Il tourne une fois
dans chaque processus dupliqué. C'est là qu'on rouvre ce que le bootstrap d'avant le fork a
laissé partagé :
return [
'on_worker_start' => fn () => $pool->reconnect(),
'handler' => fn () => $kernel->handle(Request::createFromGlobals())->send(),
];Les réglages viennent de l'environnement. STOKE_CONFIG désigne un fichier qui utilise les
mêmes noms sans le préfixe (workers = 4). L'environnement l'emporte sur le fichier.
| variable | défaut | |
|---|---|---|
STOKE_LISTEN |
0.0.0.0:8080 |
|
STOKE_ROOT |
/app/public |
racine documentaire |
STOKE_WORKERS |
nombre de cœurs | processus workers |
STOKE_WORKER_SCRIPT |
— | active le mode worker |
STOKE_FRONT_CONTROLLER |
— | script de repli en mode classic |
STOKE_TLS_CERT / STOKE_TLS_KEY |
— | active TLS, ALPN négocie HTTP/2 |
STOKE_STREAM |
0 |
envoie la sortie au fil de l'eau. Nécessaire pour SSE et long polling, coûte environ 10 % de débit |
STOKE_STATIC |
1 |
sert les fichiers statiques |
STOKE_MAX_BODY |
8 Mio | taille maximale du corps de requête |
STOKE_LOOP_MAX |
0 |
recycle un worker après N requêtes |
STOKE_PREFORK_BOOT |
1 |
boote l'app avant le fork, pour que les workers partagent sa mémoire |
STOKE_INI |
— | directives php.ini supplémentaires, séparées par des virgules |
STOKE_CONFIG |
— | chemin d'un fichier de réglages |
HTTP/1.1 et HTTP/2 partagent le même port, avec ou sans TLS.
Les suites de tests se lancent avec sh tests/run-all.sh dans le conteneur de dev.
HTTP/3 et les certificats automatiques ne sont pas prévus. C'est le boulot du proxy qu'on met devant. Pas la peine de refaire Caddy.
Deux trucs à savoir. Avec STOKE_STREAM=1, un client qui arrête de lire bloque son worker
jusqu'à max_execution_time. Tous les serveurs sans tampon font pareil. Avec
STOKE_PREFORK_BOOT=1, les workers partagent ce que le bootstrap a ouvert. Le hook
on_worker_start sert à le rouvrir, sinon mets le drapeau à 0.