, , ,

15 min de lecture

Test de charge FrankenPHP : ce que k6 a vraiment mesuré

k6 test de charge

Introduction

Monter une infra qui tient la charge, c’est une série de décisions prises bien avant d’écrire la première ligne de scénario k6. Le runtime : share-nothing process-per-request, ou worker mode persistant ? L’hébergement : une machine qu’on grossit, ou des nœuds qu’on ajoute ? La session : sur le disque local, ou dans un stockage partagé ? Chacune de ces décisions ne supprime pas le goulot d’étranglement, elle le déplace ailleurs.

Et « ailleurs », c’est rarement là où on regarde. Sur un site qui convertit, le parcours ne s’arrête pas au HTML rendu : il y a la base sous les écritures, les verrous métier sur les stocks, le pool SMTP qui doit sortir les confirmations. Un test qui ne tape que des pages de navigation te renverra des chiffres flatteurs et faux.

Le corollaire, c’est qu’un test de charge sur une infra mal pensée ne règle rien. Le test de charge FrankenPHP que je raconte ici n’a pas rendu l’environnement plus rapide d’un millième de seconde. Il m’a dit où ça cassait, et à partir de quel débit. Quand tu as une date de diffusion TV dans le calendrier et aucun droit à l’erreur, c’est exactement ce dont tu as besoin. Ce n’est pas un classement d’hébergeurs, et encore moins un énième « FrankenPHP contre PHP-FPM ».

Le use case classique

Pour cet environnement, le VPS seul était exclu. Pas parce que FrankenPHP y tournerait mal il tourne très bien sur une machine unique mais parce qu’un VPS ne sait grandir que dans un sens : tu ajoutes du CPU et de la RAM à la même machine, et quand tu arrives en haut de la grille, tu t’arrêtes. Face à un pic dont tu ne connais pas l’amplitude exacte, ce plafond n’est pas négociable. Ce qu’il fallait, c’était un environnement conteneurisé où on ajoute des nœuds.

L’hébergeur est Hidora, sur du Jelastic Cloud un PaaS qui gère les deux axes en même temps : le vertical par cloudlets, le horizontal par ajout de nœuds. Ce n’est pas du Kubernetes avec une interface graphique, c’est une autre architecture, et la distinction a son importance quand on lit les métriques de scaling pendant un test.

Devant, un load balancer nginx. Derrière, un conteneur FrankenPHP en worker mode qui fait tourner un Symfony 7.4. Le choix de la LTS n’est pas une préférence : sur un projet critique avec une date de diffusion TV dans le calendrier, tu ne veux pas être sur une branche standard supportée huit mois. Symfony 8 attendra.


Les faits

Un test k6 ne mesure pas une infra, il simule un comportement. On charge un parcours, on monte le débit par paliers, et on cherche le point où ça casse. Rien de plus. Ce que tu obtiens à la fin, c’est un chiffre valable pour cette cible, ce mix de routes, ce mode de cache et cette fenêtre de test pas une note de performance générale.

Et il y a un piège que les tutoriels ne mentionnent jamais : en scaling horizontal, les nouvelles instances ne sont pas disponibles à l’instant où le seuil se déclenche. Il faut provisionner le nœud, démarrer le conteneur, passer les health checks, et attendre que le load balancer le prenne en compte. Pendant tout ce temps, la charge tape sur les nœuds existants.

Baisser les seuils de déclenchement ne résout pas ça. Un seuil bas te fait démarrer plus tôt, il ne raccourcit pas le délai de mise en service. Et même une fois le nœud en ligne, rien ne garantit que le trafic s’y répartisse immédiatement : les connexions keep-alive déjà établies restent sur les nœuds d’origine et ne migrent pas d’elles-mêmes. Tu as ajouté de la capacité qui ne sert pas encore.

C’est exactement ce qu’un pic TV met à l’épreuve. Une montée en charge progressive laisse au scaling le temps de suivre. Une audience qui arrive en quatre-vingt-dix secondes, non.

Et si le problème ne venait pas de l’infra ?

Le plafond à 130 req/s n’existait pas

Premier escalier, le 4 septembre. Paliers de trois minutes, mix de navigation, cache edge. Jusqu’à 130 req/s, la p95 reste plate autour de 300 ms. À 135, elle explose à 4476 ms, avec 73 itérations abandonnées. À 140, 5373 ms et 641 abandons. J’ai noté « capacité soutenable : 130 req/s », et j’ai failli en rester là.

