Zum Inhalt springen
Kubernetes

Verlorene Anfragen beim Rollout: warum Kubernetes 502 liefert, während der Pod herunterfährt

Ein Rollout kann sauber durchlaufen, alle Pods kommen hoch, alle sind Ready, keine Neustarts, und trotzdem verlieren ein paar hundert Anfragen ihren Weg. In den Logs der Anwendung steht davon nichts, weil diese Anfragen die Anwendung nie erreicht haben. Dahinter steckt kein Konfigurationsfehler. Beim Beenden eines Pods läuft das Abmelden aus dem Netzwerk nicht vor dem Herunterfahren, sondern daneben. Dieser Artikel zeigt den Fehler an einem Spring-Boot-Dienst hinter einem NGINX-Ingress, dann die Korrektur mit preStop-Hook und Graceful Shutdown, und zum Schluss einen Lasttest, der beweist, dass es wirklich weg ist.

Inhalt

Ein Release um zehn nach zwei

Ein Spring-Boot-Dienst checkout im Namespace shop-prod, drei Replikate, davor ein Ingress mit dem NGINX-Controller. Deployment-Strategie RollingUpdate, wie sie aus jedem Helm-Chart fällt. Ein Release ist nichts weiter als ein neuer Image-Tag.

Um 02:10 läuft genau so ein Release durch. Kubernetes startet einen neuen Pod, wartet, bis er Ready meldet, und beendet dafür einen alten. Dreimal hintereinander. Nach knapp zwei Minuten ist die neue Version überall, alle Pods laufen, kein Container ist neu gestartet, kein Event sieht auffällig aus.

Sechs Stunden später liegen elf Meldungen über abgebrochene Bestellungen im Support-Postfach, alle aus dem Fenster zwischen 02:10 und 02:12.

Die erste Suche geht in die Anwendungs-Logs von checkout, und die sind sauber. Keine Exception, kein Fehler, keine abgebrochene Transaktion. Aus Sicht der Anwendung hat es diese elf Bestellungen nie gegeben.

Sie stehen im Log des Ingress-Controllers, mit Status 502 und dem Vermerk connect() failed (111: Connection refused) while connecting to upstream. Die Anfragen sind also angekommen, nur eben nicht dort, wo jemand danach gesucht hat.

Was beim Beenden eines Pods wirklich passiert

Die verbreitete Vorstellung ist eine Reihenfolge: Kubernetes meldet den Pod ab, wartet, bis kein Verkehr mehr kommt, und fährt ihn dann herunter. So wäre es logisch. So ist es nicht.

Wenn ein Pod gelöscht wird, setzt der API-Server einen Zeitstempel für die Löschung, und ab da laufen zwei Vorgänge gleichzeitig los:

Zeitpunkt 0: Pod wird zum Löschen markiert
   │
   ├─ Strang A (auf dem Knoten)
   │    kubelet führt den preStop-Hook aus
   │    danach: SIGTERM an PID 1 im Container
   │    Anwendung fährt herunter
   │
   └─ Strang B (im Cluster)
        EndpointSlice-Controller nimmt die Pod-IP heraus
        API-Server verteilt die Änderung
        kube-proxy schreibt auf jedem Knoten seine Regeln um
        Ingress-Controller schreibt seine Upstream-Liste neu

Die Kubernetes-Dokumentation ist an dieser Stelle deutlich: Das Entfernen aus den EndpointSlices geschieht asynchron zum Herunterfahren. Wer sich auf eine Reihenfolge verlässt, verlässt sich auf ein Rennen.

Und dieses Rennen gewinnt in der Regel Strang A. Ein SIGTERM ist auf dem Knoten sofort da. Strang B muss über den API-Server, dann auf jeden einzelnen Knoten, dann durch das Neuladen der Konfiguration im Ingress-Controller. Das sind je nach Clustergröße Hunderte Millisekunden bis mehrere Sekunden.

