Aller au contenu principal

Dimensionner PHP-FPM sur Kubernetes : le throttling invisible

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 12/06/2026
schedule 12 min lecture
Un processeur bridé par un quota ralentit une application conteneurisée malgré des ressources disponibles

Le ticket n'a l'air de rien : « l'application est lente ». Pas d'erreur dans les logs, pas de pic de trafic, pas de déploiement récent. Les dashboards sont verts. Et le graphe de consommation CPU affiche un pod tranquille, très loin de sa limite. Pourtant, chaque page met plusieurs secondes à répondre — et c'est toute l'application qui est concernée, pas un écran isolé.

Ce paradoxe — une application lente alors que le CPU semble à peine utilisé — est le symptôme typique d'un phénomène que les outils de supervision classiques ne montrent pas : le CPU throttling. Le pod n'utilise pas le processeur parce qu'il n'a pas le droit de l'utiliser. Ce retour d'expérience se déroule sur la même application Symfony, déployée sur OpenShift pour un grand opérateur télécom, dont je décrivais récemment comment l'image Docker de production est testée de bout en bout puis déployée en journée sans couper le service. Ce troisième volet descend d'un étage : l'image est validée, le déploiement est invisible — encore faut-il que les containers aient les moyens de travailler. Au menu : un diagnostic en une commande, deux goulots d'étranglement qui se masquaient mutuellement, et un passager clandestin embarqué avec un paquet Debian. Résultat final : de 89 % de fenêtres CPU throttlées à 0 %, sans ajouter un seul nœud au cluster.

Pourquoi les graphes CPU ne voient rien

Pour comprendre le symptôme, il faut comprendre comment Kubernetes limite le CPU. Une limite CPU n'est pas un plafond de vitesse continu : c'est un quota de temps par fenêtre. L'ordonnanceur Linux (CFS, Completely Fair Scheduler) découpe le temps en fenêtres de 100 ms. Une limite de 128m — 0,128 CPU — donne au container 12,8 ms de calcul par fenêtre de 100 ms. Quota épuisé ? Tous les processus du container sont gelés jusqu'à la fenêtre suivante. Au milieu d'une requête, au milieu d'une boucle, au milieu de n'importe quoi.

Concrètement : un rendu de page qui demande 300 ms de calcul reçoit au mieux 12,8 ms tous les dixièmes de seconde, et s'étale donc sur plus de deux secondes d'horloge. Et c'est le cas idéal d'une requête seule — dès que plusieurs requêtes se partagent le même quota, l'attente se multiplie d'autant.

Voilà pourquoi le monitoring est aveugle : le graphe CPU trace la consommation, et la consommation est plafonnée par le quota. Un pod throttlé à 89 % affiche sereinement un usage très bas — il voudrait consommer dix fois plus, mais cette demande refusée n'apparaît sur aucune courbe standard. Le graphe ne ment pas, il répond juste à la mauvaise question : il montre ce que le pod a obtenu, pas ce qu'on lui a refusé.

Le diagnostic en une commande

La bonne question, c'est le noyau Linux qui y répond, depuis l'intérieur du container, dans la comptabilité du cgroup :

$ oc exec deploy/app -- cat /sys/fs/cgroup/cpu.stat
usage_usec     69234712      # 69 s de CPU réellement consommées
nr_periods     5787          # fenêtres CFS de 100 ms observées
nr_throttled   5149          # fenêtres où le container a été gelé
throttled_usec 1157431892    # 1 157 s passées à attendre le droit de calculer

Deux rapports suffisent à qualifier l'incident. nr_throttled / nr_periods : 89 % des fenêtres ont été interrompues par le quota. Et le rapport throttled_usec / usage_usec est encore plus parlant : pour chaque seconde de calcul réellement effectuée, l'application a passé 16 secondes à attendre qu'on l'autorise à calculer. Ce n'est pas une application lente — c'est une application à l'arrêt forcé 94 % du temps.

La cause était une limite CPU à 128m sur le pod applicatif. Une valeur posée des années plus tôt, probablement raisonnable pour l'application de l'époque, jamais revue depuis. Personne n'avait « cassé » la production : elle s'était simplement alourdie, requête après requête, jusqu'à déborder un quota que plus personne ne regardait.

Le double goulot qui se masquait

Corriger la limite CPU était nécessaire — mais pas suffisant, et c'est le point le plus intéressant de ce diagnostic. Car derrière Apache, le pool PHP-FPM était configuré avec pm.max_children = 10 : dix processus workers au maximum, donc dix requêtes PHP simultanées, pendant qu'Apache en acceptait 150 en frontal (MaxRequestWorkers). La onzième requête attend qu'un worker se libère.

Les deux goulots se masquaient mutuellement, et c'est précisément pour cela que le problème a survécu si longtemps :

  1. Augmenter le CPU sans toucher à FPM — les dix workers répondent plus vite, mais la file d'attente devant le pool persiste dès que la concurrence dépasse dix. La latence baisse un peu, le plafond de débit reste.
  2. Augmenter les workers sans toucher au CPU — vingt processus se partagent le même quota de 12,8 ms : chacun est throttlé deux fois plus. La situation empire.