Ce qui m’a gêné, c’est la forme de la courbe. Une infra qui sature par manque de CPU se dégrade progressivement : la latence s’incurve, les files s’allongent, on voit venir. Là, j’ai un palier parfaitement plat suivi d’une falaise. Et surtout, quel que soit le débit demandé au-dessus, le débit observé plafonnait à ~135 req/s. Ce n’est pas la signature d’un serveur qui rame, c’est celle d’un tuyau plein.

La réponse était dans la décomposition de http_req_duration. k6 la découpe, et il faut le faire dès qu’un chiffre surprend :

  • waiting p95 : 498 ms — le temps que le serveur met à produire la réponse
  • receiving p95 : 3275 ms — le temps à la télécharger

Le serveur répondait en un demi-seconde. Les quatre secondes restantes, c’était du transfert. Je n’avais pas mesuré Symfony, ni FrankenPHP, ni Jelastic : j’avais mesuré ma connexion.

La cause tient en une ligne manquante. Mon scénario n’envoyait pas d’en-tête Accept-Encoding. Sans lui, pas de compression : 79 KB par requête au lieu de 14,9 KB, soit 5,3 fois le volume qu’un navigateur réel télécharge. À 135 req/s, ça réclame environ 85 Mbit/s au poste depuis lequel je lançais les tests. Il plafonne là. Le mur était sur mon bureau.

Une fois l’en-tête ajouté, le même environnement a tenu 300 req/s pendant trente minutes avec une p95 à 78 ms, et un TTFB identique à 100 et à 300 req/s 41,0 ms contre 41,9 ms. Aucune mise en file d’attente. Le plafond que j’avais documenté n’existait tout simplement pas.

Et le générateur a récidivé plus tard, autrement. Sur le run final, 2,06 % d’erreurs. Côté machine locale : 1853 connexions établies pour une plage de 16 384 ports éphémères, saturée par le recyclage en TIME_WAIT. La preuve est venue d’ailleurs mon monitoring vers un hôte sans aucun rapport avec la cible a timeouté au même instant, alors qu’un curl isolé vers le site répondait en 0,12 s. Ce n’était pas le site qui tombait, c’était ma pile réseau.

La règle que j’en tire : avant de conclure quoi que ce soit sur la cible, prouver que le générateur n’est pas le facteur limitant. Concrètement, trois vérifications qui coûtent cinq minutes décomposer waiting / receiving à chaque palier, surveiller le débit réseau et le nombre de sockets du poste, et comparer le débit demandé au débit observé. Si le second décroche du premier, tu mesures ton propre matériel.


Les médias, oui mais sous charge alors ?

Pour cet environnement, le stockage est du S3 : les vignettes de séances et les vidéos ne passent pas par l’application. Le tunnel renvoie des URLs pointant vers un hôte dédié, servi par son propre load balancer, distinct de celui de l’applicatif. L’objectif était de décharger FrankenPHP de tout ce qui n’est pas du rendu, et d’avoir un étage qui monte en charge indépendamment du reste.

Sauf que ce chemin n’apparaissait dans aucun scénario. k6 ne suit pas les sous ressources : il récupère le HTML, pas les images que le navigateur ira chercher ensuite. J’avais donc une partie du trafic visiteur réel qui n’était mesurée nulle part. Il a fallu un run dédié.

Résultat sur un palier de 100 req/s tenu cinq minutes : p95 à 101 ms, zéro erreur, 30 003 requêtes servies. Le nœud qui sert les médias monte à 23,9 % de sa limite. Et derrière lui, le stockage objet est à 0,7 %. Il sort 7,2 MB/s vers l’extérieur pour seulement 0,15 MB/s de trafic interne vers le backend — 2 %. Autrement dit, le cache absorbe 98 % des requêtes et le stockage n’est quasiment jamais sollicité.

L’autre mesure, celle que je n’attendais pas, c’est l’étanchéité. Pendant que l’étage médias tourne à fond, les nœuds applicatifs et le load balancer principal sont strictement à zéro. Pas « faible » : zéro. Et l’inverse est vrai aucun run applicatif n’a jamais touché le nœud médias. La séparation des étages n’est pas une intention d’architecture qu’on suppose tenue, c’est quelque chose qu’on peut mesurer. Une saturation d’un étage ne peut pas contaminer les autres.

