- 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
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.
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...
nprocund habe auch in anderen Containern gesehen, dass es etwa alsbundle install -j $(nproc)genutzt wird.Das respektiert die CPU-Zuweisung und liefert damit die gesuchte Funktion.
Ich weiß nicht, ob beliebige Anwendungen
nprocverwenden, 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 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.
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_getaffinityableiten; dieser Weg hängt nicht von /proc/cpuinfo ab.Je nach verwendeter Bibliothek kann man dadurch also in Schwierigkeiten geraten.
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-sharesverwenden, um die meist nutzlose Quota-Erzwingung zu vermeiden.Laut Linux-Dokumentation ist
cpu.sharesdas Gewicht jeder Gruppe auf derselben Ebene,cpu.cfs_period_usist die Scheduler-Periode zur Bandbreitenbewertung, und der Standardwert beträgt 100000 us bzw. 100 ms.cpu.cfs_quota_usist die maximale Zeit, die die aktuelle Gruppe während jedescfs_period_uslaufen darf; dieser Wert ist die aufsummierte Zeit über alle CPUs des Systems hinweg, daher muss man ihn auf das Doppelte voncfs_period_ussetzen, wenn man zwei CPUs vollständig nutzen lassen will.--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.
--cpuverwendet, 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 werde versuchen, diesen Teil klarer zu machen.
Ich denke, die Symptome zeigen sich in etwa so, aber die Formulierung sollte deutlicher sein.
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.
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.
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.
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.
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.
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, 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.
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.
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.
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?
Oder meintest du eine Runtime wie containerd?
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?