Wichtig für das Verständnis: Es sind zwei verschiedene Konsumenten, die beide von Strang B abhängen, aber auf verschiedenen Wegen Verkehr schicken.

  • kube-proxy ist für den Verkehr innerhalb des Clusters zuständig, also für Aufrufe, die über die ClusterIP eines Service gehen. Er schreibt iptables- oder IPVS-Regeln um.
  • Der Ingress-Controller ist für den Verkehr von außen zuständig. Der NGINX-Controller umgeht die ClusterIP komplett und hält eine eigene Liste von Pod-IPs als Upstreams. kube-proxy ist an diesem Pfad gar nicht beteiligt.

Beide beziehen ihre Wahrheit aus denselben EndpointSlices, beide brauchen ihre eigene Zeit, sie umzusetzen. Deshalb betrifft das Problem sowohl Aufrufe von außen als auch Aufrufe zwischen zwei Diensten im selben Cluster.

Warum das Anwendungs-Log leer bleibt

In dem Fenster, in dem die Anwendung schon zumacht und der Upstream noch eingetragen ist, kommt die Anfrage an einem Port an, der nicht mehr annimmt. Das Betriebssystem antwortet mit einem TCP-Reset, der Ingress-Controller übersetzt das in ein 502 an den Client.

Die Anwendung hat davon nichts mitbekommen. Es gab keinen Request-Handler, keinen Filter, keinen Interceptor, der hätte loggen können. Der Verbindungsaufbau ist gescheitert, bevor irgendetwas auf HTTP-Ebene passiert ist.

Das erklärt die zweite Hälfte des Problems. Nicht nur, dass der Fehler auftritt, sondern auch, dass er an der Stelle unsichtbar ist, an der man zuerst nachsieht:

Wo Was steht da
Anwendungs-Log nichts
Metriken der Anwendung (http_server_requests) nichts, der Zähler wird nur bei bedienten Anfragen erhöht
Traces nichts, der Span beginnt erst im Server
Ingress-Log 502 und connect() failed (111: Connection refused)
Kubernetes-Events nichts, der Pod ist ja plangemäß gegangen

Wer nur die Anwendungsseite überwacht, hat für diesen Fehler keinen einzigen Indikator. Die Fehlerquote in Grafana bleibt bei null, weil die Anfragen nie gezählt wurden. Das ist derselbe blinde Fleck wie bei fehlenden Zeitreihen: Ein Wert, den niemand misst, sieht aus wie ein Wert, der in Ordnung ist.

Warum der Fehler sporadisch wirkt und die Schreiber trifft

An dieser Stelle kommt der Einwand, den es in jeder Diskussion gibt: NGINX probiert doch bei einem Verbindungsfehler den nächsten Upstream. Das stimmt, und es ist der Grund, warum das Problem oft jahrelang unentdeckt bleibt.

Der NGINX-Ingress-Controller setzt proxy_next_upstream standardmäßig auf error timeout. Ein abgelehnter Verbindungsaufbau ist ein error, also wird die Anfrage an den nächsten Pod weitergereicht, und der Client merkt nichts.

Nur gilt das nicht für alle Anfragen. NGINX wiederholt von sich aus keine nicht-idempotenten Anfragen, also kein POST, PATCH oder LOCK. Der Grund ist vernünftig: Bei einer Anfrage, die etwas verändert, kann NGINX nicht wissen, ob der Server sie vor dem Verbindungsabbruch schon verarbeitet hat. Eine Wiederholung würde im Zweifel doppelt bestellen. Wer das trotzdem will, muss non_idempotent explizit in die Direktive schreiben.

Daraus folgt das Fehlerbild, das im Postfach ankommt:

  • GET-Anfragen werden still wiederholt und landen bei einem gesunden Pod. Niemand beschwert sich.
  • POST-Anfragen schlagen durch und kommen als 502 beim Client an.

Deshalb wirkt das Problem sporadisch, deshalb lässt es sich nicht durch Neuladen der Seite nachstellen, und deshalb trifft es ausgerechnet die Aufrufe, die Geld bewegen. Eine Bestellung, ein Zahlungsvorgang, eine Registrierung: alles POST.

Wer die 502er im Ingress-Log nach Methode aufschlüsselt, sieht das sofort. Der Anteil der schreibenden Anfragen liegt weit über ihrem Anteil am Gesamtverkehr.

