Un WordPress sur Kubernetes qui « rame ». 36 plugins au compteur, dont WPML pour le multilingue et Brevo pour les mails. Côté runtime c’est du PHP 8.3 en mod_php derrière Apache prefork, le tout sur Scaleway Kapsule, avec un MySQL 8 managé en HA, un Valkey pour l’object cache, du NFS pour les uploads, du S3, et Edge Services devant (CDN + WAF).
Le premier truc que tout le monde a envie de faire dans ce cas, c’est de rajouter du CPU. Plus de nodes, des requests plus grosses, un HPA plus agressif. J’ai résisté, j’ai mesuré, et au final le CPU n’avait quasiment rien à voir avec la lenteur. Ce qui coûtait, c’était le réseau entre zones et des plugins qui parlaient beaucoup trop à la base.
Résultats
- Jusqu’à -45 % de temps PHP sur les pages clés :
/login/passe de 166 à 91 ms, une page de listing de 199 à 121 ms (profilé en staging). - 14 à 17 requêtes SQL en moins par page, en corrigeant deux plugins.
- -12 à -15 % de temps de réponse rien qu’en rapprochant les pods de leur cache.
- Résolution DNS 48 fois plus rapide : 7,7 ms → 0,16 ms.
- ~161 €/mois économisés en supprimant un pool de nodes, et 30 % de CPU réservé en moins.
- Zéro CPU ajouté.
Voici comment.
Latence entre zones
Le symptôme qui m’a mis la puce à l’oreille : n’importe quelle requête mettait au moins 0,4 à 0,5 s. Même une 302, qui ne fait rien d’autre que renvoyer un header Location. Et pendant ce temps-là, le CPU des pods se tournait les pouces.
Quand une page vide coûte autant qu’une page pleine et que la machine ne fait rien, c’est qu’on attend quelque chose. Du coup je suis allé regarder où vivait chaque brique.
Le MySQL HA est en fr-par-1. Scaleway garde le primaire et le standby dans le même DC, il n’y a pas de bascule manuelle possible, et la base ne peut pas être déplacée. Donc la base, elle est là où elle est. Valkey et le NFS, eux, étaient en fr-par-2. Et les pods WordPress étaient étalés sur les zones, ce qui est plutôt une bonne pratique en général, sauf qu’ici chaque requête PHP fait des allers-retours vers MySQL et vers Valkey.
J’ai lancé des sondes TCP depuis l’intérieur des pods. Vers MySQL, 0,44 ms dans la même zone contre 2,02 ms en cross-zone. Vers Valkey, 0,30 ms contre 1,81 ms. Pris un par un, ça ne fait pas peur. Sauf qu’une page WordPress avec 36 plugins, ce n’est pas un aller-retour, c’est des dizaines voire des centaines, et chaque milliseconde et demie de plus s’empile.
Le résultat le plus marrant est venu du pod de prod qui tournait en fr-par-1, juste à côté de MySQL. Je m’attendais à ce que ce soit le plus rapide. C’était le plus lent : environ 606 ms sur /, contre à peu près 490 ms pour les autres. Il était collé à la base mais loin de Valkey, et l’object cache fait plus d’allers-retours que le SQL. Bref, sur ce site c’est Valkey qu’il faut avoir sous la main, pas MySQL.
Le fix est assez bête une fois qu’on a compris. Valkey est épinglé en dur sur fr-par-2, WordPress a une affinité preferred pour la même zone, et j’ai gardé l’anti-affinité sur le hostname pour ne pas mettre tous les pods sur le même node. En preferred et pas en required, parce que si fr-par-2 a un souci je préfère un site un peu plus lent qu’un site en Pending.
Sur le staging, en médiane de 3 séries de 10 requêtes :
| Page | Avant | Après | Gain |
|---|---|---|---|
/ |
587 ms | 515 ms | -12 % |
/login/ |
465 ms | 397 ms | -15 % |
| Page de listing | 553 ms | 515 ms | -7 % |
Pas spectaculaire pris isolément, mais c’est gratuit, et ça enlève du bruit pour la suite.
Résolution DNS et ndots
En creusant la latence inter-zone je suis tombé sur un deuxième truc du même genre. Kapsule fait tourner un seul replica de CoreDNS, managé, et il était en fr-par-1. Donc depuis un pod en fr-par-2, chaque résolution de nom traverse les zones.
Et avec le ndots:5 par défaut de Kubernetes, ce n’est pas une requête DNS par nom. Un nom avec moins de cinq points passe d’abord par toute la liste des search domains avant d’être essayé tel quel, avec à chaque fois du A et du AAAA. Résoudre le nom du service Valkey depuis fr-par-2 coûtait 7,7 ms. Avec le FQDN et le point final (valkey.<namespace>.svc.cluster.local.), on tombe à 1,9 ms parce qu’on saute les search domains.
Le point final c’est bien, mais ça ne règle que les noms qu’on contrôle. Pour le reste j’ai mis en place NodeLocal DNSCache, branché via une Local Redirect Policy Cilium : chaque node a son cache, les pods lui parlent en local sans changer leur resolv.conf. Le nom du service Valkey se résout en 0,16 ms, les noms externes autour de 0,6 ms. J’ai vérifié que si un node n’a pas de cache qui tourne, la résolution retombe bien sur CoreDNS, parce qu’un cache DNS qui casse la résolution c’est le genre de truc qui fait une très mauvaise soirée.
Profilage PHP avec SPX
Une fois le réseau propre, il restait le temps passé dans PHP. Pour ça j’ai utilisé SPX, un profiler PHP. Il est compilé dans l’image mais inerte tant qu’on ne l’active pas, et je ne l’active que sur le staging. L’intérêt c’est qu’il attribue le temps par fichier, donc par plugin, et là les coupables sortent tout seuls.
Brevo, en version 3.3.4, vérifiait son schéma de base à chaque requête. SHOW TABLES, SHOW COLUMNS, CREATE TABLE IF NOT EXISTS, des vérifs d’ALTER, au total 14 requêtes SQL par hit, sur des pages qui n’ont rien à voir avec l’envoi de mails. J’ai mis un patch appliqué au build de l’image : les vérifs ne tournent plus qu’une fois par version du plugin, plus une fois par jour au cas où.
Un plugin de reporting, lui, réécrivait une option autoloadée dès que le host ou le scheme de la requête différait de ce qu’il avait en base. Résultat, une écriture en base par requête, et en prime ça invalidait le cache des options autoloadées, donc le hit suivant devait tout recharger. Le fix tient dans un mu-plugin qui pose un filtre pre_option sur cette option, et le plugin arrête de croire qu’il doit la réécrire.
Mesuré avec SPX sur le staging, par requête :
| Page | Requêtes SQL | Temps PHP |
|---|---|---|
| Page de listing | 59 → 42 | 199 → 121 ms |
/login/ |
46 → 32 | 166 → 91 ms |
| Pages REST | 57 → 43 | 191 → 131 ms |
En prod, mesuré depuis l’intérieur du pod, /login/ est passé d’environ 370-390 ms à environ 310-360 ms, avec une médiane autour de 325 ms.
Il reste du boulot de ce côté. Un plugin de communauté fait encore un SHOW TABLES par requête, autour de 6 ms à chaque fois. WPML String Translation pèse 17 à 20 % du temps profilé, avec jusqu’à 30 000 appels au filtre gettext sur une seule requête REST. Et les autoloaders font environ 46 000 recherches de classes par requête, pour lesquelles un autoloader en APCu serait la piste évidente. Rien de tout ça n’est fait pour l’instant.
Steal et throttling CPU
Je voulais quand même fermer la question CPU proprement, parce que c’est le premier suspect de tout le monde.
Les nodes sont des POP2-4C-16G, donc des vCPU dédiés. Sur du vCPU partagé, l’hyperviseur peut donner « ton » temps CPU à une autre VM : c’est le steal. Ça ne se voit pas comme une saturation, ça se voit comme de la variance, des requêtes qui prennent parfois plus longtemps sans raison, des voisins bruyants qu’on ne voit jamais. Pour une stack PHP synchrone où chaque requête bloque un process Apache, la variance de latence fait plus mal que le débit moyen. Si tu as node_exporter, c’est node_cpu_seconds_total{mode="steal"} qu’il faut regarder. Ici j’ai mesuré entre 0,000 et 0,003 cœur de steal par node, autrement dit rien, ce qui confirme que le dédié fait son job. Je n’ai pas d’historique fiable de l’époque où le cluster tournait éventuellement sur du partagé, donc je ne vais pas te vendre un avant/après que je n’ai pas.
L’autre angle classique, c’est le throttling CFS. Quand on pose une limit CPU sur un conteneur, le kernel lui donne un quota de temps CPU par période (100 ms par défaut). Si le conteneur crame son quota en début de période, il attend la période suivante, même si le node est vide à côté. Pour du PHP sensible à la latence, ça veut dire des requêtes qui se prennent des pauses de plusieurs dizaines de ms au milieu de leur exécution, sans que l’usage CPU moyen n’ait l’air élevé. Ça se vérifie avec un truc comme :
rate(container_cpu_cfs_throttled_periods_total{namespace="..."}[5m])
/ rate(container_cpu_cfs_periods_total{namespace="..."}[5m])
Sur ce cluster, la requête ne renvoie rien. Les pods WordPress ont une request CPU de 500m et pas de limit, donc pas de quota CFS, donc pas de throttling possible. C’est exactement la config que je recommande dans mon article sur l’allocation des ressources, et pour une fois je n’ai rien eu à corriger là-dessus.
Et en vrai, l’usage réel est minuscule : p95 à 0,08 cœur par pod, max à 0,22. Sur 14 jours, le cluster entier tourne à 3,25 cœurs en p95 et 6,3 au max. Ajouter du CPU n’aurait strictement rien changé à la latence.
Réduction des coûts
Du coup la question devient plutôt de payer moins. J’ai regardé les POP2-HM-2C-16G, plus petits : 1,8 vCPU allouable par node, il en aurait fallu 7, pour environ 26 €/mois d’économie. Pas la peine de multiplier les nodes pour ça. Par contre j’ai supprimé le pool en fr-par-3, qui coûte 50 % plus cher que les autres zones (0,2205 €/h contre 0,147 €/h pour le même type de node). Il n’existait que pour le quorum de la base Galera qui tournait dans le cluster, et une fois la base passée sur du MySQL managé il ne portait plus aucun volume. Le cluster passe de 5 à 4 nodes, environ 161 €/mois de moins, et vu ce qu’on a appris sur les zones, ces pods n’avaient de toute façon rien à faire loin de Valkey.
Les requests ont aussi été recalées sur l’usage réel des 7 derniers jours : le cluster passe de 13,9 à 9,7 cœurs demandés et de 56 à 46,8 GiB de mémoire, sans impact mesuré.
Autres optimisations
Quelques trucs plus modestes, faits en passant :
- les pages anonymes sont cachées 5 minutes au niveau du CDN ;
- les ExternalSecrets se rafraîchissent toutes les heures au lieu de toutes les 30 secondes, ce qui évite 4,1 millions de lectures Secret Manager par mois ;
- le buffer des interned strings d’OPcache était plein (32 Mo sur 32), passé à 64 Mo, effet faible mais c’était un voyant rouge de moins ;
- le WAF en mode log-only montrait 579 blocages potentiels dont 570 étaient des faux positifs sur les appels d’API de ce plugin de reporting (règle CRS 920420), exclus de la règle.
Pour le contexte Kapsule et ce que ça coûte face à d’autres options, j’en parlais déjà dans ma migration de Kapsule vers Talos sur Hetzner, sur mon infra perso cette fois.
Prochaines étapes
La suite logique serait de passer de mod_php à PHP-FPM, de faire un vrai tuning MySQL, éventuellement d’ajouter un read replica, et de brancher cet autoloader APCu. Mais aucun de ces chantiers n’aurait corrigé le plancher de 400 ms.
C’est ce que je retiens de la mission : la lenteur venait d’allers-retours réseau et de SQL inutile, pas d’un manque de CPU, alors que c’est le CPU que tout le monde aurait tuné en premier. Si ton WordPress sur Kubernetes traîne et que les graphes CPU sont calmes, commence par regarder où vivent tes briques et ce que tes plugins racontent à la base.
Tu veux un deuxième avis sur ton cluster ? C’est ce que je fais pendant un audit de cluster Kubernetes.
Points clés à retenir
- ✓ Le plancher de 400 ms venait des allers-retours entre zones (MySQL en fr-par-1, Valkey en fr-par-2), pas du CPU
- ✓ Le pod collé à MySQL était le plus lent : l'object cache fait plus d'allers-retours que le SQL
- ✓ ndots:5 plus un CoreDNS unique dans une autre zone : 7,7 ms par résolution, 0,16 ms avec NodeLocal DNSCache
- ✓ Brevo et un plugin de reporting faisaient du SQL inutile à chaque requête : /login/ passe de 166 à 91 ms de temps PHP en staging
- ✓ Pas de limit CPU donc pas de throttling, un p95 à 0,08 cœur par pod, et ~161 €/mois économisés en retirant un pool de nodes