Ordre de grandeur pour finir : à une ou deux vignettes par parcours, un pic à 150 parcours/s génère 150 à 300 req/s sur cet étage, soit 36 à 72 % de sa capacité. C’est le nœud qui a le moins de marge absolue de toute l’infrastructure. Il ne fait pourtant que servir du cache.

Les tests, et un correctif simple

En worker mode, tout se joue sur le dimensionnement du pool. Le premier run en charge mixte a montré un comportement que la navigation seule ne laissait pas voir : avec 4 workers par nœud, 4 réservations par seconde occupaient 58 % du pool. Les acheteurs mangeaient les workers, et les visiteurs qui se contentaient de naviguer payaient l’addition — la p95 de navigation passait de 97 ms pendant le pic sans écriture à 301 ms dès que les écritures démarraient.

Le correctif tient en une variable d’environnement : 4 workers par nœud, puis 12. Pour trancher entre ce qui relevait de la contention et ce qui est intrinsèque, j’ai rejoué exactement le même scénario, mêmes conditions, avant et après.

Mesure4 workers/nœud12 workers/nœudÉcart
Navigation p95, charge mixte301 ms86 ms−71 %
booking_send p951743 ms1712 ms−2 %
PostgreSQL58,9 %53,3 %
Nœuds applicatifs30,8 %31,0 %

La navigation retrouve le niveau qu’elle avait pendant le pic sans écriture. La famine de pool était donc réelle, et elle est corrigée : le plafond côté workers passe d’environ 7 à environ 21 réservations/s. Coût de l’opération : rien. Les nœuds utilisaient 145 MB sur 3 GB disponibles.

Mais booking_send n’a pas bougé d’un pouce. Ces 1,7 seconde ne sont pas de l’attente de worker, c’est le coût intrinsèque de la requête. Et pendant ce temps, aucune ressource n’est saturée : PostgreSQL à 53 %, applicatif à 31 %. Le temps part dans du travail synchrone à l’intérieur de la requête.

Un piège avant de toucher à ça. Chaque worker occupe un thread en permanence. Monter FRANKENPHP_NUM_WORKERS sans monter FRANKENPHP_NUM_THREADS au-dessus fige l’application : les requêtes PHP pendent indéfiniment pendant que /healthz continue de répondre 200. Tu as un service mort que ton monitoring déclare en bonne santé.

test de charge K6

pour illustration

La seule limite d’infra est sur l’écriture

Sur le chemin de lecture, je n’ai jamais trouvé de plafond. 300 req/s sur les pages publiques pendant trente minutes, 150 parcours/s sur le tunnel complet, sans qu’aucun nœud dépasse 37 % de sa limite de cloudlets. La mémoire des nœuds applicatifs est restée stable sur les deux soaks de trente minutes pas de fuite en worker mode, ce qui est la première chose que je voulais vérifier avant de laisser tourner ça pendant une diffusion.

Le détail qui a le plus changé ma façon de dimensionner : les IOPS mesurées étaient à zéro sur les seize échantillons du chemin de lecture. Absolument tout sort du cache. Un scaling vertical pour l’I/O aurait été de l’argent dépensé pour rien, et c’est exactement le réflexe qu’on a quand on voit « base de données » et « pic de trafic » dans la même phrase.

L’écriture, c’est une autre histoire. Le dernier run a comparé les deux régimes sur la même infrastructure, à quelques minutes d’intervalle :

  • 150 parcours/s en lecture seule, soit 750 requêtes/s → PostgreSQL à 20,5 %
  • 4 réservations/s, plus 36 parcours/s de navigation → PostgreSQL à 58,9 %

Quatre écritures par seconde coûtent près du triple de sept cent cinquante lectures par seconde. Ramené à l’unité, une réservation/s pèse environ quatre vingt dix fois un parcours de lecture/s. Le chiffre exact importe moins que l’ordre de grandeur et surtout, il ne se devine pas depuis les métriques de navigation. Tu peux passer une campagne entière à regarder des p95 confortables sur ton tunnel et ne rien voir venir.

Extrapolé linéairement, le plafond arrive vers 7 à 8 réservations par seconde. C’est le seul endroit de toute l’infrastructure où ajouter des cloudlets PostgreSQL sert à quelque chose.

