Praktische Muster zur Implementierung von Graceful Shutdown in Go
(victoriametrics.com)- Der Graceful Shutdown einer Go-Anwendung ist ein Beendigungsverfahren, das neue Anfragen blockiert, auf laufende Arbeiten wartet und anschließend Ressourcen wie Datenbankverbindungen, Dateisperren und Netzwerk-Listener bereinigt
- Die Behandlung der Beendigung beginnt damit, Beendigungssignale wie
SIGTERMundSIGINTüberos/signaloder ab Go 1.16 übersignal.NotifyContextabzufangen, um das standardmäßige sofortige Beenden zu ersetzen - In Kubernetes muss die Beendigung innerhalb der standardmäßigen Grace Period von 30 Sekunden abgeschlossen sein; über eine
preStop-Verzögerung oder ein fehlschlagendes Readiness Probe sollte Zeit geschaffen werden, damit sich die Unterbrechung des Traffics bis zu externen Load Balancern fortpflanzen kann http.Server.Shutdownblockiert neue Verbindungen und wartet auf den Abschluss aktiver Requests, aber wenn Handler die Context Cancellation nicht berücksichtigen, können Probleme wie partielle Schreibvorgänge, Datenverlust und offene Transaktionen entstehen- Wichtige Ressourcen sollten nicht direkt nach dem Beendigungssignal, sondern erst nach Abschluss der Requests oder nach Ablauf eines Zeitlimits bereinigt werden; ein Shutdown in der umgekehrten Initialisierungsreihenfolge erleichtert es, Abhängigkeiten zwischen Komponenten einzuhalten
Mindestbedingungen für Graceful Shutdown
- Graceful Shutdown sollte in der Regel drei Bedingungen erfüllen
- An Einstiegspunkten wie HTTP oder Pub/Sub keine neuen Anfragen oder Nachrichten mehr annehmen
- Warten, bis bereits laufende Requests abgeschlossen sind, und bei zu langer Dauer mit einem Graceful Error antworten
- Wichtige Ressourcen wie Datenbankverbindungen, Dateisperren und Netzwerk-Listener freigeben und letzte Aufräumarbeiten durchführen
- Ausgehende Verbindungen zu externen Diensten wie Datenbanken oder Caches werden nicht schon in der Phase des Blockierens neuer Requests getrennt
- Der Fokus liegt auf HTTP-Servern und Container-Anwendungen, aber die Grundprinzipien gelten auch für andere Anwendungen
Umgang mit Beendigungssignalen
- In Unix-artigen Systemen sind Signale Software-Interrupts, die einem Prozess mitteilen, dass eine bestimmte Situation eingetreten ist
- Ein Prozess kann für bestimmte Signale Handler registrieren; ohne Handler gilt das Standardverhalten
- Das Standardverhalten kann Beenden, Stoppen, Fortsetzen oder Ignorieren sein
- Manche Signale wie
SIGKILLkönnen weder abgefangen noch ignoriert werden und beenden den Prozess
- Die Go-Runtime registriert schon vor Ausführung der
main-Funktion automatisch verschiedene Signal-Handler, darunterSIGTERM,SIGQUIT,SIGILLundSIGTRAP - Für Graceful Shutdown sind vor allem drei Beendigungssignale wichtig
SIGTERM: die standardmäßige und höfliche Art, das Beenden eines Prozesses anzufordern; Kubernetes sendet dieses Signal vor einer erzwungenen Beendigung an die AnwendungSIGINT: wird gesendet, wenn ein Benutzer den Prozess im Terminal mitCtrl+Cstoppen willSIGHUP: wurde ursprünglich beim Trennen einer Terminalverbindung verwendet und dient heute oft auch als Signal zum Neuladen von Konfigurationen
- Ohne gesonderte Behandlung beendet die Go-Runtime die Anwendung bei
SIGTERM,SIGINToderSIGHUP
os/signal und NotifyContext
signal.Notifyweist die Go-Runtime an, bestimmte Signale nicht mit dem Standardverhalten zu behandeln, sondern an einen Channel weiterzuleiten- Für den Signal-Channel ist ein Puffer der Größe 1 meist die stabile Wahl
- Intern verwendet Go für das Senden an den Channel
selectunddefault - Ist im Puffer Platz, wird das Signal zugestellt; ist der Puffer voll, wird das Signal verworfen
- Bei einem ungepufferten Channel kann ein Signal verloren gehen, wenn gerade keine empfangende Goroutine vorhanden ist
- Intern verwendet Go für das Senden an den Channel
signal.Notifykann für dasselbe Signal mehrfach aufgerufen werden; Go sendet das Signal dann an alle registrierten Channels- Selbst wenn
Ctrl+Cmehrfach gedrückt wird, wird die zweite Eingabe normalerweise nicht automatisch zuSIGKILLhochgestuft- Die meisten bash- oder Linux-Shells führen keine automatische Hochstufung durch
- Für ein erzwungenes Beenden muss
SIGKILLdirekt mitkill -9gesendet werden
- Wenn in der lokalen Entwicklung das zweite
Ctrl+Cein erzwungenes Beenden auslösen soll, kann direkt nach dem ersten Signal mitsignal.Stopder Empfang weiterer Signale beendet werden - Seit Go 1.16 lässt sich die Signalbehandlung mit
signal.NotifyContextan Context Cancellation koppeln- Auch nach
ctx.Done()solltestop()aufgerufen werden, damit ein zweitesCtrl+Cdie Anwendung zwangsweise beenden kann
- Auch nach
Shutdown-Zeitlimit und Kubernetes-Verhalten
- Nachdem ein Beendigungssignal eingegangen ist, sollte zuerst geklärt werden, wie viel Zeit der Anwendung real für den Shutdown zur Verfügung steht
- Die Standard-Grace-Period von Kubernetes beträgt 30 Sekunden, sofern
terminationGracePeriodSecondsnicht separat gesetzt ist - Danach sendet Kubernetes
SIGKILLund stoppt die Anwendung zwangsweiseSIGKILLkann nicht abgefangen oder verarbeitet werden
- Die gesamte Shutdown-Logik einschließlich Abarbeitung verbleibender Requests und Freigabe von Ressourcen muss innerhalb dieses Zeitfensters abgeschlossen sein
- Nimmt man bei den Standard-30-Sekunden etwa 20 % als Sicherheitsmarge, sollte der gesamte Shutdown idealerweise innerhalb von 25 Sekunden beendet sein
Blockieren neuer Requests und Readiness-Verhalten
- In Gos
net/httplässt sich Graceful Shutdown mithttp.Server.Shutdownumsetzen- Neue Verbindungen werden nicht mehr angenommen
- Auf den Abschluss aktiver Requests wird gewartet
- Anschließend werden Idle Connections geschlossen
- Bereits laufende Requests können noch abgeschlossen werden; danach geht die Verbindung in den Idle-Zustand über und wird geschlossen
- Clients, die während des Shutdowns eine neue Verbindung aufbauen wollen, erhalten in der Regel einen
connection refused-Fehler, weil der Listener bereits geschlossen ist - In Container- oder Orchestrierungsumgebungen mit externem Load Balancer ist es wichtig, die Annahme neuer Requests nicht sofort zu stoppen
- Ein Pod kann nach der Markierung zur Beendigung noch kurz Traffic empfangen
- Die interne Kubernetes-Komponente
kube-proxyerkennt den Wechsel des Pod-Status zuTerminatingschnell - Externe Load Balancer verwenden jedoch eigene Health Checks unabhängig von Kubernetes und benötigen Zeit für die Zustandspropagierung
- Es gibt zwei Wege, auf die Propagierung der Traffic-Sperre zu warten
- Im
preStop-Hook kurzsleepausführen, damit externe Load Balancer Zeit haben, den Beendigungsstatus des Pods zu erkennen- Die Zeit im
preStopzählt zurterminationGracePeriodSeconds
- Die Zeit im
- Auf Code-Ebene das Readiness Probe fehlschlagen lassen und kurz warten
- Das funktioniert nicht nur in Kubernetes, sondern auch in anderen Umgebungen, in denen der Load Balancer den Bereitschaftszustand kennen muss
- Im
- Ein Readiness Probe prüft regelmäßig, ob der Container bereit ist, Traffic anzunehmen
- Health Checks können per HTTP-Request, TCP-Verbindung oder Kommandoausführung erfolgen
- Scheitert das Probe, entfernt Kubernetes den Pod aus den Service-Endpoints, sodass er keinen Traffic mehr erhält
- Für die Shutdown-Vorbereitung kann etwa ein
atomic.BoolwieisShuttingDownverwendet werden, damit/healthzHTTP 503 zurückgibt - Nachdem der Readiness-Status auf fehlschlagend gesetzt wurde, sollte einige Sekunden auf die Propagierung der Änderung gewartet werden
- Die Beispielkonfiguration verwendet
periodSeconds: 5, und im Textbeispiel wird 5 Sekunden gewartet - Die genaue Wartezeit hängt von der Konfiguration des Readiness Probe ab
- Die Beispielkonfiguration verwendet
Umgang mit laufenden Requests
- Passend zum Shutdown-Budget wird mit
context.WithTimeoutein Zeitlimit erzeugt und anserver.Shutdown(ctx)übergeben server.Shutdownkehrt in zwei Fällen zurück- Alle aktiven Verbindungen sind geschlossen und alle Handler abgeschlossen
- Der übergebene Context läuft vor Abschluss der Handler ab, sodass der Server das Warten aufgibt
- In beiden Fällen kehrt
Shutdownerst zurück, nachdem der Server die Request-Verarbeitung vollständig beendet hat - Handler sollten schnell und context-aware arbeiten
- Andernfalls können sie beim Ablauf des Zeitlimits mitten in der Arbeit unterbrochen werden
- Das kann zu partiellen Schreibvorgängen, Datenverlust, inkonsistentem Zustand, offenen Transaktionen oder beschädigten Daten führen
- Es gibt zwei typische Wege, Handlern das Shutdown-Signal zu übermitteln
- Über Middleware, die jedem Request-Context eine Cancel-Logik injiziert
- Über
BaseContextvonhttp.Server, um allen Verbindungen einen gemeinsamen globalen Context bereitzustellen
- Im HTTP-Server lassen sich
BaseContextundConnContextanpassen- Für Graceful Shutdown ist
BaseContextbesser geeignet, weil damit ein abbrechbarer globaler Context für den gesamten Server erstellt werden kann
- Für Graceful Shutdown ist
- Graceful Shutdown wirkt nur dann zuverlässig, wenn Funktionen die Context-Cancellation respektieren
- Nutzungen wie
context.Background()odertime.Sleep()sollten vermieden werden, wenn sie Cancellation ignorieren time.Sleep(duration)kann durch einselectersetzt werden, das gleichzeitig auftime.After(duration)undctx.Done()wartet
- Nutzungen wie
- In älteren Go-Versionen konnte
time.AfterSpeicher verlieren, bis der Timer ausgelöst wurde- Dieses Problem wurde ab Go 1.23 behoben
- Ist die Version nicht sicher bekannt, können
time.NewTimer,Stopund bei Bedarf eine Prüfung von<-t.Cverwendet werden - Zugehöriges Issue: time: stop requiring Timer/Ticker.Stop for prompt GC
Unterschied zwischen Shutdown und Close
- Dieselben Prinzipien gelten nicht nur für HTTP-Server, sondern auch für Dienste von Drittanbietern
database/sql-DB.Closeschließt Datenbankverbindungen, verhindert den Start neuer Queries und wartet auf den Abschluss laufender Queries- Entscheidend ist, keine neuen Requests oder Nachrichten mehr anzunehmen und bestehenden Arbeiten Zeit zu geben, innerhalb einer definierten Grace Period abzuschließen
server.Close()beendet sofort, ohne auf laufende Verbindungen zu warten- Handler, die das Netzwerk verwenden, erhalten beim Lesen oder Schreiben Fehler
- Clients können sofort Verbindungsfehler wie
ECONNRESETodersocket hang uperhalten - Lang laufende Handler ohne Netzwerkinteraktion können im Hintergrund weiterlaufen
server.Close()kann nach einem Fehler vonserver.Shutdown()verwendet werden, aber das hängt von der gewählten Shutdown-Strategie ab- Das Weiterreichen des Shutdown-Signals über einen Context ist der verlässlichere und gracefulere Ansatz
Reihenfolge beim Freigeben wichtiger Ressourcen
- Ein häufiger Fehler ist es, wichtige Ressourcen sofort nach Eingang des Beendigungssignals freizugeben
- Zu diesem Zeitpunkt können Handler und In-Flight-Requests diese Ressourcen noch verwenden; die Bereinigung sollte daher auf nach Ablauf des Shutdown-Timeouts oder nach Abschluss aller Requests verschoben werden
- In vielen Fällen räumt das Betriebssystem Ressourcen bereits beim Prozessende auf
- Von Go belegter Speicher wird beim Beenden des Prozesses freigegeben
- File Descriptors werden vom Betriebssystem geschlossen
- Auch OS-Ressourcen wie Process Handles werden freigegeben
- In manchen Fällen ist explizites Aufräumen dennoch nötig
- Datenbankverbindungen sollten sauber geschlossen werden, und offene Transaktionen benötigen ein Commit oder Rollback
- Message Queues und Broker können ein Flush von Nachrichten, ein Commit von Offsets oder eine Abmeldung des Clients erfordern
- Externe Dienste erkennen einen Verbindungsabbruch möglicherweise nicht sofort; ein manuelles Schließen der Verbindung kann schneller aufräumen, als auf ein TCP-Timeout zu warten
- Eine gute Regel ist, Komponenten in der umgekehrten Reihenfolge ihrer Initialisierung zu beenden
- Gos
deferpasst gut zu diesem Muster, weil die zuletzt registrierte Funktion zuerst ausgeführt wird
- Gos
- Für manche Komponenten, etwa wenn Daten aus einem In-Memory-Cache auf die Festplatte geschrieben werden müssen, sollte eine eigene Shutdown-Routine entworfen werden
Ablauf des Gesamtbeispiels
- Das Gesamtbeispiel erstellt mit
signal.NotifyContexteinen Root-Context, derSIGINTundSIGTERMempfängt - Der Endpunkt
/healthzgibt beiisShuttingDown == trueHTTP 503 undShutting downzurück, andernfallsOK - Der Beispiel-Request-Handler liefert nach 2 Sekunden
Hello, world!zurück oder antwortet mit HTTP Request Timeout, wenn der Request-Context abgebrochen wird - Über
BaseContextwirdongoingCtxverbunden, damit In-Flight-Requests nicht direkt nachSIGTERMabgebrochen werden - Nach Eingang des Beendigungssignals läuft die folgende Reihenfolge ab
- Aufruf von
stop(), um weitere Standardbehandlung zuzulassen isShuttingDown.Store(true), um den Readiness-Status auf fehlschlagend zu setzen- Warten auf die Propagierung des Readiness Checks für 5 Sekunden über
_readinessDrainDelay - Aufruf von
server.Shutdownmit einem Zeitlimit von 15 Sekunden über_shutdownPeriod - Abbruch des laufenden Contexts über
stopOngoingGracefully() - Falls
Shutdownfehlschlägt, eine Wartezeit für erzwungenes Beenden von 3 Sekunden über_shutdownHardPeriod
- Aufruf von
1 Kommentare
Hacker-News-Kommentare
Ich bin schon einmal darauf hereingefallen, dass Kubernetes in manchen Konfigurationen länger als gedacht braucht, um die Ziel-IPs des Load Balancers zu aktualisieren. In meinem Fall bestanden 90 % des Graceful Shutdowns darin sicherzustellen, dass der Traffic vor dem Beenden des Pods tatsächlich gedraint wird.
Ein 15-Sekunden-Sleep im globalen
preStop-Hook hat die HTTP-503-Rate deutlich gesenkt und Zeit gewonnen, bis nach Beginn der Abmeldung vom Load BalancerSIGTERMan die Anwendung zugestellt wird. Dadurch wurde die Verarbeitung auf Anwendungsseite viel einfacher.preStop-Sleep ist eine Art magische Lösung, um bei hochwertigen Rolling Deployments SLOs einzuhalten.Ich sehe zwei Dinge, die Kubernetes verbessern könnte. Pods sollten zuerst aus den Endpoints entfernt werden, bevor die Beendigungssequenz startet, und es sollte eine Option für einen Termination Delay geben, ähnlich der Termination Grace. Außerdem sollte PDB eine Option haben, vor der Räumung eine Neuerstellung zu erlauben.
Wenn man einen üblichen Prometheus-
/metrics-Endpoint alle N Sekunden scrapt, entsteht ein Zeitfenster, in dem Metriken, die zwischen dem letzten Scrape und dem tatsächlichen Prozessende aufgezeichnet wurden, nicht weitergegeben werden. Dadurch kann man einen falschen Eindruck davon bekommen, ob es während der Beendigungssequenz Fehler gab.Wenn man nicht aufpasst, kann man auch die Logs der letzten Sekunden vor dem Dienstende verlieren. Wenn zum Beispiel ein Sidecar wie Promtail oder Vector eine Logdatei überwacht und der Dienst beim Start denselben Pfad truncatet und danach wieder schreibt, entsteht eine Race Condition, bei der Logs während des Shutdowns verschwinden.
Trotz dieses ganzen Aufwands wird der größte Teil der Daten völlig ignoriert, und Business-Insights sind selten deutlich besser als die Slum-Version, sich per
sshauf einen Server einzuloggen und Logdateien mitgrepzu durchsuchen. Ich bin nicht sicher, ob der Aufwand, der in dieses Ökosystem geflossen ist, Uptime, Performance und Usability signifikant verbessert hat.Dinge wie „Log-Synchronisierung“ und „warten, bis der Ingress den Liveness-Handler eingeholt hat“ werden wir behandeln.
https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
https://github.com/utrack/caisson-go/tree/main/closer
Die Dokumentation ist noch dürftig und es fehlt noch einiges, aber nach dem Urlaub plane ich den ersten Release. Am Ende soll es eine Meta-Plattform und eine Referenz-Plattformbibliothek für typische k8s/otel/grpc+http-Infrastruktur werden.
Wegen dieses Verhaltens verwenden unsere Dienste immer noch statsd, denn ein Push-basiertes Modell hat dieses Problem nicht.
Eine kleine Falle, die ich häufig sehe: Manche glauben, dass bei einem Aufruf von
log.Fatalauchdeferausgeführt wird. Tatsächlich passiert das nicht.log.Fatal("fatal")ruft internos.Exitauf, beendet also sofort, sodassdefernicht läuft.panic("fatal")hingegen zeigt sowohlfatalals auchin deferan.Wenn ein verteiltes System darauf angewiesen ist, dass Clients graceful herunterfahren, damit es korrekt funktioniert, wird es früher oder später zwangsläufig heftig scheitern.
Die einzige Möglichkeit zu prüfen, ob ein System harte Abstürze von Komponenten aushält, besteht darin, harte Abstürze zu einem normalen, ständig auftretenden Ereignis zu machen. Ruhm dem Chaos Monkey.
Es ist ein großer Unterschied, ob eine Anwendung per
sig intheruntergefahren oder perkillgetötet wurde. Für eine Blue-Green-Migration braucht man zum Beispiel ein graceful Shutdown-Verhalten.Wenn ich noch einmal darüber nachdenke, vielleicht muss man es doch. Es könnte die einzige Möglichkeit sein, sicherzustellen, dass diese Annahme stimmt. So ähnlich wie Netflix’ Chaos Monkey vor ein paar Jahren.
Ich dachte, es würde darum gehen, wie eine neue Service-Instanz den Listening Socket von der bestehenden Instanz übernimmt und die Anwendung neu startet, ohne eine einzige eingehende Verbindung zu kappen.
Mit systemd lässt sich das relativ einfach umsetzen, und nginx unterstützt es seit über 20 Jahren. Leider unterstützen Kubernetes und Docker das nicht, weil sie davon ausgehen, dass das im Load Balancer oder Reverse Proxy gehandhabt wird.
Mein Kollege sagte immer, ein Programm sei ein schlecht geschriebenes Programm, wenn es
ctrl cund einige Beendigungsbefehle nicht sauber behandeln kann.Ich finde, Elixir handhabt solche Dinge wirklich clever. Ich habe zwar nicht besonders viel Erfahrung damit, aber weil kleine VM-Prozesse so entworfen sind, dass sie abstürzen, beendet und wieder erstellt werden, scheint es seltener nötig zu sein, absichtlich eine Graceful-Shutdown-Routine zu bauen.
Diese Eigenschaft ist bereits in die Anwendungsarchitektur eingebaut.
Für mein Projekt habe ich eine kleine Bibliothek gebaut, um graceful shutdown zu handhaben: https://github.com/eberkund/graceful
Meist gibt es ein paar Services, die gestartet werden müssen, und jeder hat oft eine andere Art, gestartet und beendet zu werden. Manchmal muss man zuerst ein Objekt instanziieren, manchmal gibt es einen Context, den man abbrechen möchte, und manchmal eine
Stop-Methode, die aufgerufen werden muss. Ich habe sie so entworfen, dass all das an einer Stelle über eine einheitliche API zusammengeführt wird.https://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
Ein Pod, der beendet wird, ist per Definition nicht bereit. Auch der Service markiert den Endpoint als terminating und not ready. Das passiert beim Wechsel in den Terminating-Zustand, daher muss man den Readiness Check nicht extra fehlschlagen lassen.
Die genaue Reihenfolge von
SIGTERMund Aktualisierungen von Objekten wiePod.statusoder Endpoint Slices kenne ich nicht. Es kann ein kleines Zeitfenster geben, in dem auch nachSIGTERMnoch Verbindungen hereinkommen, aber kein großer Zeitraum „bis der Readiness Check fehlschlägt“, wie der Text nahelegt. Aus Sicht des Cluster-Betriebs halte ich dieses winzige Fenster für nicht besonders wichtig. Man sollte keine neuen Verbindungen annehmen, bestehende Verbindungen sauber schließen und in angemessen kurzer Zeit beenden. Allerdings gehört die Hälfte der Apps, mit denen ich zu tun habe, zu denen, dieSIGTERMzwar behandeln, aber lange zum Beenden brauchen, oder zu denen, dieSIGTERMgar nicht behandeln und trotzdem lange zum Beenden brauchen.In einigen Projekten bei JustWatch haben wir Google Wire eingeführt, und das hat die Spielregeln verändert. Es ist überraschend wenig bekannt, hilft aber dabei, unordentliche Shutdown-Logik in Kubernetes loszuwerden.
Wire erzwingt saubere Dependency Injection, sodass jetzt alles in einer festgelegten Reihenfolge herunterfährt, statt in einer unbekannten Reihenfolge.
https://go.dev/blog/wire
https://github.com/google/wire