Kubernetes: Rollende Updates ohne Ausfallzeiten

Oktober 6, 2017

Rolling Update sorgt lediglich dafür, dass Kubernetes die Pods nach und nach stoppt - einen nach dem anderen, wobei immer sichergestellt wird, dass nur die minimal gewünschte Anzahl von Pods läuft. Das mag für Zero-Downtime-Implementierungen ausreichend erscheinen. Aber wie üblich ist es nicht so einfach.

Während der rollierenden Aktualisierung muss Kubernetes die alten Versionen der Pods beenden - das ist ja auch gewollt. Aber das ist ein Problem, wenn Ihr Pod mitten in der Verarbeitung einer HTTP-Anfrage beendet wird. Und das kann passieren, wenn Ihre Anwendung nicht mit Kubernetes kooperiert. In diesem Artikel werde ich untersuchen, warum das passiert und wie man es beheben kann.

Das Problem

Das Problem fiel uns auf, als wir vor kurzem einen unserer meistgenutzten Dienste in unseren Kubernetes-Cluster verlegten. Der Umzug verlief nahtlos, aber nach einiger Zeit begannen wir, scheinbar zufällige Verbindungsfehler zu diesem Dienst auf unserer gesamten Plattform zu sehen.

Nach einigen Untersuchungen stellten wir fest, dass bei der Bereitstellung eines neuen Updates für den Dienst einige der anderen Dienste während der Bereitstellung einige Anfragen nicht bearbeiten konnten.

Um diese Hypothese zu beweisen, habe ich einen synthetischen Test durchgeführt, bei dem ich wrk verwendete und gleichzeitig die rollende Aktualisierung auslöste:

wrk https://www.server.com/api &
sleep 1 && kubectl set image deployment/api api=driftrock/api:2

Das Ergebnis bestätigte das Problem:

Laufender 10s Test @ https://www.server.com/api
2 Threads und 10 Verbindungen
Thread Stats Avg Stdev Max +/- Stdev
Latency 290.50ms 176.36ms 1.19s 87.78%
Req/Sec 19.39 9.31 49.00 47.06%
368 Anfragen in 10.10s, 319.48KB gelesen
Non-2xx oder 3xx Antworten: 19
Anfragen/Sek: 36,44
Übertragung/Sek: 31,63KB

Lassen Sie uns das Thema in diesem Artikel näher beleuchten.

Kubernetes-Pod-Beendigungsprozess

Schauen wir uns zunächst an, wie Kubernetes Pods terminiert. Es ist wichtig zu verstehen, wie die Anwendung die Beendigung handhaben sollte.

Laut der Dokumentation https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods werden diese Schritte (mehr oder weniger) durchgeführt:

  1. Wenn der Pod über Netzwerkdienste verfügt, leitet Kubernetes keine neuen Verbindungen mehr an den beendenden Pod weiter. Bereits bestehende Verbindungen bleiben intakt und offen.
  2. Kubernetes sendet das TERM-Signal an den Root-Prozess jedes Containers im Pod und geht davon aus, dass die Container aufhören werden. Das gesendete Signal kann nicht konfiguriert werden.
  3. Er wartet die in terminationGracePeriodSeconds des Pods angegebene Zeitspanne ab (standardmäßig 30 Sekunden). Wenn die Container zu diesem Zeitpunkt noch in Betrieb sind, sendet er KILL und beendet die Container, ohne ihnen die Chance auf einen weiteren Atemzug zu geben.

Das sieht gut aus. Was ist das Problem?

Es scheint, dass Kubernetes alles Notwendige tut - es stoppt das Senden neuer Verbindungen und gibt der Anwendung 30 Sekunden Zeit, um die aktuelle Verarbeitung zu beenden.

Allerdings gibt es zwei wichtige Haken. Der erste ist die HTTP-Keep-Alive-Verbindung und der zweite ist, wie Anwendungen auf das TERM-Signal reagieren.

Keep-alive-Verbindungen

Wie wir oben sehen konnten, hält Kubernetes alle bestehenden Netzwerkverbindungen während der Beendigung aufrecht. Das macht Sinn - Kubernetes gibt der Anwendung 30 Sekunden Zeit, um ihren Clients mitzuteilen, dass sie die Verbindung trennen oder die Antwort senden sollen.

Wenn die Anwendung jedoch nichts unternimmt, empfängt sie neue Anfragen über die geöffnete Verbindung, bis die 30 Sekunden abgelaufen sind. Wenn der Client also nur eine Millisekunde vor dem Beenden des Containers durch Kubernetes mit dem KILL-Signal eine neue Anforderung sendet, wird die Verbindung unterbrochen, anstatt eine Antwort zu erhalten.

