2 Punkte von GN⁺ 2023-11-09 | 1 Kommentare | Auf WhatsApp teilen
  • Selbst wenn für einen Container CPU-Limits gesetzt sind, erkennt die Go-Runtime diese standardmäßig nicht und kann daher Threads auf Basis aller Host-Kerne erstellen, was die Latenz erhöhen kann
  • Der Go-GC läuft die meiste Zeit parallel zur Anwendung, benötigt aber bei Sweep Termination und Mark Termination stop-the-world(STW)-Phasen, in denen alle Goroutinen angehalten werden
  • Linux CFS verteilt CPU-Zeit proportional zur Kernanzahl pro Sekunde; --cpus=4 bedeutet, dass der Container pro Sekunde 4 Sekunden CPU-Zeit erhält
  • Läuft ein Container mit einem Limit von 4 Kernen auf einem Host mit 16 Kernen, kann Go Goroutinen auf 16 OS-Threads planen, sodass sich STW nach Ausschöpfen der CPU-Quote verlängern kann
  • Wird GOMAXPROCS an das CPU-Limit des Containers angepasst, sinkt im Beispiel der GC-Zyklus von unter 2,5 ms auf unter 1 ms und STW auf etwa 26 μs

Diskrepanz zwischen Container-CPU-Limits und der Go-Runtime

  • Wenn Go-Anwendungen in Containern laufen, dienen CPU-Limits dazu, zu verhindern, dass die gesamte Host-CPU verbraucht wird
  • Das Problem ist, dass die Go-Runtime die CPU-Limits des Containers standardmäßig nicht erkennt
  • Durch diese Diskrepanz geht die Runtime davon aus, mehr CPU nutzen zu können als die tatsächliche Quote erlaubt, was zu höherer Latenz führen kann

Wo in Go GC STW auftritt

  • Der Go-Garbage-Collector läuft die meiste Zeit parallel zur Anwendung
  • Im GC-Prozess gibt es jedoch zwei Abschnitte, in denen alle Goroutinen angehalten werden müssen
    • Die Phase vor der Mark Phase, in der angehalten wird, um die Write Barrier anzuwenden, heißt Sweep Termination
    • Die Phase nach der Mark Phase, in der erneut angehalten wird, um die Write Barrier zu entfernen, heißt Mark Termination
  • STW-Abschnitte liegen normalerweise im Bereich von einigen Dutzend Mikrosekunden
  • Die Beispielanwendung ist eine einfache Webanwendung mit vielen Speicherallokationen; der Quellcode liegt unter go-cfs-blog
  • Der Container wird mit einem Limit von 4 CPUs gestartet
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • Mit dem Paket runtime/trace lässt sich ein Trace erfassen und mit go tool trace analysieren
  • In diesem Lauf lag der GC-Zyklus unter 2,5 ms, doch fast 10 % davon entfielen auf STW
  • Für latenzsensitive Anwendungen kann selbst dieser Anteil problematisch sein

Docker-CPU-Limits und Funktionsweise von Linux CFS

  • Das CPU-Limit --cpus in Docker ist ein hard limit
  • Auch --cpu-shares kann gesetzt werden, wird aber nur erzwungen, wenn der Host CPU-seitig unter Druck steht
    • Hat der Host noch Spielraum, kann der Container mehr als die zugewiesenen CPU-Kerne nutzen
    • Gerät der Host unter Last, wird die Anwendung begrenzt
  • Der Linux-Completely Fair Scheduler (CFS) wurde in Linux 2.6.23 eingeführt und war bis vor Linux 6.6 der Standard-Scheduler
  • CFS ist ein proportional share scheduler, bei dem das Gewicht eines Prozesses proportional zur Zahl der nutzbaren CPU-Kerne ist
    • Ein Prozess, der 4 CPU-Kerne nutzen kann, hat Gewicht 4
    • Ein Prozess, der 2 CPU-Kerne nutzen kann, hat Gewicht 2
  • CFS teilt CPU-Zeit in Scheiben auf und verteilt sie
    • Ein System mit 4 Kernen kann pro Sekunde 4 Sekunden CPU-Zeit vergeben
    • Einem Container eine Anzahl CPU-Kerne zuzuweisen entspricht der Anforderung an den Linux-Scheduler, n CPUs an Zeit zu erhalten
    • --cpus=4 bedeutet, dass der Container pro Sekunde 4 Sekunden CPU-Zeit bekommt

