Späť na blog

Kubernetes graceful shutdown ako kontrakt: nula 502 počas rolloutov (HTTP + gRPC)

|
| kubernetes, reliability, sre, grpc, http, deployments

Ak ste niekedy spravili rollout Deploymentu a videli:

  • burst 502/504 z ingressu,
  • ECONNRESET / “connection reset by peer” v klientoch,
  • gRPC UNAVAILABLE špičky,
  • a potom sa to “nejako” ustálilo…

…tak poznáte nepríjemnú pravdu: “graceful shutdown” nie je boolean. Je to kontrakt medzi:

  • klientom (keepalive, retry, reuse spojení),
  • LB/ingress/sidecarom (draining správanie),
  • Kubernetes propagáciou endpointov (EndpointSlice → kube-proxy),
  • a aplikáciou (SIGTERM handling, odmietnutie novej práce, dobehnutie in-flight práce).

Tento článok je praktický, reprodukovateľný postup ako spraviť rollouty nudné (v dobrom).

Testované na: Kubernetes 1.27–1.30, NGINX Ingress a Envoy-based proxy, Go HTTP servery a gRPC služby.

Čo musí “graceful shutdown” garantovať

Definujte si Drain Contract s jasnými invariantmi:

  1. Najprv zastav routing novej prevádzky (z pohľadu Kubernetes)
  2. Prestaň prijímať novú prácu (v procese)
  3. DobeHni alebo ukonči in-flight prácu v ohraničenom čase
  4. Až potom skonči proces, skôr než príde SIGKILL

Ak chýba hociktorý bod, rollouty budú produkovať chyby aj keď “správne” chytáte SIGTERM.

Ako v skutočnosti prebieha terminácia Podu

Keď sa Pod terminujete, Kubernetes (zjednodušene) spraví:

  1. Označí Pod deletion timestampom.
  2. Spustí preStop hook (ak existuje).
  3. Pošle kontajnerom SIGTERM.
  4. Čaká do terminationGracePeriodSeconds.
  5. Ak proces stále žije, pošle SIGKILL.

Routing sa však vypína oddelene – závisí od:

  • readiness stavu a aktualizácie EndpointSlices,
  • propagácie cez dataplane (kube-proxy/IPVS/eBPF),
  • a od reuse spojení v klientoch (keepalive).

To znamená: počas terminácie môžete stále dostávať requesty, ak nerobíte aktívny drain.

Kľúčová myšlienka: readiness-driven draining

Najspoľahlivejší pattern:

  • Readiness endpoint začne vracať not ready hneď ako začne draining.
  • Pri terminácii prepnete aplikáciu do “draining módu” skôr než začnete vypínať server.

Draining môžete spustiť:

  • cez preStop (lokálne HTTP/exec volanie – konzistentné a testovateľné),
  • alebo cez SIGTERM handler (tiež OK, len si odmerajte timing).

Drain budget matematika (nehádajte)

Grace period musí pokryť:

grace >= endpoint_propagation + drain_delay + worst_case_request_time + safety_margin

Kde:

  • endpoint_propagation: čas na update EndpointSlice + stop routing v dataplane
  • drain_delay: krátke čakanie po prepnutí na NotReady (nech sa routing “rozleje”)
  • worst_case_request_time: reálny upper bound requestu (alebo enforce deadline)
  • safety_margin: buffer na jitter

Nemusíte trafiť presné čísla. Potrebujete odmerané čísla.

Referenčná implementácia: Kubernetes YAML

1) Readiness probe (musí reflektovať draining)

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  periodSeconds: 2
  timeoutSeconds: 1
  failureThreshold: 1

Poznámky:

  • Probe má byť rýchly.
  • failureThreshold: 1 spraví readiness veľmi citlivý (na liveness to často nechcete).

2) preStop hook: spusti draining

lifecycle:
  preStop:
    httpGet:
      path: /admin/drain
      port: 8080

A terminationGracePeriodSeconds nastavte podľa budgetu:

terminationGracePeriodSeconds: 60

Prečo nie preStop: sleep 10?

  • Sleep mimo aplikácie nezabráni prijímaniu nových requestov.
  • Stratíte kontrolu aj observability.

Referenčná implementácia: draining v aplikácii (Go príklady)

Implementovať sa to dá v každom jazyku. Toto je logika.

HTTP server: odmietni nové requesty, dobehni in-flight

// Ilustračný Go-like pseudokód.
// Readiness závisí od drain flagu a terminácia má hard deadline.

