- Même avec une limite CPU appliquée à un conteneur, le runtime Go ne la détecte pas par défaut et peut créer des threads en se basant sur l’ensemble des cœurs de l’hôte, ce qui augmente la latence
- Le GC de Go s’exécute en grande partie en parallèle de l’application, mais nécessite des phases stop-the-world (STW) lors de Sweep Termination et Mark Termination, où toutes les goroutines sont arrêtées
- Le CFS de Linux répartit le temps CPU en fonction du nombre de cœurs par seconde, et
--cpus=4 signifie qu’un conteneur reçoit 4 secondes de temps CPU par seconde
- Sur un hôte à 16 cœurs, exécuter un conteneur limité à 4 cœurs peut amener Go à répartir des goroutines sur 16 threads OS, ce qui peut allonger les STW une fois le quota CPU épuisé
- En ajustant
GOMAXPROCS à la limite CPU du conteneur, le cycle GC de l’exemple passe de moins de 2,5 ms à moins de 1 ms, et le STW descend à environ 26 μs
Désalignement entre la limite CPU du conteneur et le runtime Go
- Lorsqu’on exécute une application Go dans un conteneur, la limite CPU sert à empêcher qu’elle ne consomme tous les CPU de l’hôte
- Le problème, c’est que le runtime Go ne reconnaît pas par défaut la limite CPU du conteneur
- À cause de ce décalage, le runtime suppose qu’il peut utiliser plus de CPU que le quota réel, ce qui peut entraîner une latence plus élevée
Où se produisent les STW dans le GC de Go
- Le garbage collector de Go s’exécute concurremment avec l’application pendant la majeure partie du temps
- Mais le processus de GC comporte deux phases où toutes les goroutines doivent être arrêtées
- L’étape d’arrêt avant la phase de marquage pour appliquer la write barrier s’appelle Sweep Termination
- L’étape d’arrêt après la phase de marquage pour retirer la write barrier s’appelle Mark Termination
- Les phases STW durent généralement quelques dizaines de microsecondes
- L’application d’exemple est une application web simple qui alloue beaucoup de mémoire, et son code source est disponible sur go-cfs-blog
- Le conteneur est lancé avec une limite de 4 CPU
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
- On peut collecter une trace avec le paquet
runtime/trace et l’analyser avec go tool trace
- Dans cette exécution, le cycle GC durait moins de 2,5 ms, mais près de 10 % de ce temps correspondait à des phases STW
- Pour des applications sensibles à la latence, une telle proportion peut déjà poser problème
Fonctionnement des limites CPU Docker et du CFS Linux
- La limite CPU
--cpus de Docker est une hard limit
- Il est aussi possible de configurer
--cpu-shares, mais cette valeur n’est appliquée que lorsque l’hôte est en situation de contrainte CPU
- Si l’hôte a des ressources disponibles, le conteneur peut utiliser plus de CPU que le nombre de cœurs qui lui est attribué
- Si l’hôte devient contraint, l’application est alors limitée
- Le Completely Fair Scheduler (CFS) de Linux a été introduit avec Linux 2.6.23 et a été l’ordonnanceur par défaut jusqu’à Linux 6.6
- Le CFS est un proportional share scheduler qui attribue un poids aux processus proportionnel au nombre de cœurs CPU qu’ils peuvent utiliser
- Le poids d’un processus pouvant utiliser 4 cœurs CPU est de 4
- Le poids d’un processus pouvant utiliser 2 cœurs CPU est de 2
- Le CFS découpe et répartit le temps CPU
- Un système à 4 cœurs peut distribuer 4 secondes de temps CPU par seconde
- Attribuer un certain nombre de cœurs CPU à un conteneur revient à demander à l’ordonnanceur Linux un temps CPU équivalent à
n CPU
--cpus=4 signifie que le conteneur reçoit 4 secondes de temps CPU par seconde
Pourquoi les STW s’allongent
- Au démarrage, le runtime Go crée un thread OS par cœur CPU
- Sur une machine à 16 cœurs, il peut ainsi créer 16 threads OS indépendamment de la limite CPU du cgroup
- Le runtime planifie ensuite les goroutines sur ces threads OS
- Même si le conteneur est limité à 4 cœurs CPU, Go peut répartir des goroutines sur l’ensemble des 16 threads OS
- Dans cette situation, le runtime s’attend à pouvoir utiliser 16 secondes de temps CPU par seconde
- Les STW s’allongent parce qu’il faut aussi arrêter les goroutines placées sur des threads qui attendent d’être replanifiés par l’ordonnanceur Linux
- Une fois le quota CPU du conteneur consommé, ces threads ne sont plus planifiés
Ajuster GOMAXPROCS au quota CPU
- Go permet de limiter le nombre de threads CPU utilisés par le runtime via la variable d’environnement
GOMAXPROCS
- Dans un conteneur avec un quota CPU de 4, il faut aussi définir
GOMAXPROCS=4
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- Avec la même application et la même charge, aligner
GOMAXPROCS sur le quota CPU réduit le temps passé par le GC
- Dans la trace, le cycle GC tombe à moins de 1 ms et la phase STW à 26 μs
- Cela représente environ un dixième du temps STW observé sans limite
GOMAXPROCS
GOMAXPROCS doit être défini en fonction du nombre de cœurs CPU disponibles pour le conteneur
- en cas d’allocation CPU fractionnaire, on arrondit à l’inférieur
- en dessous de 1 CPU, on arrondit au supérieur
- la formule est
GOMAXPROCS=max(1, floor(CPUs))
- automaxprocs d’Uber est une bibliothèque open source qui calcule automatiquement cette valeur à partir des cgroups du conteneur
- Une GitHub Issue a été ouverte pour intégrer ce support par défaut au runtime Go
Points à vérifier pour des services Go conteneurisés
- Définir uniquement une limite CPU ne suffit pas : il faut aussi ajuster GOMAXPROCS pour que le runtime Go prenne cette limite en compte
- Si le calcul manuel est compliqué, une bibliothèque comme automaxprocs peut configurer automatiquement la bonne valeur à partir des cgroups
- Pour les services Go sensibles à la latence, il faut vérifier le temps STW dans les traces GC afin de s’assurer qu’il n’y a pas de décalage entre le quota CPU et la configuration du runtime
1 commentaires
Avis sur Hacker News
Un problème commun à plusieurs langages est que les applications consultent /proc/cpuinfo pour détecter le nombre de cœurs de la machine
Mais dans Docker ou d’autres technologies de conteneurs, ce fichier apparaît comme sur l’hôte du conteneur et liste tous les cœurs, quel que soit le nombre réellement alloué au conteneur
Pendant un temps, je me suis demandé si Docker ne pourrait pas créer un faux /proc/cpuinfo qui ne listerait que les « Docker CPU » alloués à une tâche, mais en y repensant, cela ne semble pas très bien fonctionner pour plusieurs raisons
Ce qui est limité, c’est combien de temps il peut les utiliser
Il existe des exceptions, la documentation est ici : https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
nproc, et je l’ai aussi vu utilisé dans d’autres conteneurs, par exemplebundle install -j $(nproc)Cela respecte l’allocation CPU, et fournit donc la fonctionnalité recherchée
Je ne sais pas si les applications arbitraires utilisent
nprocquand c’est possible« Affiche le nombre d’unités de traitement disponibles pour le processus courant, ce qui peut être inférieur au nombre de processeurs en ligne. Si cette information ne peut pas être obtenue, affiche le nombre de processeurs installés »
https://www.gnu.org/software/coreutils/manual/html_node/npro...
https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
Go regarde le nombre de masques CPU au démarrage, puis ne le revérifie plus ensuite
Cela pose problème dans Kubernetes, où les CPU visibles par un processus peuvent changer pendant son exécution
lxcfs est un système de fichiers FUSE qui émule /proc en déduisant les valeurs de cgroup, afin que les applications et bibliothèques n’aient pas à se soucier du fait qu’elles tournent ou non dans un conteneur
Par exemple, /proc/uptime devrait refléter le temps de fonctionnement du conteneur plutôt que celui de l’hôte, et /proc/cpuinfo devrait refléter comme nombre de CPU la limite la plus basse entre la combinaison de cpu.max et cpuset.cpus
Il est aussi possible de déduire le nombre de CPU via l’appel système
sched_getaffinity, et cette méthode ne dépend pas de /proc/cpuinfoDonc selon la bibliothèque utilisée, on peut se retrouver dans une situation délicate
Cette explication est subtilement erronée
Du point de vue de Docker, l’extension CFS cgroup dispose de plusieurs réglages :
cfs_quota_us,cfs_period_us(la valeur par défaut courante est 100 ms, pas 1 seconde) et les sharesSi l’on configure les shares, on obtient une planification proportionnelle basée sur des poids, mais cela n’a de sens qu’en cas de contention
Les deux premières valeurs imposent un quota strict
Mieux vaut utiliser
--cpu-sharesplutôt que le flag--cpude Docker afin d’éviter un mécanisme de quota généralement peu utileD’après la documentation Linux,
cpu.sharescorrespond au poids de chaque groupe au même niveau de hiérarchie,cpu.cfs_period_usest la période du scheduler utilisée pour juger de la bande passante, et sa valeur par défaut est 100000us ou 100mscpu.cfs_quota_usest le temps maximal pendant lequel le groupe courant peut s’exécuter durant chaquecfs_period_us, et cette valeur est cumulée sur l’ensemble des CPU du système ; pour utiliser pleinement 2 CPU, il faut donc la régler au double decfs_period_us--cpude Docker, utilisez plutôt… » est trop catégorique sans davantage de contexteOn ne peut absolument pas dire que c’est « généralement peu utile »
shares et quota répondent à des cas d’usage différents ; il faut comprendre son propre cas d’usage et choisir en conséquence
--cpu, l’application peut le détecterApparemment parce que cpuset est utilisé
Avec un quota, elle ne peut pas le détecter, ce qui augmente fortement le risque de créer plus de threads que nécessaire
Je vais essayer de clarifier cette partie
Je pense que les symptômes apparaissent bien de cette manière, mais il faut que je l’exprime plus clairement
L’application doit fonctionner correctement
Si on utilise des CPU reservations au lieu de CPU limits, il n’y a pas besoin de ce type d’ajustement : https://home.robusta.dev/blog/stop-using-cpu-limits
Les CPU reservations sont en pratique aussi des limits, mais déclarées comme une limite implicite et une garantie
Il suffit donc de laisser le runtime Go utiliser tous les CPU disponibles, puis de laisser le scheduler Linux limiter en cas de contention CPU selon les reservations déclarées
C’est parce qu’on ne veut pas s’habituer à pouvoir utiliser du CPU excédentaire non garanti
À mesure que d’autres pods remplissent le nœud, un pod qui tournait très bien juste avant peut soudainement ralentir
Utiliser des limits permet de simuler ce même comportement et de s’y préparer avec une bonne planification de capacité
Ce n’est pas la seule méthode, mais c’est la plus simple
Cette discussion m’intéresse davantage, mais l’article lié semble seulement traiter de l’idée selon laquelle les gens pensent qu’il faut des limits pour garantir du CPU à tous les pods
Cet article n’est pas faux en soi et relève surtout du content marketing, mais ses affirmations sont trop générales et il ignore plusieurs bonnes raisons de configurer des limits
Parmi les articles du même site, certains sont tout simplement faux : https://home.robusta.dev/blog/containers-dont-use-chroot
Il existe aussi des workloads qui ne tirent qu’un faible bénéfice mais consomment toute la capacité de burst, et des cas où il faut prioriser la capacité de burst d’un serveur HTTP plutôt que celle d’un cronjob qui doit juste finir dans un délai donné
J’ai aussi vu des pannes provoquées par des développeurs qui n’avaient pas mis à jour les requests alors que les besoins de l’app avaient augmenté, puis le CPU disponible en marge a soudainement manqué
En théorie, ce sont des ressources minimales garanties, mais quand plusieurs conteneurs actifs tournent ensemble sur le même hôte, la latence de queue et la latence moyenne peuvent augmenter de manière anormale
Sur une instance EC2 à 4 cœurs, la latence à 50 % d’utilisation CPU et à 90 % est assez différente
Avec des reservations, on observe un phénomène similaire : même si chaque conteneur reçoit bien sa reservation, l’utilisation CPU relative devient très élevée à cause des autres processus actifs sur le même hôte
L’OOMKiller peut intervenir
Sans limits CPU et mémoire, on ne peut pas obtenir la classe QoS Guaranteed, donc le pod peut finir par être évincé à un moment donné
En utilisant des conteneurs et les cgroups, je me suis fait avoir plusieurs fois par le scheduler CFS
Je me demande ce qu’apporte le nouveau scheduler
Quelqu’un ici l’a-t-il déjà utilisé sur un cluster de production ?
On gaspille des cœurs depuis presque 20 ans maintenant : https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
Le conteneur a bien appliqué des limites de ressources, mais le souci est que Go, en tant que processus dans le conteneur, ne vérifie pas la fonctionnalité de l’OS utilisée pour ces limites lorsqu’il calcule le niveau de parallélisme disponible
En plus de GOMAXPROCS, les versions récentes de Go incluent aussi GOMEMLIMIT
Avec https://github.com/KimMachineGun/automemlimit, on peut configurer automatiquement cette limite, un peu comme https://github.com/uber-go/automaxprocs
J’ai découvert ça l’an dernier en gérant, comme platform engineer dans mon ancien poste, un cluster Kubernetes on-premise et l’infrastructure des pipelines CI/CD
J’avais bien constaté que le décalage entre CPU réel et CPU alloué provoquait notamment des problèmes comme le CPU throttling, mais il était difficile de trouver une solution scalable qui impacte tous les déploiements Go du cluster
Faire ajouter une dépendance autoprocs à tous les développeurs sur des centaines de projets n’était pas une option
Comme alternative, aligner tous les requests/limits CPU sur des entiers et injecter cette valeur comme variable d’environnement GOMAXPROCS dans les manifests Kubernetes était aussi fastidieux et peu réaliste
Au final, on a obtenu des améliorations en appliquant la variable GOMAXPROCS seulement à certaines applications fortement multithreadées, mais on n’a toujours pas trouvé de solution applicable à tous les déploiements dans une architecture microservices où les besoins CPU varient fortement d’un projet à l’autre
Limiter GOMAXPROCS peut provoquer de graves problèmes de latence quand le processus reçoit un pic de trafic et que la mise en file d’attente est simple
Indépendamment de ce qu’on pense du temps moyen que le processus utilisera, définir GOMAXPROCS selon la valeur fournie par le matériel reste en pratique la meilleure option
Comme quelqu’un qui connaît mal Docker et Go, je me demande si ce comportement est intentionnel
L’équipe Go peut-elle faire en sorte que les limits CGroups soient prises en compte ?
Les autres runtimes se comportent-ils de façon comparable ?
Ou bien tu parlais d’un runtime comme containerd ?
C’était en Scala
Il existe aussi des techniques de GC pour raccourcir davantage les pauses
Par exemple, elles consistent à effectuer en parallèle ce qui doit être fait pendant la pause, puis à le répéter au point sûr
L'idée est que, grâce au travail concurrent, le travail au point sûr se résume à une simple vérification indiquant qu'il n'y a « rien à faire »
Doubler le travail peut dégrader le débit du GC
Cet article parle de conteneurs, mais le problème semble toujours se produire dès que Go n'a accès qu'à moins de temps CPU que prévu
Est-ce que la même chose n'arrive pas aussi lorsqu'on exécute Go sur un système où d'autres processus utilisent le CPU ?
Et même simplement en lançant deux programmes Go en même temps ?