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 :
- 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.
- 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.