Der Reflex, der nicht hilft: die Readiness Probe schärfer stellen

Der erste Einfall, wenn man das Bild verstanden hat, ist meistens: Dann muss die Readiness Probe eben schneller merken, dass der Pod weggeht.

Das geht an der Sache vorbei, und zwar aus zwei Gründen.

Erstens beantwortet die Readiness Probe eine andere Frage. Sie klärt, ob ein laufender Pod gerade Verkehr bekommen soll, etwa während er startet oder eine Abhängigkeit kurz nicht erreicht. Beim Beenden wird der Pod von Kubernetes ohnehin als nicht bereit behandelt, sobald er zum Löschen markiert ist. Auf das Ergebnis der nächsten Prüfung wartet dabei niemand.

Zweitens würde es selbst dann nicht reichen. Angenommen, die Probe liefe jede Sekunde und schlüge sofort an: Der Weg von dieser Erkenntnis bis zur neu geschriebenen Upstream-Liste im Ingress-Controller ist derselbe wie vorher. Man hätte den Auslöser beschleunigt, nicht die Verteilung.

Die Readiness Probe ist trotzdem nicht wertlos, sie löst nur die andere Hälfte des Problems. Beim Hochfahren sorgt sie dafür, dass ein Pod erst Verkehr bekommt, wenn er ihn beantworten kann. Beim Herunterfahren braucht es einen anderen Mechanismus.

Der preStop-Hook, der nichts tut

Der Mechanismus ist so simpel, dass er beim ersten Lesen falsch wirkt: Der Pod wartet, bevor er anfängt herunterzufahren. Er tut in dieser Zeit nichts Besonderes, er bleibt einfach vollständig arbeitsfähig.

lifecycle:
  preStop:
    sleep:
      seconds: 10

Was dabei passiert, ist genau das Gewünschte: Strang A wird künstlich ausgebremst, während Strang B ungebremst weiterläuft. Der kubelet führt den Hook aus und schickt das SIGTERM erst danach. In diesen zehn Sekunden ist der Pod noch immer ein voll funktionsfähiger Server. Er nimmt Verbindungen an, beantwortet Anfragen und weiß nicht einmal, dass er gehen soll.

Parallel dazu läuft die Abmeldung durch den Cluster, kube-proxy schreibt seine Regeln um, der Ingress-Controller lädt seine Upstream-Liste neu. Wenn das SIGTERM schließlich ankommt, schickt niemand mehr etwas an diese Pod-IP.

Zur Verfügbarkeit der sleep-Aktion, weil das regelmäßig durcheinandergeht:

Kubernetes-Version Stand
1.29 Alpha, Feature Gate muss aktiviert werden
1.30 bis 1.33 Beta, standardmäßig aktiv
ab 1.34 stabil

Auf Clustern vor 1.30 bleibt der klassische Weg:

lifecycle:
  preStop:
    exec:
      command: ["sleep", "10"]

Der funktioniert genauso, setzt aber ein sleep-Binary im Image voraus. Bei Distroless- oder Scratch-Images ist keins vorhanden, und der Hook scheitert still. Genau dafür gibt es die native sleep-Aktion.

Zur Dauer: Zehn Sekunden sind ein brauchbarer Ausgangswert für einen normalen Cluster. Der richtige Wert ist die Zeit, die eine Endpoint-Änderung bei dir braucht, bis sie überall angekommen ist. Auf einem kleinen Cluster reichen fünf, bei vielen Knoten oder einem externen Load Balancer mit eigenem Health-Check-Intervall können es dreißig sein. Ein Cloud-Load-Balancer, der alle zehn Sekunden prüft und zwei Fehlschläge braucht, hat allein dafür zwanzig Sekunden Verzug.

Die Rechnung, die aufgehen muss

Hier liegt die Falle, die den ganzen Fix wieder kaputt macht. Der Zähler für terminationGracePeriodSeconds startet nicht nach dem preStop-Hook, sondern zusammen mit ihm.

Die Kubernetes-Dokumentation sagt es klar: Die Gnadenfrist gilt für die Gesamtzeit aus preStop-Hook und regulärem Herunterfahren des Containers.

