jguillaumesio
cloud-opsdevopsaws

Le CPU ment : autoscaler un service mono-thread

Un service ramait sous charge, mais l'autoscaling ne se déclenchait jamais parce que le CPU restait à 30 pour cent. Une app mono-thread sur une task multi-cœur sature un cœur et paraît à peine occupée. Pourquoi, et comment corriger.

Le service ramait. Pas à terre, juste lent : la latence p95 grimpait bien au-delà du seuil de perception des utilisateurs, les requêtes s’accumulaient, le genre de dégradation qui génère des tickets de support plutôt que des alertes. Et l’autoscaler, dont tout l’intérêt est d’ajouter de la capacité quand un service souffre, restait là à ne rien faire.

La métrique qu’il surveillait disait que tout allait bien. L’utilisation CPU moyenne des tasks tournait autour de 30 pour cent, loin du seuil de scale-out. Le dashboard était serein. Les utilisateurs, non. Les deux avaient raison, et l’écart entre les deux est l’un des pièges d’autoscaling les plus courants sur une plateforme de conteneurs.

Voici le premier article d’une série sur l’exploitation d’un SaaS multi-tenant sur AWS à l’échelle d’une équipe. Il parle d’une métrique qui ment, discrètement, par conception.

Pourquoi 30 pour cent de CPU voulait dire 100 pour cent occupé

Le service était une application mono-thread. Une API Node.js, en l’occurrence, mais c’est vrai de tout processus qui fait son vrai travail sur un seul thread : un worker Python ou Ruby classique, la plupart des runtimes mono-processus.

Un processus mono-thread peut, par définition, saturer exactement un cœur CPU. La task sur laquelle il tournait avait quatre vCPU. Donc l’arithmétique qui compte est brutalement simple :

un cœur à fond / quatre vCPU sur la task = ~25% de CPU moyen sur la task

À pleine saturation, le maximum que ce processus peut jamais faire paraître à la task, c’est environ 25 pour cent. Ajoutez un peu d’I/O async réparti sur le runtime et vous atterrissez vers 30 pour cent. Ce n’est pas un service avec de la marge. C’est un service à la limite sur le seul cœur qu’il peut utiliser, pendant que trois cœurs restent inactifs et tirent la moyenne vers un chiffre qui se lit “à peine au travail”.

La politique d’autoscaling suivait le CPU moyen sur les cœurs de la task. Pour une charge qui ne peut en utiliser qu’un seul, cette moyenne n’est pas une mesure de charge. C’est une mesure de charge divisée par quatre.

La métrique répondait à une autre question

C’est la vraie leçon, et elle n’est pas spécifique à AWS ni à ECS. L’utilisation CPU moyenne répond à “quelle part de la puissance totale de la machine est utilisée”. L’autoscaling a besoin de la réponse à une autre question : “ce service suit-il sa charge de travail”.

Pour un service multi-thread qui s’étale sur tous ses cœurs, ces deux questions ont presque la même réponse, ce qui explique pourquoi le CPU est la métrique de scaling par défaut et pourquoi elle marche d’habitude. Pour un service mono-thread, elles divergent complètement. Le service peut être totalement saturé et rater ses objectifs de latence pendant que la métrique CPU, honnêtement et correctement, affiche 30 pour cent.

Scaler sur la mauvaise métrique est pire que ne pas scaler, parce que ça vient avec un dashboard qui vous rassure activement. Tout a l’air sain jusqu’à ce qu’un humain remarque que non.

Trois façons de corriger

Il y a un levier rapide et deux durables.

Dimensionner la task à un vCPU. Si le processus ne peut utiliser qu’un cœur, arrêtez de lui en donner quatre. Sur une task à un vCPU, “un cœur saturé” fait 100 pour cent de CPU, et l’autoscaler peut enfin voir la vérité. C’est le correctif le moins cher et il fait double emploi comme nettoyage de coûts : vous payiez trois cœurs inactifs par task. Standardiser la flotte à un vCPU par service n’est pas une régression ici, c’est redonner un sens à la métrique affichée.

Scaler sur un signal qui reflète la vraie contrainte. La charge est limitée par la latence, donc scalez sur la latence ou sur la pression de requêtes, pas sur le CPU. Sur cette stack, ça veut dire du target-tracking sur le temps de réponse des cibles du load balancer, ou sur le nombre de requêtes par cible, plutôt que sur l’utilisation CPU. Une métrique custom marche aussi : pour un runtime à boucle d’événements, le lag de la boucle d’événements est une mesure directe de “ce processus est en retard”. Scalez sur ce qui fait mal, pas sur un proxy qui le moyenne pour le faire disparaître.

Lancer un worker par cœur si vous devez utiliser de grosses tasks. Si une task a vraiment besoin de quatre vCPU, alors lancez quatre workers dessus (mode cluster, un processus par cœur, un gestionnaire de processus, ou quatre tasks plus petites au lieu d’une grosse). Maintenant les quatre cœurs peuvent être occupés, et le CPU moyen redevient une métrique honnête. La règle en dessous : l’utilisation CPU n’a de sens que quand votre app peut réellement utiliser tous les cœurs sur lesquels elle est mesurée.

La mitigation immédiate, le temps de décider, c’est de scaler à la main : ajoutez des tasks et regardez la latence. Si plus de tasks corrige la latence, vous venez de confirmer que le service était affamé de capacité depuis le début, et vous pouvez baisser le seuil de scaling (ou changer de métrique) en confiance.

À retenir

L’autoscaling ne vaut que la métrique qu’il surveille, et la métrique par défaut suppose discrètement que votre app utilise chaque cœur qu’on lui donne. Un service mono-thread casse cette hypothèse : il sature un cœur, affiche une fraction du CPU total de la task, et passe sous un seuil de scale-out basé sur le CPU pendant que sa latence s’effondre.

Faites correspondre la métrique à la façon dont l’app consomme réellement les ressources. Dimensionnez les tasks mono-thread pour qu’un cœur soit toute la task et que le CPU dise la vérité, ou scalez sur la latence et la pression de requêtes à la place. Et traitez un dashboard serein à côté d’utilisateurs mécontents comme un bug de votre supervision, pas comme une coïncidence. La métrique qui dit que tout va bien est la première dont il faut se méfier.