Quiconque avait tenté de régler l'un des deux paramètres isolément avait pu conclure, mesures à l'appui, que « ça ne change rien » — et reposer le dossier. C'est une leçon qui dépasse ce cas précis : quand deux goulots se suivent dans un pipeline, corriger un seul des deux est invisible. Il faut viser la métrique de bout en bout — ici le temps de réponse — et instrumenter chaque étage, plutôt que de tourner un bouton au hasard.

Donner au pod les moyens : requests et limits

Premier étage de la correction, les ressources du pod applicatif — des valeurs choisies d'après la mesure, pas au doigt mouillé :

resources:
  requests:
    cpu: 512m       # ce que le scheduler réserve
    memory: 1Gi
  limits:
    cpu: '2'        # 128m avant — le plafond de burst
    memory: 2Gi     # 512Mi avant

Le ratio limites/réservations d'environ 4 est un choix délibéré : les requests reflètent la charge de croisière et n'épuisent pas le quota du namespace, les limits autorisent les bursts — un cache à reconstruire, un export, une heure de pointe — sans pénaliser les voisins en temps normal. Supprimer purement et simplement les limites CPU est une école qui existe ; sur un cluster mutualisé entre plusieurs équipes, garder un plafond généreux est un compromis plus diplomatique. Le vrai problème n'a jamais été d'avoir une limite — c'était d'en avoir une dimensionnée à l'aveugle, seize fois trop petite.

Dimensionner le pool FPM — et payer la facture mémoire

Deuxième étage, le pool de workers, doublé pour suivre :

pm = dynamic
pm.max_children = 20        ; 10 avant — le plafond de concurrence PHP
pm.start_servers = 5        ; 2 avant
pm.min_spare_servers = 3    ; 1 avant
pm.max_spare_servers = 8    ; 3 avant
pm.max_requests = 500       ; 1000 avant — recycler plus souvent

Le détail contre-intuitif : pm.max_requests baisse. Chaque worker est recyclé après 500 requêtes au lieu de 1 000, pour contenir la croissance mémoire résiduelle d'un long processus Symfony/Doctrine. Le fork d'un worker neuf coûte quelques millisecondes toutes les 500 requêtes — une assurance bon marché contre la dérive.

Mais doubler les workers n'est pas gratuit, et c'est l'équation que beaucoup de tunings FPM oublient d'écrire : chaque worker est un processus PHP complet. Mesuré en production sur cette application, environ 60 Mo par worker. Le budget mémoire du container s'écrit donc noir sur blanc : 20 workers × ~60 Mo, plus 256 Mo d'OPcache en mémoire partagée, plus Apache et le système — la limite mémoire du pod doit suivre, d'où le passage à 2 Gi. Augmenter pm.max_children sans toucher limits.memory, c'est échanger du throttling CPU contre des OOM kills : le container ne ralentit plus, il meurt. Difficile d'appeler ça un progrès.

OPcache : dimensionné sur les vrais chiffres

Pendant qu'on tient le capot ouvert, un troisième réglage : OPcache, le cache de bytecode qui évite à PHP de recompiler chaque fichier à chaque requête.

opcache.memory_consumption = 256        ; 128 avant
opcache.interned_strings_buffer = 16    ; 8 avant
opcache.max_accelerated_files = 20000   ; 10000 avant
opcache.validate_timestamps = 0         ; l'image est immuable, ne rien revérifier
realpath_cache_size = 4096K             ; 256K avant

La valeur décisive est max_accelerated_files. Une application Symfony + API Platform de taille moyenne dépasse allègrement les 10 000 fichiers PHP une fois le vendor compté — il suffit de lancer find . -name '*.php' | wc -l pour s'en convaincre. Sous ce seuil, OPcache évince en boucle : chaque fichier mis en cache en expulse un autre, qui sera recompilé à la prochaine requête, silencieusement, indéfiniment. Le cache existe, il est juste trop petit pour servir — une dégradation que rien ne signale, sinon les compteurs d'éviction du cache lui-même, que personne ne regarde spontanément.

Quant à validate_timestamps = 0, c'est le réglage que la conteneurisation rend enfin trivial : le code d'une image Docker ne change jamais après le build, vérifier la fraîcheur des fichiers à chaque requête est un pur gaspillage. Un déploiement remplace le pod entier, cache compris — le rolling update fait office d'invalidation.

Le passager clandestin : un Apache que personne n'avait choisi

Une fois le throttling éliminé, une seconde passe d'audit de l'image a révélé un piège plus sournois, parce qu'il ne vient d'aucune décision : installer le paquet Debian libapache2-mod-php force Apache en mpm_prefork, le mode de fonctionnement historique où chaque connexion HTTP occupe un processus entier. C'est un effet de bord de l'installation du paquet — aucun fichier de configuration du projet ne le mentionne, aucune revue de code ne peut le voir.

Or sur cette image, tout le trafic PHP passe par FPM : mod_php est chargé pour rien, et le prefork qu'il impose fait très mal avec les connexions persistantes. Un keep-alive HTTP garde sa connexion ouverte 5 secondes par défaut après chaque réponse — en prefork, c'est un processus Apache complet qui reste mobilisé pendant ces 5 secondes, à ne rien faire. Sous trafic réel, une part significative de la capacité d'Apache sert des connexions idle au lieu de requêtes.