Damit gilt:

preStop-Wartezeit  +  Zeit zum Austrudeln  <  terminationGracePeriodSeconds

Der Standardwert für terminationGracePeriodSeconds ist 30 Sekunden. Wer einen preStop-Hook mit 10 Sekunden einbaut und ihn dabei belässt, hat für das eigentliche Herunterfahren nur noch 20 Sekunden übrig.

Unangenehm wird es in Kombination mit Spring Boot. Dort steht spring.lifecycle.timeout-per-shutdown-phase standardmäßig ebenfalls auf 30 Sekunden. Rechnet man das zusammen, kommt man auf 40 und ist damit über der Gnadenfrist:

10s preStop  +  30s Austrudeln  =  40s  >  30s Gnadenfrist

Was dann passiert, ist besonders ärgerlich: Nach 30 Sekunden schickt der kubelet ein SIGKILL. Das ist nicht abfangbar, der Prozess ist sofort weg, und die Anfragen, die gerade noch bedient wurden, brechen mitten in der Antwort ab. Man hat also die 502er beim Verbindungsaufbau beseitigt und sich dafür abgebrochene Antworten bei langlaufenden Anfragen eingehandelt.

Die Rechnung muss deshalb explizit gesetzt werden:

terminationGracePeriodSeconds: 60

Mit 10 Sekunden preStop und 30 Sekunden Austrudeln bleiben 20 Sekunden Reserve. Diese Reserve ist bewusst großzügig, weil der Ablauf noch andere Verzögerungen kennt, etwa das Beenden von Sidecars.

Die Anwendung muss mitspielen: Graceful Shutdown in Spring Boot

Der preStop-Hook schützt die Anfragen, die noch nicht da sind. Er schützt nicht die Anfragen, die gerade bearbeitet werden. Dafür muss die Anwendung auf SIGTERM richtig reagieren, und das tut sie nicht von allein.

Spring Boot beendet sich standardmäßig sofort. Laufende Anfragen werden abgeschnitten. Der Schalter dafür:

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

Mit graceful schließt Spring Boot beim SIGTERM zuerst den Annahmepunkt, nimmt also keine neuen Verbindungen mehr an, und lässt die laufenden Anfragen zu Ende laufen. Erst danach beendet sich der Kontext. timeout-per-shutdown-phase ist die Obergrenze dafür.

Ein Detail, das dabei gern kippt: Das SIGTERM muss auch wirklich beim Java-Prozess ankommen. Es geht an PID 1 im Container. Wer sein Image mit einem Shell-Wrapper startet, hat als PID 1 die Shell, und die reicht Signale nicht automatisch weiter.

# Falsch: die Shell ist PID 1 und schluckt das SIGTERM
ENTRYPOINT java -jar /app.jar

# Richtig: der Java-Prozess ist PID 1
ENTRYPOINT ["java", "-jar", "/app.jar"]

Die erste Form ist die Shell-Form, Docker startet sie über /bin/sh -c. Die zweite ist die Exec-Form. Wer ein Startskript wirklich braucht, beendet es mit exec java -jar /app.jar, damit der Java-Prozess das Skript ersetzt und dessen PID übernimmt.

Prüfen lässt sich das in einem Satz:

kubectl exec -it deploy/checkout -- ps -o pid,comm

Steht dort bei PID 1 etwas anderes als java, kommt das Signal nie an, und die gesamte Graceful-Shutdown-Konfiguration ist wirkungslos.

Das vollständige Manifest

Alle Teile zusammen, an einem Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
  namespace: shop-prod
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: checkout
          image: registry.example.com/checkout:1.4.2
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              sleep:
                seconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 10
            failureThreshold: 6
          startupProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
            failureThreshold: 30

Drei Einstellungen darin verdienen eine Begründung.

maxUnavailable: 0 sorgt dafür, dass während des Rollouts nie weniger Pods bereitstehen als vorgesehen. Zusammen mit maxSurge: 1 startet Kubernetes erst einen neuen Pod und beendet dann einen alten. Ohne das nimmt der Rollout selbst Kapazität weg, und die verbleibenden Pods bekommen zusätzlich zur Umschaltung noch eine Lastspitze.