Deux choses que ce run a écartées, au passage. Aucune contention de verrous sur l’allocation des places : 2401 réservations créées, zéro refusée, ce qui était ma crainte principale sur un système de réservation de sièges. Et les 1,7 seconde de booking_send ne viennent pas de la base non plus, puisqu’elle est à 53 % pendant ce temps treize fois plus lent que l’étape de lecture la plus lourde, et déjà à 4 req/s, donc ce n’est pas de la mise en file d’attente mais le coût de la requête elle-même.

Reste à traduire ça en scénario réel. Un passage TV qui génère 5000 réservations étalées sur vingt-cinq minutes, c’est 3,3 réservations/s : ça passe avec de la marge. Un pic de conversion très concentré 55 % en trois minutes, soit 15 réservations/s ne passerait pas. Ce qui m’a rassuré, c’est que les conversions se répartissent naturellement mieux que les pages vues : le visiteur qui regarde arrive en quatre-vingt dix secondes, celui qui achète prend le temps de remplir un formulaire.

Le vrai goulot n’était pas dans l’infrastructure

Le fournisseur de mails plafonne à 500 e-mails par fenêtre glissante de 24 heures, comptés par destinataire. Un passage générant 1500 réservations laisse donc deux tiers des clients sans confirmation silencieusement, puisque le tunnel répond success: true et que la réservation est bien en base. L’infrastructure encaisse dix fois ce que le contrat mail autorise.

Et pendant la campagne, une faute de frappe dans MAILER_DSN a fait tomber tout le tunnel : null:// au lieu de null://null. L’hôte est vide, l’exception est levée à la construction du service, et tout contrôleur dont le graphe de dépendances touche le mailer répond 500. Le tunnel de réservation et la boutique tombent pendant que /fr/faq et /healthz répondent 200. Un monitoring qui ne surveille que la santé du process ne voit rien.


L’après passage TV

Les chiffres du jour J viennent de Google Analytics, avec la réserve qui va avec : seuls les visiteurs ayant accepté les cookies sont comptabilisés. La mesure est donc un plancher, pas une valeur exacte.

Sur cette base, +451 % de trafic par rapport à une journée normale. Le site a tenu, les commandes sont passées, les paiements aussi, sans interruption.

Ce qui a rendu ça possible, ce n’est pas un réglage héroïque le jour même. C’est l’enchaînement : une architecture qui sépare vraiment ses étages, un dimensionnement établi par la mesure plutôt qu’à l’estime, et une plateforme qui encaisse le pic sans qu’on ait à intervenir Hidora a joué son rôle, et c’est le genre de chose qu’on ne remarque que quand ça se passe mal.

Conclusion

FrankenPHP en worker mode fait ce qu’on lui demande. Un projet Symfony encaisse des variations de trafic majeures, avec une gestion nettement plus simple qu’un pool PHP-FPM à accorder. Sur toute cette campagne, le runtime n’a jamais été le facteur limitant : sur les trois vraies limites trouvées, une seule relevait de l’infrastructure, et les deux autres étaient une faute de frappe dans un DSN de test et un quota de boîte mail.

PostgreSQL sature vers 7 à 8 réservations par seconde. Rapporté au métier, c’est beaucoup les équipes n’auraient jamais absorbé un tel afflux de commandes en interne. Le dimensionnement ne visait pas le record, il visait à ne pas perdre de client pendant les vingt minutes qui comptaient.

Reste la question que je n’ai pas tranchée : jusqu’où pousse-t-on le raisonnement Ops ? On aurait pu sortir l’écriture du flux synchrone et passer les commandes par un broker. Ça déplace le goulot de PostgreSQL vers une file qu’on peut consommer à son rythme, et ça rend les 1,7 seconde de soumission sans objet. Ça ajoute aussi un composant à exploiter, une sémantique de retry à écrire, et un état « commande acceptée mais pas encore confirmée » à faire comprendre au métier. Sur cette base, impossible de scaler horizontalement sur l’écriture : le seul levier disponible était de faire grossir le nœud avant la diffusion. C’est ce que k6 a permis de décider, avec un chiffre plutôt qu’une intuition.

Si ce retour terrain vous donne un endroit où aller regarder dans votre propre infrastructure, commencez par le plus bête : mesurez ce que fait votre générateur de charge avant de conclure quoi que ce soit sur votre serveur.