2 points par GN⁺ 2023-11-09 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2023-11-09
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

    • Avec des limites basées sur des quotas, le conteneur peut utiliser tous les cœurs CPU de l’hôte
      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...
    • J’utilise seulement nproc, et je l’ai aussi vu utilisé dans d’autres conteneurs, par exemple bundle install -j $(nproc)
      Cela respecte l’allocation CPU, et fournit donc la fonctionnalité recherchée
      Je ne sais pas si les applications arbitraires utilisent nproc quand 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 ne fait pas ça
      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
    • Un faux /proc/cpuinfo existe déjà : https://github.com/lxc/lxcfs
      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/cpuinfo
      Donc selon la bibliothèque utilisée, on peut se retrouver dans une situation délicate
    • En voyant ça, j’en arrive à la conclusion que les conteneurs sont une abstraction bancale et que VMware a laissé passer une opportunité
  • 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 shares
    Si 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-shares plutôt que le flag --cpu de Docker afin d’éviter un mécanisme de quota généralement peu utile
    D’après la documentation Linux, cpu.shares correspond au poids de chaque groupe au même niveau de hiérarchie, cpu.cfs_period_us est la période du scheduler utilisée pour juger de la bande passante, et sa valeur par défaut est 100000us ou 100ms
    cpu.cfs_quota_us est le temps maximal pendant lequel le groupe courant peut s’exécuter durant chaque cfs_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 de cfs_period_us

    • La formulation « n’utilisez pas le flag --cpu de Docker, utilisez plutôt… » est trop catégorique sans davantage de contexte
      On 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
    • Un point à noter est qu’avec --cpu, l’application peut le détecter
      Apparemment 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 suis l’auteur du billet, merci pour le retour
      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
    • Les utilisateurs de Kubernetes n’ajustent ni ne modifient directement ce genre de paramètres
      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

    • Si on définit des limits, ce n’est pas parce qu’on craint qu’un pod affecte les autres pods
      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
    • Je fais tourner plusieurs choses sur une configuration à 128 cœurs, et je définis des CPU limits bien plus élevés que les requests, tout en gardant ces limits pour éviter qu’un composant ne s’emballe
      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
    • Dans la communauté Kubernetes, on a l’impression d’avoir ce débat toutes les deux semaines
      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é
    • Les reservations ne sont pas des limits, mais une contrainte de consommation CPU minimale garantie
      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
    • Intéressant, mais cela ne s’applique pas à la mémoire, non ?
      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 problème ici n’est pas le scheduler
      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
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • 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

    • Il n’y a pas de réponse unique à cette question
      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
    • On peut définir un mutating webhook qui injecte GOMAXPROCS dans tous les conteneurs de pod
  • 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 ?

    • Je suis presque sûr que .NET a aussi dû traiter ce problème, et il me semble que Java a eu ou a encore ce souci
      Ou bien tu parlais d’un runtime comme containerd ?
    • J’ai eu le même problème avec la JVM
      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 ?