In der HTTP-Welt betrifft dies nicht nur Browser-Clients, bei denen ein paar unterbrochene Verbindungen nicht unbedingt eine Katastrophe darstellen. Die meisten Anwendungen laufen hinter einem Load Balancer. In den meisten Fällen verbindet sich der LB mit dem Backend über eine Keep-Alive-Verbindung. Dies wirkt sich dann auch auf API-Aufrufe aus, auch wenn diese Aufrufe normalerweise als getrennte Verbindungen vom Client zur öffentlichen Seite des Load Balancer erfolgen.

TERM Fehlbedienung von Signalen

Jetzt werden Sie vielleicht denken: Moment mal, warum hört die Anwendung nicht auf? Warum erhält sie nach dem Empfang des TERM-Signals bis zur letzten CPU-Anweisung weiterhin neue Anfragen?

Das Problem besteht darin, dass verschiedene Anwendungen in verschiedenen Docker-Image-Konfigurationen das Signal auf verschiedene, andere Weise behandeln.

Bei dieser Untersuchung sind mir 3 verschiedene Fälle aufgefallen, in denen Anwendungen das TERM-Signal behandeln:

  • es wird ignoriert - zum Beispiel hat das Phoenix-Framework von Elixir keine eingebaute "graceful termination"(siehe hier)
  • es tut etwas anderes - zum Beispiel beendet sich nginx sofort (d.h. nicht graceful)(siehe hier)
  • oder es wird in der Shell des Docker-Containers ausgeführt (z. B. /bin/sh -c "rails s"), so dass die Anwendung das Signal überhaupt nicht erhält.

Jeder dieser Fälle führt dazu, dass das beschriebene Problem mit den Keep-Alive-Verbindungen auftritt. Schauen wir uns an, wie man dies vermeiden und die Anwendungen für das rollende Update von Kubernetes bereit machen kann.

Zero-downtime ready pod™

Aus den obigen Ausführungen können wir ersehen, was erfüllt sein muss, damit Pods wirklich für rollende Updates auf Kubernetes bereit sind. Lassen Sie mich versuchen, es in Punkten auszudrücken:

  • Wenn der Pod über einen Netzwerkdienst verfügt, muss er nach dem Empfang des TERM-Signals die Verbindungen ordnungsgemäß beenden
  • Wenn Ihre Anwendung über eine integrierte Verarbeitung des TERM-Signals verfügt, stellen Sie sicher, dass sie es empfängt, wenn sie im Docker-Container ausgeführt wird
  • Legen Sie die terminationGracePeriodSeconds entsprechend der erwarteten maximalen Zeit für die Beendigung eines in Bearbeitung befindlichen Auftrags fest. Dies hängt von den Arbeitslasten Ihrer Anwendung ab.
  • Lassen Sie mindestens 2 (idealerweise jedoch 3) Replikate laufen und richten Sie die Bereitstellung so ein, dass während der rollenden Aktualisierungen mindestens 1 Pod läuft.

Wenn alle Bedingungen erfüllt sind, kümmert sich Kubernetes um den Rest und sorgt dafür, dass die rollenden Updates tatsächlich keine Ausfallzeiten verursachen - sowohl für Netzwerkanwendungen als auch für lang laufende Hintergrundlasten.

Schauen wir uns nun an, wie diese Bedingungen erreicht werden können.

Entleeren von Verbindungen (mit nginx Reverse Proxy)

Leider lassen sich nicht alle Anwendungen (einfach) so aktualisieren, dass Verbindungen ordnungsgemäß abgebaut werden können. Wie oben erwähnt, hat das Phoenix-Framework keine eingebaute Unterstützung. Um den Umgang mit solchen Diskrepanzen zwischen verschiedenen Technologien zu vermeiden, können wir ein einfaches Nginx-Image erstellen, das dies universell handhabt.

Aus der Dokumentation über die Entleerung von Anschlüssen erfahre ich

nginx kann mit Signalen an den Hauptprozess gesteuert werden:
TERM, INT fast shutdown
QUIT graceful shutdown

Großartig! Er hat ein Signal für das sanfte Herunterfahren. Aber halt ... Wir müssen ihm dazu das QUIT-Signal senden, aber Kubernetes sendet nur TERM, das auch von Nginx empfangen würde, aber mit einem anderen Ergebnis - sofortiges Beenden.

Um dies zu umgehen, habe ich ein kleines Shell-Skript geschrieben, das den nginx-Prozess umgibt und das TERM-Signal in ein QUIT-Signal umwandelt:

#!/bin/sh

# Trap für TERM (= 15) Signal einrichten, und stattdessen QUIT an nginx senden
trap "echo SIGTERM trapped. Signalisiert nginx mit QUIT.; kill -s QUIT \$(cat /var/run/nginx.pid)" 15

# Starten Sie den Befehl im Hintergrund - so ist die Shell im Vordergrund und empfängt Signale (ansonsten ignoriert sie Signale)
nginx "$@" &

CHILD_PID=$!
echo "Nginx gestartet mit PID $CHILD_PID"

# Warten Sie auf das Kind in der Schleife - anscheinend führt das QUIT-Signal an nginx dazu, dass das `wait` zu
# exit wird, selbst wenn der Prozess noch läuft. Fügen wir also auch eine Schleife hinzu, bis der Prozess
# existiert
while kill -s 0 $CHILD_PID; do wait $CHILD_PID; done

echo "Process $CHILD_PID exited. Exiting too..."

Durch Hinzufügen einer einfachen default.conf mit Reverse-Proxy-Konfiguration können wir das Dockerfile für unser Proxy-Image erstellen:

FROM nginx:mainline-alpine

COPY default.conf /etc/nginx/conf.d/
ADD trap_term_nginx.sh /usr/local/bin/

CMD [ "/usr/local/bin/trap_term_nginx.sh", "-g", "daemon off;" ]

Das Senden des TERM-Signals an den neuen Container führt dazu, dass nginx sich ordnungsgemäß beendet - d.h. er wartet, bis die aktiven Anfragen beantwortet sind, und nimmt keine neuen Anfragen über die bestehenden Verbindungen an. Dann wird er sich selbst beenden.

Wir haben das Image freigegeben und es kann im Docker Hub gefunden werden: https://hub.docker.com/r/driftrock/https-redirect-proxy/

Sicherstellen, dass der Prozess das Signal erhält

Eine weitere Bedingung, die ich oben beschrieben habe, besteht darin, sicherzustellen, dass der Prozess das Signal erhält, wenn wir wissen, dass die Anwendung bereit ist, es zu verarbeiten.

Zum Beispiel behandelt sidekiq TERM bereits so, wie wir es wollen. Dies mag den Anschein erwecken, als gäbe es nichts Zusätzliches zu tun. Leider muss man in einer Docker-Umgebung, wie es Kubernetes ist, besonders vorsichtig sein, um keine unbeabsichtigten Fehler zu machen.

Das Problem tritt auf, wenn der Container für die Ausführung von Befehlen über die Shell eingerichtet ist. Zum Beispiel:

Befehl: ["/bin/sh", "-c", "rake db:migrate && sidekiq -t 30"]

In diesem Fall wird der Root-Prozess des Containers /bin/sh sein. Wie oben beschrieben, sendet Kubernetes das Signal an ihn. Was auf den ersten Blick nicht klar ist, ist, dass die UNIX-Shell Signale ignoriert, wenn ein Kindprozess in ihr läuft. Sie leitet es weder an den Kindprozess weiter, noch tut sie irgendetwas anderes. Das führt dazu, dass das Signal nicht an unsere Anwendung gesendet wird - im obigen Beispiel Sidekiq.

Es gibt zwei Möglichkeiten, dies zu beheben. Der einfache Weg ist, die Shell anzuweisen, sich selbst durch den letzten Befehl mit dem Befehl exec zu ersetzen:

Befehl: ["/bin/sh", "-c", "rake db:migrate && exec sidekiq -t 30"]

Wenn es möglich ist, sollten Sie jedoch die Verwendung eines Shell-Wrappers ganz vermeiden. Führen Sie den Befehl direkt als ersten Prozess aus und verwenden Sie Kubernetes Init-Container für die Befehle, die Sie vor dem Start der Anwendung ausführen möchten.

Einstellung Zero-downtime ready pod™

Wenn wir über einen Proxy verfügen, der Keep-alive-Verbindungen in HTTP-Anwendungen handhabt, und wissen, wie wir sicherstellen, dass andere Anwendungen das TERM-Signal erhalten, um ordnungsgemäß beendet zu werden, können wir unseren Zero-downtime ready pod™ konfigurieren.

Zuerst müssen Sie den nginx-Proxy einrichten. Fügen Sie ihn als weiteren Container zu Ihrem Pod hinzu. Der Proxy geht davon aus, dass Ihre Anwendung auf Port 8080 lauscht und selbst auf Port 80 lauschen wird. Wenn Ihre Dienste bereits für Port 80 konfiguriert sind, brauchen Sie nichts weiter zu tun, fügen Sie einfach den Container hinzu (Anmerkung am Rande: Wir finden es nützlich, den Container genauso zu benennen wie den Anwendungscontainer, mit dem Suffix -proxy).