Die getrennten Endpunkte für Readiness und Liveness kommen aus Spring Boot Actuator und müssen aktiviert werden:

management.endpoint.health.probes.enabled=true

Damit stehen /actuator/health/readiness und /actuator/health/liveness bereit. Der Unterschied ist wesentlich: Der Readiness-Endpunkt bezieht Abhängigkeiten wie die Datenbank ein, der Liveness-Endpunkt nicht. Wer für beide denselben Pfad nimmt und dort die Datenbank prüft, baut sich einen Neustart-Sturm: Fällt die Datenbank aus, startet Kubernetes alle Pods neu, obwohl keiner von ihnen kaputt ist.

Die startupProbe deckt den Start ab. Mit failureThreshold: 30 und periodSeconds: 5 darf die Anwendung 150 Sekunden zum Hochkommen brauchen, ohne dass die Liveness Probe dazwischenfunkt. Für eine JVM mit größerem Spring-Kontext ist das ein realistischer Rahmen.

Was der preStop-Hook nicht löst

Vier Grenzen, die in der Praxis auffallen.

Bestehende Keep-Alive-Verbindungen bleiben außen vor. Der preStop-Hook schützt neue Verbindungsaufbauten. Eine bereits offene Keep-Alive-Verbindung vom Ingress zum Pod bleibt bestehen und wird weiter benutzt, auch nachdem der Pod aus den Upstreams verschwunden ist. Schließt die Anwendung sie beim Herunterfahren hart, kann die Anfrage darauf noch scheitern. Spring Boots Graceful Shutdown behandelt diesen Fall korrekt, indem es laufende Anfragen zu Ende bringt.

Langlaufende Anfragen sprengen jeden Rahmen. Ein Export, der drei Minuten rechnet, überlebt keine Gnadenfrist von 60 Sekunden. Solche Arbeit gehört nicht in eine HTTP-Anfrage, die ein Rollout überstehen soll, sondern in einen Job oder eine Queue.

Dauerverbindungen wie WebSockets und SSE werden beim Beenden zwangsläufig getrennt. Hier hilft kein Timing, sondern nur ein Client, der die Verbindung neu aufbaut.

Externe Load Balancer folgen ihrem eigenen Rhythmus. Steht vor dem Cluster ein Cloud-Load-Balancer, der die Knoten selbst per Health Check prüft, gilt dessen Takt zusätzlich. Ein Prüfintervall von zehn Sekunden mit zwei nötigen Fehlschlägen ergibt zwanzig Sekunden, in denen weiter Verkehr geschickt wird. Die preStop-Wartezeit muss dann darüber liegen.

Ein Randfall noch, weil er beim Suchen oft auftaucht: Kubernetes kennt in den EndpointSlices die Bedingungen serving und terminating. Ein Pod, der gerade beendet wird, ist terminating, kann aber weiterhin serving sein. Manche Proxys nutzen das, um Verkehr im Notfall an beendende Pods zu schicken, wenn sonst keiner mehr übrig ist. Das ist eine Rettungsleine gegen Totalausfall, kein Ersatz für sauberes Timing.

Den Fehler herstellen, bevor er von selbst kommt

Der Punkt, an dem sich alles entscheidet: Ein Rollout ohne Last beweist gar nichts. Fast jeder Rollout wird in einer Umgebung getestet, in der niemand die Anwendung benutzt, und dort tritt der Fehler naturgemäß nicht auf.

Der Test besteht aus zwei Terminals. Im ersten läuft dauerhaft Last, und zwar mit POST, weil GET wiederholt würde:

hey -z 120s -c 20 -m POST 
  -H "Content-Type: application/json" 
  -d '{"sku":"A-1","qty":1}' 
  https://shop.example.com/api/cart

Im zweiten wird währenddessen ein Rollout ausgelöst:

kubectl -n shop-prod rollout restart deploy/checkout
kubectl -n shop-prod rollout status deploy/checkout