Warum STW länger wird

  • Die Go-Runtime erstellt beim Start einen OS-Thread pro CPU-Kern
  • Auf einer Maschine mit 16 Kernen kann sie unabhängig von CGroup-CPU-Limits 16 OS-Threads erzeugen
  • Die Runtime plant Goroutinen auf diesen OS-Threads
  • Selbst wenn das Container-Limit 4 Kerne beträgt, kann Go Goroutinen auf alle 16 OS-Threads legen
  • In diesem Zustand erwartet die Runtime, pro Sekunde 16 Sekunden CPU-Zeit nutzen zu können
  • Die längere STW-Zeit entsteht, weil auch Goroutinen auf Threads angehalten werden müssen, die darauf warten, vom Linux-Scheduler wieder ausgeführt zu werden
  • Hat der Container seine CPU-Quote bereits verbraucht, werden diese Threads nicht mehr eingeplant

GOMAXPROCS an die CPU-Quote anpassen

  • Mit der Umgebungsvariable GOMAXPROCS kann Go die Zahl der von der Runtime genutzten CPU-Threads begrenzen
  • In einem Container mit einer CPU-Quote von 4 wird daher zusätzlich GOMAXPROCS=4 gesetzt
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • Bei gleicher Anwendung und gleicher Last wird der GC kürzer, wenn GOMAXPROCS an die CPU-Quote angepasst wird
  • Im Trace sank der GC-Zyklus auf unter 1 ms, der STW-Abschnitt auf 26 μs
  • Verglichen mit der STW-Zeit ohne GOMAXPROCS-Limit entspricht das etwa einem Zehntel
  • GOMAXPROCS sollte auf die Zahl der CPU-Kerne gesetzt werden, die dem Container tatsächlich zur Verfügung stehen
    • Bei fractional CPU-Zuweisungen wird abgerundet
    • Bei weniger als 1 CPU wird aufgerundet
    • Die Formel lautet GOMAXPROCS=max(1, floor(CPUs))
  • Ubers automaxprocs ist eine Open-Source-Bibliothek, die diesen Wert in Container-cgroups automatisch berechnet
  • Es gibt ein offenes GitHub-Issue, um dies direkt in der Go-Runtime zu unterstützen

Worauf man bei containerisierten Go-Services achten sollte

  • Es reicht nicht, nur CPU-Limits zu setzen; auch GOMAXPROCS muss so angepasst werden, dass die Go-Runtime das Limit berücksichtigt
  • Falls die manuelle Berechnung aufwendig ist, kann eine Bibliothek wie automaxprocs den cgroups-basierten Wert automatisch setzen
  • Bei latenzsensitiven Go-Services sollte im GC-Trace die STW-Zeit geprüft werden, um sicherzustellen, dass CPU-Quote und Runtime-Konfiguration nicht auseinanderlaufen