...
container:
...
- name: APP-NAME-proxy
image: driftrock/https-redirect-proxy
ports:
- containerPort: 80
...
...

Als Zweites müssen wir sicherstellen, dass die Anwendung bereit ist, TERM-Signale zu empfangen. Wie ich oben beschrieben, können wir tun:

...
Container:
- name: APP-NAME
command: ['/bin/sh', '-c', 'rake db:migrate && exec puma -p 8080']
...
...
...

Und das ist alles. Natürlich kann es sein, dass Sie aufgrund Ihrer speziellen Gegebenheiten hier und da etwas anders vorgehen müssen. Ich hoffe aber, dass Sie jetzt die Idee verstanden haben.

Ergebnis

Um dies zu testen, haben wir die Pods mit dem neuen Proxy-Image aktualisiert, dann den Benchmark gestartet und Kubernetes dazu gebracht, das Rolling Update erneut durchzuführen:

Laufender 10s Test @ https://www.server.com/api
2 Threads und 10 Verbindungen
Thread Stats Avg Stdev Max +/- Stdev
Latency 249.36ms 95.76ms 842.78ms 90.19%
Req/Sec 20.04 9.21 40.00 54.05%
403 Anfragen in 10.10s, 349.87KB gelesen
Anfragen/sec: 39.91
Transfer/sec: 34.65KB

Jetzt wurden alle Benchmark-Anfragen erfolgreich abgeschlossen, auch wenn die Pods während des rollenden Test-Updates beendet und neu gestartet wurden.

Lass den Chaos-Affen frei

Danach dachte ich, wie ich das unter Stress testen könnte. Wenn das Kubernetes-Deployment das Rolling Update durchführt, wird der Pod einfach auf der Rückseite beendet. Das Gleiche, was man mit kubectl delete pod [POD_NAME] machen kann. Die oben beschriebenen Schritte der Beendigung stammen aus dem Artikel "Pod termination process", nicht "Deployment rolling update process".

In Anbetracht dessen war ich daran interessiert, ob das neue Setup mit nur Tötung der Pods in der Schleife (nur sicherstellen, dass es mindestens ein Pod läuft die ganze Zeit), so dass sie nur wirklich kurze Zeit, um sogar leben zu behandeln. Theoretisch sollte es funktionieren. Der Pod startet, erhält vielleicht eine Anfrage und beginnt, diese zu verarbeiten. Gleichzeitig erhält er ein TERM-Signal, da mein Chaos-Affe versuchen wird, ihn zu töten. Die Anfrage ist dann beendet und es werden keine neuen Anfragen mehr an ihn weitergeleitet.

Mal sehen, was passiert:

wrk -t 120 https://www.server.com/api &

while true; do
sleep 5
READY_PODS=$(kubectl get pods -l app=api-server -o json | jq -r ".items | map(select(.metadata | has(\"deletionTimestamp\") | not)) | map(select(.status.containerStatuses | map(.ready) | all)) | .[].metadata.name")
EXTRA_READY_PODS=$(echo $READY_PODS | ruby -e 'puts STDIN.readlines.shuffle[1..-1]' | tr '\n' ' ' )
/bin/sh -c "kubectl delete pods $EXTRA_READY_PODS"
kubectl get pods -l app=api-server
done

Liefert das Ergebnis:

Laufender 120s Test @ https://www.server.com/api
2 Threads und 10 Verbindungen
Thread Stats Avg Stdev Max +/- Stdev
Latency 953.28ms 513.90ms 3938.00ms 90.19%
Req/Sec 10.49 9.21 40.00 54.05%
1261 Anfragen in 120.209s, 21.022MB gelesen
Anfragen/sec: 10.49
Transfer/sec: 175.48KB

Dies zeigt, dass die Einrichtung keine einzige Verbindung abbricht, selbst wenn die Pods beendet werden, sobald sie bereit sind und Anfragen erhalten. ERFOLG!

Schlussfolgerung

Zusammenfassend haben wir also ein paar einfache Prinzipien gefunden, die erfüllt werden müssen, damit Kubernetes für uns funktioniert und wir Zero-Downtime-Bereitstellungen beibehalten können. Die Lösung, die wir gefunden haben, funktioniert auch unter Stress.

Vielen Dank fürs Lesen und lassen Sie uns wissen, was Sie denken. Wenn Sie eine andere Möglichkeit haben, dieses Problem zu lösen, würden wir das auch gerne wissen!