Danach zählt nur die Statusverteilung in der Ausgabe von hey. Ohne preStop-Hook stehen dort 502er, ihre Zahl skaliert mit der Anzahl der Replikate und der Last. Mit korrekt gesetztem Hook und Graceful Shutdown steht dort ausschließlich 200.

Gegenprobe aus dem Ingress-Log, falls die Zahlen nicht eindeutig sind:

kubectl -n ingress-nginx logs deploy/ingress-nginx-controller --since=5m 
  | grep ' 502 ' | wc -l

Der Wert vor und nach der Änderung ist das eigentliche Ergebnis dieses Artikels. Alles davor ist Theorie.

Ein Hinweis zum Ablauf: Der Test gehört in die Staging-Umgebung mit derselben Replikatzahl und derselben Ingress-Konfiguration wie Produktion. Ein Cluster mit einem einzigen Replikat zeigt ein anderes Bild, weil dort während des Rollouts ohnehin kurz gar kein Ziel bereitsteht.

Die Muster auf einen Blick

Symptom Ursache Maßnahme
502 beim Rollout, Anwendungs-Log leer Abmeldung läuft parallel zum Herunterfahren preStop mit sleep, 5 bis 30 Sekunden
Nur POST betroffen, GET unauffällig NGINX wiederholt keine nicht-idempotenten Anfragen am Timing arbeiten, nicht non_idempotent setzen
Antworten brechen mitten drin ab SIGKILL nach abgelaufener Gnadenfrist terminationGracePeriodSeconds über preStop plus Austrudeln setzen
SIGTERM wirkt nicht PID 1 ist eine Shell Exec-Form im ENTRYPOINT oder exec im Startskript
Laufende Anfragen brechen ab kein Graceful Shutdown server.shutdown=graceful
Neustart-Sturm bei DB-Ausfall Liveness prüft Abhängigkeiten getrennte Actuator-Endpunkte für Liveness und Readiness
Kapazitätsloch während des Rollouts Standard-Strategie maxUnavailable: 0, maxSurge: 1
Hook läuft nicht, keine Fehlermeldung sleep-Binary fehlt im Distroless-Image native sleep-Aktion statt exec
Externer LB schickt weiter eigener Health-Check-Takt preStop-Wartezeit über das Prüfintervall legen

Wann du keinen preStop-Hook brauchst

Nicht jede Arbeitslast braucht das, und ein Hook, der überall pauschal drinsteht, verlängert jedes Herunterfahren um seine Wartezeit.

Bei einem Job oder CronJob gibt es keinen Service und keine Upstream-Liste. Niemand schickt Verkehr, es gibt nichts abzumelden.

Bei einem Verbraucher aus einer Queue, etwa einem Kafka-Consumer, geht der Verkehr nicht über einen Service. Der Pod holt sich seine Arbeit selbst. Was er braucht, ist ein sauberes Verlassen der Consumer-Gruppe beim SIGTERM, also Graceful Shutdown, aber keine Wartezeit davor.

Bei einer Anwendung, die pro Knoten nur ein Replikat hat und über hostNetwork erreicht wird, greift der Mechanismus ebenfalls nicht.

Prüf einfach, ob die Pod-IP in einer Liste steht, die jemand anders pflegt. Wenn ja, braucht das Aktualisieren dieser Liste Zeit, und diese Zeit muss der Pod abwarten. Wenn nein, reicht Graceful Shutdown allein.

FAQ

Warum meldet Kubernetes den Pod nicht einfach zuerst ab und beendet ihn danach?
Weil beide Vorgänge von verschiedenen Komponenten ausgeführt werden, die sich nicht miteinander abstimmen. Der kubelet auf dem Knoten beendet den Pod, der EndpointSlice-Controller pflegt die Endpunkte. Eine Synchronisierung müsste vom kubelet aus abwarten, bis jeder kube-proxy und jeder Ingress-Controller im Cluster bestätigt hat. Diesen Rückkanal gibt es nicht, und er wäre bei mehreren tausend Knoten auch nicht praktikabel.