# avant : libapache2-mod-php a imposé mpm_prefork à l'installation
RUN a2dismod -fp autoindex status mpm_prefork php8.4 \
 && a2enmod mpm_event proxy_fcgi alias

Le passage à mpm_event — des threads événementiels, conçus pour garder des milliers de connexions ouvertes à coût quasi nul — rend le débit indépendant du keep-alive. Les workers FPM redeviennent l'unique facteur limitant, ce qui était l'intention du dimensionnement précédent. La vérification tient en une ligne, à inscrire dans la checklist post-déploiement :

$ oc exec deploy/app -- apache2ctl -V | grep MPM
Server MPM:     event

La même passe d'audit a épinglé un cousin de ce piège : un COPY --from=composer:latest dans le build multi-stage. Même famille — un comportement implicite qui évolue sans qu'aucun commit du projet n'en décide. Reconstruire l'image six mois plus tard n'embarque plus le même Composer, et le jour où une version majeure sort, tous les builds cassent d'un coup, partout, sans changement de code. Épinglé en composer:2 : la reproductibilité d'une image se joue aussi dans ces détails-là.

Re-mesurer, toujours

Un REX de performance sans mesure finale est une anecdote. Après déploiement, la même commande, sur le même pod, sous trafic réel — et une période d'observation longue, pas cinq minutes de complaisance :

$ oc exec deploy/app -- cat /sys/fs/cgroup/cpu.stat
nr_periods     4936383
nr_throttled   31            # 0,0 % des fenêtres (89 % avant)
throttled_usec 706166        # 0,7 s de throttling cumulé (1 157 s avant)

31 fenêtres throttlées sur 4,9 millions. Côté mémoire, le pod croise à 400 Mo avec des pointes à 515 Mo, pour une limite à 2 Gi : les vingt workers ont la place de respirer, et la marge absorbe les bursts sans flirter avec l'OOM. Le temps de réponse de l'application, lui, est redevenu ce qu'il aurait toujours dû être — au point que le sujet a disparu des conversations, ce qui reste le meilleur indicateur de clôture d'un incident de performance.

Cette mesure n'est pas un événement ponctuel : le diagnostic complet — throttling, mémoire, statut FPM, compteurs OPcache — est scripté et rejoué périodiquement sur tous les environnements. C'est lui qui a confirmé la disparition du throttling, et c'est lui qui repérera le prochain goulot avant qu'un ticket « l'application est lente » ne le fasse.

Conclusion

Le bilan tient en peu de lignes : une limite CPU multipliée par seize, un pool FPM doublé, un OPcache dimensionné sur le nombre réel de fichiers, un MPM Apache corrigé. Aucun nœud ajouté au cluster, aucune refonte, aucun outil exotique — l'application avait déjà tout ce qu'il fallait pour être rapide, elle n'avait simplement pas le droit de s'en servir.

Si cette histoire a une morale, c'est que le dimensionnement n'est pas une configuration, c'est un processus. Des valeurs posées un jour, justes ce jour-là, deviennent fausses en silence pendant que l'application grossit. Aucun graphe standard ne signale un quota CFS trop étroit, un OPcache qui évince en boucle ou un MPM imposé par un paquet Debian : il faut aller poser la question au noyau, worker par worker, cache par cache. Une après-midi de mesure et quelques lignes de configuration ont rendu à cette application des secondes sur chaque page — le ratio effort/impact le plus favorable qu'on puisse trouver dans une application en production. Encore faut-il savoir que la question se pose.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi mon monitoring ne montre-t-il pas le CPU throttling ? expand_more
Parce que les graphes CPU tracent la consommation, qui est précisément plafonnée par le quota : un pod très throttlé affiche un usage bas. Il faut regarder la demande refusée — le fichier cpu.stat du cgroup, ou la métrique container_cpu_cfs_throttled_periods_total si Prometheus est en place.
Faut-il supprimer les limites CPU, comme certains le recommandent ? expand_more
C'est défendable sur un cluster qu'on maîtrise entièrement, mais sur un cluster mutualisé entre équipes, une limite généreuse protège les voisins. Le vrai problème n'est pas d'avoir une limite : c'est d'en avoir une dimensionnée à l'aveugle et jamais revue. Un ratio limites/réservations d'environ 4 laisse de la place aux bursts.
Comment choisir la valeur de pm.max_children ? expand_more
Par la mémoire d'abord : mesurer la taille réelle d'un worker en production (ici ~60 Mo), puis diviser la mémoire disponible après OPcache, Apache et le système. Vérifier ensuite que le quota CPU suit : plus de workers sur le même quota signifie plus de throttling, pas plus de débit.
Cette démarche s'applique-t-elle hors OpenShift ? expand_more
Oui. Le throttling CFS et le fichier cpu.stat sont du Linux standard : tout Kubernetes, et même un simple Docker avec une limite CPU, se diagnostiquent de la même façon. Seul l'outillage change — kubectl exec remplace oc exec.