1 Kommentare

 
GN⁺ 2023-11-09
Meinungen auf Hacker News
  • Ein Problem, das in mehreren Sprachen zu beobachten ist: Anwendungen schauen in /proc/cpuinfo, um die Anzahl der Maschinen-Cores zu erkennen.
    In Docker-Containern oder anderen Container-Technologien sieht diese Datei jedoch genauso aus wie auf dem Container-Host und listet alle Cores auf, egal wie viele dem Container tatsächlich zugewiesen wurden.
    Eine Zeit lang dachte ich, Docker könnte vielleicht eine gefälschte /proc/cpuinfo erzeugen, die nur die dem Workload zugewiesenen „Docker-CPUs“ auflistet, aber bei näherem Nachdenken dürfte das aus mehreren Gründen nicht gut funktionieren.

    • Bei quota-basierten Limits kann der Container alle CPU-Cores des Hosts nutzen.
      Begrenzt ist, wie lange er diese Cores nutzen darf.
      Es gibt auch Ausnahmen; die Dokumentation steht hier: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • Ich verwende nur nproc und habe auch in anderen Containern gesehen, dass es etwa als bundle install -j $(nproc) genutzt wird.
      Das respektiert die CPU-Zuweisung und liefert damit die gesuchte Funktion.
      Ich weiß nicht, ob beliebige Anwendungen nproc verwenden, wenn es verfügbar ist.
      „Gibt die Anzahl der Verarbeitungseinheiten aus, die dem aktuellen Prozess zur Verfügung stehen; diese kann kleiner sein als die Anzahl der Online-Prozessoren. Wenn diese Information nicht verfügbar ist, wird die Anzahl der installierten Prozessoren ausgegeben.“
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go macht das nicht so.
      Go betrachtet beim Start die Anzahl in der CPU-Maske und schaut danach nicht erneut nach.
      In Kubernetes wird das zum Problem, weil sich die während der Prozessausführung sichtbaren CPUs ändern können.
    • Eine gefälschte /proc/cpuinfo gibt es bereits: https://github.com/lxc/lxcfs
      lxcfs ist ein FUSE-Dateisystem, das aus cgroup-Werten Rückschlüsse zieht und /proc nachbildet, sodass Anwendungen und Bibliotheken sich nicht darum kümmern müssen, ob sie in einem Container laufen.
      Zum Beispiel sollte /proc/uptime die Laufzeit des Containers widerspiegeln, nicht die des Hosts, und /proc/cpuinfo sollte als CPU-Anzahl das niedrigere Limit aus der Kombination von cpu.max und cpuset.cpus abbilden.
      Die CPU-Anzahl lässt sich auch über den Systemaufruf sched_getaffinity ableiten; dieser Weg hängt nicht von /proc/cpuinfo ab.
      Je nach verwendeter Bibliothek kann man dadurch also in Schwierigkeiten geraten.
    • Wenn man sich das ansieht, kommt man zu dem Schluss, dass Container eine schlampige Abstraktion sind und VMware eine Chance verpasst hat.
  • Diese Erklärung ist in feinen Punkten falsch.
    Aus Docker-Sicht gibt es bei der CFS-cgroup-Erweiterung mehrere Stellschrauben: cfs_quota_us, cfs_period_us (der übliche Standardwert ist 100 ms, nicht 1 Sekunde) sowie shares.
    Wenn man shares setzt, greift eine gewichtungsbasierte proportionale Planung, die aber nur bei Konkurrenz um Ressourcen relevant ist.
    Die ersten beiden Werte erzwingen eine harte Quota.
    Statt Dockers --cpu-Flag sollte man besser --cpu-shares verwenden, um die meist nutzlose Quota-Erzwingung zu vermeiden.
    Laut Linux-Dokumentation ist cpu.shares das Gewicht jeder Gruppe auf derselben Ebene, cpu.cfs_period_us ist die Scheduler-Periode zur Bandbreitenbewertung, und der Standardwert beträgt 100000 us bzw. 100 ms.
    cpu.cfs_quota_us ist die maximale Zeit, die die aktuelle Gruppe während jedes cfs_period_us laufen darf; dieser Wert ist die aufsummierte Zeit über alle CPUs des Systems hinweg, daher muss man ihn auf das Doppelte von cfs_period_us setzen, wenn man zwei CPUs vollständig nutzen lassen will.

    • Die Formulierung „Verwende nicht Dockers --cpu-Flag, sondern stattdessen …“ ist ohne weitere Einordnung zu stark.
      Man kann es keineswegs als „meist nutzlos“ ansehen.
      Shares und Quota sind für unterschiedliche Anwendungsfälle gedacht; man sollte also den eigenen Anwendungsfall verstehen und passend dazu wählen.
    • Ein Punkt, auf den man achten sollte: Wenn man --cpu verwendet, kann die Anwendung das erkennen.
      Das liegt vermutlich daran, dass cpuset verwendet wird.
      Bei einer Quota kann sie es nicht erkennen, sodass wahrscheinlich mehr Threads als nötig entstehen.
    • Ich bin der Autor des Blogposts, danke für das Feedback.
      Ich werde versuchen, diesen Teil klarer zu machen.
      Ich denke, die Symptome zeigen sich in etwa so, aber die Formulierung sollte deutlicher sein.
    • Menschen, die Kubernetes verwenden, passen solche Einstellungen nicht direkt an oder ändern sie.
      Die Anwendung muss korrekt funktionieren.
  • Wenn man statt CPU limits CPU reservations verwendet, ist eine solche Anpassung nicht nötig: https://home.robusta.dev/blog/stop-using-cpu-limits
    CPU reservations sind im Grunde ebenfalls limits, werden aber als implizite Grenze und Garantie deklariert.
    Man kann die Go-Runtime also alle verfügbaren CPUs nutzen lassen, und wenn CPU-Konkurrenz entsteht, lässt man den Linux-Scheduler gemäß den deklarierten reservations begrenzen.

    • Der Grund, limits zu setzen, ist nicht die Sorge, dass ein Pod andere Pods beeinträchtigt.
      Es geht darum, sich nicht an den Zustand zu gewöhnen, nicht garantierte überschüssige CPU nutzen zu können.
      Wenn sich ein Node nach und nach mit weiteren Pods füllt, kann ein Pod, der eben noch gut lief, plötzlich langsamer werden.
      Mit limits kann man dasselbe Verhalten simulieren und sich durch korrekte Kapazitätsplanung darauf vorbereiten.
      Es ist nicht die einzige Methode, aber die einfachste.
    • In einer 128-Core-Konfiguration betreiben wir einiges; dabei setzen wir die CPU limits deutlich höher als die requests, setzen sie aber trotzdem, damit nichts durchdreht.
      Ich würde gern mehr über diese Diskussion erfahren, aber der verlinkte Artikel scheint nur darauf einzugehen, dass Leute glauben, man brauche ein limit, um CPU für alle Pods zu garantieren.
    • In der Kubernetes-Community fühlt es sich so an, als würde diese Diskussion alle zwei Wochen geführt.
      Der Artikel ist an sich nicht falsch und geht größtenteils eher in Richtung Content-Marketing, aber seine Aussagen sind sehr pauschal und ignorieren mehrere gute Gründe, limits zu setzen.
      Manche Artikel derselben Quelle sind schlicht falsch: https://home.robusta.dev/blog/containers-dont-use-chroot
      Es gibt Workloads, die bei nur geringem Nutzen die gesamte Burst-Kapazität aufbrauchen, und manchmal sollte man die Burst-Kapazität eines HTTP-Servers gegenüber einem cronjob priorisieren, der innerhalb einer vorgegebenen Zeit fertig werden muss.
      Es gab auch Fälle, in denen Entwickler die requests nicht aktualisiert haben, obwohl der Bedarf der App gestiegen war, und es dann zu Ausfällen kam, als plötzlich weniger freie CPU-Zeit verfügbar war.
    • reservations sind keine limits, sondern eine Beschränkung für die minimal garantierte CPU-Nutzung.
      Theoretisch handelt es sich um minimal garantierte Ressourcen, aber wenn auf demselben Host mehrere ausgelastete Container laufen, können Tail-Latenz und durchschnittliche Latenz unnormal stark steigen.
      Die Latenz auf einer 4-Core-EC2-Instanz ist bei 50 % CPU-Auslastung ziemlich anders als bei 90 %.
      Ähnlich ist es bei reservations: Selbst wenn jeder Container seine eigene reservation garantiert bekommt, wird die relative CPU-Auslastung wegen anderer ausgelasteter Prozesse auf demselben Host sehr hoch.
    • Interessant, aber gilt das nicht für Speicher?
      Der OOMKiller kann ihn sich holen.
      Wenn es weder CPU- noch Memory limits gibt, bekommt man keine Guaranteed-QoS-Klasse, und der Pod kann irgendwann evicted werden.
  • Mit Containern und cgroups bin ich dem CFS-Scheduler schon mehrfach zum Opfer gefallen.
    Ich frage mich, was der neue Scheduler ist.
    Hat ihn hier jemand in einem Produktionscluster eingesetzt?
    Wir verschwenden inzwischen seit fast 20 Jahren Cores: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

    • Das Problem ist hier nicht der Scheduler.
      Das Problem ist, dass der Container Ressourcenlimits setzt, aber Go, also der Prozess im Container, bei der Berechnung der verfügbaren Parallelität nicht die Betriebssystemfunktion prüft, die für dieses Limit verwendet wurde.
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • Neben GOMAXPROCS gibt es in neueren Go-Releases auch GOMEMLIMIT.
    Mit https://github.com/KimMachineGun/automemlimit kann man dieses Limit ähnlich wie mit https://github.com/uber-go/automaxprocs automatisch setzen.

  • Letztes Jahr habe ich das bei meinem früheren Arbeitgeber entdeckt, als ich als Platform Engineer On-Premises-Kubernetes-Cluster und CI/CD-Pipeline-Infrastruktur verwaltet habe.
    Ich habe gesehen, dass die Diskrepanz zwischen tatsächlicher CPU und zugewiesener CPU insbesondere Probleme wie CPU-Throttling verursacht, aber es war schwierig, eine skalierbare Lösung zu finden, die alle Go-Deployments im Cluster betrifft.
    Alle Entwickler von Hunderten Projekten dazu zu bringen, eine autoprocs-Abhängigkeit einzubauen, war keine Option.
    Eine Alternative, alle CPU requests/limits auf Ganzzahlen zu setzen und diesen Wert in Kubernetes-Manifesten in die Umgebungsvariable GOMAXPROCS zu schreiben, war ebenfalls umständlich und nicht praktikabel.
    Am Ende haben wir die Variable GOMAXPROCS nur auf einige Anwendungen angewendet, die viel Multithreading nutzen, und damit Verbesserungen erzielt; eine Lösung, die sich auf alle Deployments in einer Microservice-Architektur anwenden lässt, in der der CPU-Bedarf je nach Projekt stark schwankt, haben wir aber noch nicht gefunden.

    • Darauf gibt es keine einzelne richtige Antwort.
      Wenn man GOMAXPROCS begrenzt, kann es bei starkem Traffic auf einem Prozess und einfachem Queuing zu schwerwiegenden Latenzproblemen kommen.
      Unabhängig davon, wie viel Zeit der Prozess im Durchschnitt verbrauchen wird, ist es in der Praxis am besten, GOMAXPROCS auf den Wert zu setzen, den die Hardware bereitstellt.
    • Man kann einen mutating webhook definieren, der GOMAXPROCS in alle Pod-Container injiziert.
  • Aus Sicht von jemandem, der mit Docker oder Go nicht vertraut ist, frage ich mich, ob dieses Verhalten beabsichtigt ist.
    Kann man das Go-Team dazu bringen, CGroups limits zu erkennen?
    Verhalten sich andere Runtimes ähnlich?

    • Ich bin ziemlich sicher, dass .NET dieses Problem ebenfalls behandeln musste, und ich meine mich zu erinnern, dass Java das Problem hatte oder immer noch hat.
      Oder meintest du eine Runtime wie containerd?
    • In der JVM hatten wir dasselbe Problem.
      Es war in Scala.
  • Es gibt auch GC-Techniken, die Pausen kürzer machen
    Zum Beispiel, indem die Arbeit, die während einer Pause anfallen würde, parallel ausgeführt und dann an einem sicheren Punkt erneut wiederholt wird
    Die Erwartung ist, dass die Arbeit am sicheren Punkt dank der parallelen Ausführung zu einer einfachen Prüfung wird, dass „nichts zu tun“ ist
    Wenn man die Arbeit verdoppelt, kann sich der GC-Durchsatz verschlechtern

  • Dieser Artikel spricht zwar von Containern, aber das Problem scheint immer dann aufzutreten, wenn Go weniger CPU-Zeit nutzen kann als erwartet
    Passiert nicht dasselbe, wenn man Go auf einem System ausführt, auf dem andere Prozesse CPU nutzen?
    Gilt das nicht sogar schon, wenn man einfach zwei Go-Programme gleichzeitig laufen lässt?