var draining atomic.Bool

func readyHandler(w http.ResponseWriter, r *http.Request) {
  if draining.Load() {
    w.WriteHeader(http.StatusServiceUnavailable)
    return
  }
  w.WriteHeader(http.StatusOK)
}

func drainHandler(w http.ResponseWriter, r *http.Request) {
  draining.Store(true)
  w.WriteHeader(http.StatusOK)
}

func main() {
  srv := &http.Server{Addr: \":8080\", Handler: mux()}

  go func() {
    <-sigterm
    draining.Store(true)

    // Daj čas na konvergenciu routingu.
    time.Sleep(5 * time.Second)

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx)
  }()

  _ = srv.ListenAndServe()
}

gRPC server: GracefulStop s hard capom

gRPC (hlavne streaming) vie blokovať dlho. Chcete:

  • skúsiť graceful stop,
  • ale s maximálnym drain časom (potom hard stop).
go func() {
  <-sigterm
  draining.Store(true)
  time.Sleep(5 * time.Second)

  done := make(chan struct{})
  go func() {
    grpcServer.GracefulStop()
    close(done)
  }()

  select {
  case <-done:
  case <-time.After(45 * time.Second):
    grpcServer.Stop()
  }
}()

Repro lab: dokážte to číslami (pred/po)

Krok 1: generujte load

hey -z 2m -c 50 http://my-service.default.svc.cluster.local/

Na gRPC sa často používa ghz.

Krok 2: opakujte rollout

kubectl rollout restart deploy/my-service
kubectl rollout status deploy/my-service

Krok 3: sledujte propagáciu endpointov

kubectl get endpointslices -l kubernetes.io/service-name=my-service -w

Hľadajte:

  • či endpointy miznú rýchlo po draining začiatku,
  • či load stále naráža na chyby počas okna.

Krok 4: porovnajte error rate

Sledujte:

  • HTTP 5xx / reset
  • gRPC UNAVAILABLE / CANCELLED
  • p95/p99 latenciu počas rollout okna

Najčastejšie failure módy

“Máme preStop sleep” a stále dropujeme requesty

Symptóm:

  • chyby pretrvávajú
  • readiness je stále OK počas sleep

Príčina:

  • app stále prijíma requesty

Fix:

  • readiness-driven drain, nie externý sleep

Krátky grace period → SIGKILL → rozbité in-flight

Fix:

  • upravte terminationGracePeriodSeconds podľa budgetu
  • enforce request deadline (aby ste mali reálny upper bound)

Long-lived streams (gRPC streaming, WebSockets)

Fix:

  • max drain window
  • stream limity / keepalive policy
  • klientská reconnect stratégia

Retry amplifikuje rollout

Fix:

  • retry budget a deadline alignment
  • bounded retries + backoff

Čo by som spravil v produkcii

  1. Readiness reflektuje draining (nikdy “Ready” pri shutdown)
  2. preStop spúšťa draining (nie sleep)
  3. Krátky, odmeraný routing convergence delay
  4. Bounded timeouts na shutdown
  5. terminationGracePeriodSeconds z drain budgetu
  6. Rollout SLO: “error rate počas deploy okna”
  7. Rehearsal: rollout lab aspoň na jednej kritickej službe

FAQ

Prečo vidím chyby aj keď chytám SIGTERM?

Lebo stop-routing nie je okamžitý: EndpointSlice + dataplane propagácia + reuse spojení znamenajú krátke “doznievanie”.

Mám hneď failnúť readiness pri SIGTERM?

Zvyčajne áno – ak readiness znamená “môžem bezpečne prijímať nové requesty”.

Kedy je preStop: sleep OK?

Len ako núdzové riešenie a len ak app okamžite odmieta novú prácu. Inak je to “čakaj, ale stále prijímaj traffic”.

Súvisiace články

  • /sk/blog/conntrack-stale-nat-mapovanie/ (deploy 503, ktoré nie sú graceful shutdown)
  • /sk/blog/kubernetes-ghost-pod-conntrack/ (prečo traffic vie ísť aj na “mŕtvy” Pod)
  • /sk/blog/k8s-postgresql-connection-storm/ (rollout ako systémový event)

Further reading

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Kubernetes graceful shutdown ako kontrakt: nula 502 počas rolloutov (HTTP + gRPC)". https://www.michal-drozd.com/sk/blog/kubernetes-graceful-shutdown-rollouty/ (Publikované 22. novembra 2025).