Wie lange muss der preStop-Hook warten?
So lange, wie eine Endpoint-Änderung in deinem Cluster bis zum letzten Konsumenten braucht. Fünf Sekunden reichen bei kleinen Clustern, zehn sind ein guter Ausgangswert, bei einem externen Load Balancer mit eigenem Prüfintervall müssen es mehr sein als dieses Intervall multipliziert mit der Anzahl nötiger Fehlschläge. Messen statt raten: Rollout unter Last, 502er zählen, Wert anpassen.

Hilft es, terminationGracePeriodSeconds einfach hochzusetzen?
Nein, allein nicht. Die Gnadenfrist ist eine Obergrenze, kein Wartemechanismus. Ohne preStop-Hook geht das SIGTERM sofort raus, die Anwendung fährt sofort herunter, und die Gnadenfrist läuft ins Leere. Sie muss trotzdem hoch genug sein, sonst schneidet das SIGKILL das Austrudeln ab.

Warum trifft es nur POST-Anfragen?
Weil NGINX bei einem Verbindungsfehler den nächsten Upstream probiert, das aber aus Sicherheitsgründen nicht bei nicht-idempotenten Methoden tut. Ein wiederholter POST könnte eine Bestellung doppelt auslösen. GET-Anfragen werden still wiederholt und fallen deshalb nicht auf.

Kann ich stattdessen non_idempotent einschalten?
Technisch ja, fachlich selten. Damit erlaubst du NGINX, auch POST-Anfragen zu wiederholen, und riskierst doppelte Verarbeitung, wenn der Server die erste Anfrage bereits angenommen hatte. Der preStop-Hook behebt die Ursache, non_idempotent verdeckt sie und schafft ein neues Risiko.

Gilt das auch für Traefik, HAProxy oder Envoy?
Ja, der Mechanismus ist derselbe, weil er in Kubernetes liegt und nicht im Proxy. Unterschiedlich sind nur die Reaktionszeit und das Verhalten bei Wiederholungen. Envoy im Rahmen eines Service Mesh reagiert meist schneller, weil er Endpunkte über eine eigene Steuerungsebene bezieht, aber auch dort ist die Verteilung nicht augenblicklich.

Brauche ich das auch bei Aufrufen zwischen zwei Diensten im Cluster?
Ja. Dort verläuft der Weg über kube-proxy statt über den Ingress-Controller, das Rennen ist dasselbe. Ein aufrufender Dienst bekommt dann statt eines 502 eine abgelehnte Verbindung direkt in seinen HTTP-Client, was je nach Client-Konfiguration als Exception oder als Wiederholung endet.

Reicht es, den preStop-Hook nur beim Produktionsdienst zu setzen?
Wenn es dort um Verkehr geht, ja. Sinnvoller ist es allerdings, ihn in die gemeinsame Vorlage zu legen, aus der die Deployments entstehen, etwa in das Basis-Chart. Sonst hat ihn der Dienst, an den jemand gedacht hat, und der Rest nicht.

Fazit

Die Ursache ist eine Reihenfolge, die keine ist: Abmelden und Herunterfahren laufen nebeneinander, und die Anwendung ist schneller fertig als der Cluster mit dem Weiterreichen der Information.

Drei Einstellungen beheben das, und keine davon ist aufwendig: ein preStop-Hook, der wartet, eine Gnadenfrist, die über Wartezeit plus Austrudeln liegt, und eine Anwendung, die auf SIGTERM sauber austrudelt statt sofort aufzuhören. Dazu kommt die Prüfung, dass das SIGTERM überhaupt bei PID 1 ankommt.

Ohne die vierte fällt der Rest allerdings in sich zusammen: Rollout unter Last auslösen und die Statuscodes zählen. Ohne diesen Schritt weiß man nur, dass die neuen Pods hochkommen, und das war noch nie die Frage.

Fang bei dem Dienst an, bei dem eine verlorene Anfrage am meisten weh tut. POST-Last dagegen laufen lassen, Rollout starten, 502er zählen. Diese Zahl ist deine Ausgangsmessung.

Quellen

Alle Manifeste, Konfigurationen und Code-Ausschnitte in diesem Artikel sind eigene.

$ lang DE EN ES