Опирается на правила: R-SHUT-K8S-1R-SHUT-K8S-3 и R-SHUT-K8S-X1R-SHUT-K8S-X2 из Graceful Shutdown → раздел 6. Kubernetes.

Важно знать

  • terminationGracePeriodSeconds: 60 — явно в Deployment, не k8s default 30.
  • preStop sleep 10 — отдельный бюджет; SIGTERM приходит после preStop, не вместе с ним.
  • atomic.Bool в health.State — единственный источник readiness; SetNotReady() вызывается до srv.Shutdown.
  • /health/ready отвечает 503 при !appState.IsReady() — k8s убирает pod из endpoints; /health/live всегда 200 на shutdown.
  • srv.Shutdown(ctx), не srv.Close() — Shutdown дожидается in-flight запросов, Close рвёт соединения немедленно.
  • maxSurge: 1, maxUnavailable: 0 — новый pod принимает трафик до того как старый начал shutdown.
  • Без preStop — гарантированные 502 в окне 5-15 секунд после SIGTERM.

K8s — контекст, в котором живёт graceful shutdown. Go-сервис на net/http+chi может корректно завершать обработчики, но без правильной k8s-конфигурации клиенты увидят 502: kube-proxy не успевает убрать pod из endpoints до того, как SIGTERM добирается до процесса. Три k8s-параметра замыкают цепочку.

terminationGracePeriodSeconds: 60

R-SHUT-K8S-1: total budget для всей shutdown-последовательности.

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: order-service
          image: order-service:2.1.0
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]

Sequence завершения pod:

T=0s    kubelet начинает shutdown:
          - запускает preStop hook параллельно с убиранием pod из Service endpoints
T=10s   preStop sleep завершён → kubelet отправляет SIGTERM
T=10s   os.Signal-канал в Run() получает SIGTERM:
          - appState.SetNotReady()     // /health/ready → 503
          - cancelConsumerCtx()        // kafka consumer завершает batch
          - consumerWg.Wait()
          - schedulerWg.Wait()         // outbox relay дожимает итерацию
          - srv.Shutdown(shutCtx)      // дожидается in-flight HTTP
          - pool.Close()               // pgxpool последним
T=35s   процесс завершается (25s на shutdown после preStop)
T=70s   если процесс жив → SIGKILL

terminationGracePeriodSeconds: 30 (k8s default) + preStop 10s → Go-процессу остаётся 20s, но ShutdownTimeout: 25s уже не помещается. Pod уходит в SIGKILL посередине дрейна HTTP.

Явная запись 60 обязательна — k8s не читает значение из кода приложения.

Readiness-флаг и health endpoints

R-SHUT-CFG-3, R-SHUT-CFG-4, R-SHUT-K8S-2: на shutdown нужен readiness=503, liveness должна оставаться 200.

// internal/health/state.go
package health

import "sync/atomic"

type State struct{ ready atomic.Bool }

func NewState() *State {
    s := &State{}
    s.ready.Store(true)
    return s
}

func (s *State) SetNotReady() { s.ready.Store(false) }
func (s *State) IsReady() bool { return s.ready.Load() }
// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, state *health.State) {
    r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
        if !state.IsReady() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })
}

На SIGTERM SetNotReady() вызывается первым — ещё до srv.Shutdown. k8s через periodSeconds: 5 × failureThreshold: 2 = 10s видит 503 и убирает pod из endpoints. Новый трафик не поступает; in-flight запросы, принятые до переключения, дожимаются через srv.Shutdown.

Liveness возвращает 200 всегда — падение liveness вызывает restart pod, что разрушает graceful.

Probes в Deployment

R-SHUT-K8S-2:

containers:
  - name: order-service
    readinessProbe:
      httpGet:
        path: /health/ready
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 2
    livenessProbe:
      httpGet:
        path: /health/live
        port: 8080
      initialDelaySeconds: 15
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3
ProbeEndpointДействие k8s при fail
readinessProbe/health/readyУбрать из endpoints (без restart)
livenessProbe/health/liveПерезапустить pod

Go-сервисы стартуют быстро (< 5s в среднем), поэтому initialDelaySeconds: 15 на liveness — достаточно. В отличие от JVM-сервисов, где прогрев JIT занимает 30-60s, Go-бинарь готов почти сразу после запуска.

readinessProbe начинает работать через 5s — если сервис поднялся, kube-proxy сразу добавит pod в endpoints.

SIGTERM-handler и порядок shutdown

R-SHUT-CFG-2, R-SHUT-CFG-3:

// internal/server/server.go
package server

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/example/order-service/internal/health"
)

type Config struct {
    ShutdownTimeout time.Duration // 25s
}

func Run(
    ctx context.Context,
    srv *http.Server,
    cfg Config,
    appState *health.State,
    cancelConsumerCtx context.CancelFunc,
    consumerDone <-chan struct{},
    schedulerDone <-chan struct{},
    closePool func(),
) error {
    sigC := make(chan os.Signal, 1)
    signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
    defer signal.Stop(sigC)

    errC := make(chan error, 1)
    go func() { errC <- srv.ListenAndServe() }()

    select {
    case sig := <-sigC:
        slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown", "signal", sig.String())
    case err := <-errC:
        return err
    }

    appState.SetNotReady() // R-SHUT-CFG-3: readiness=503 первым

    cancelConsumerCtx()      // kafka consumer: не начинать новый batch
    <-consumerDone           // дождаться завершения текущего batch
    <-schedulerDone          // outbox relay: текущая итерация

    shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer cancel()
    if err := srv.Shutdown(shutCtx); err != nil { // R-SHUT-CFG-1: дожать HTTP
        slog.ErrorContext(ctx, "http shutdown error", "error", err)
    }

    closePool() // R-SHUT-DB-1: pgxpool последним
    return nil
}

context.WithTimeout(context.Background(), ...) — не родительский ctx: на момент shutdown родительский контекст уже может быть отменён, а нам нужно завершить in-flight запросы корректно.

maxSurge / maxUnavailable

R-SHUT-K8S-3: zero-downtime rolling deploy.

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

Sequence deploy order-service v2.1.0 → v2.2.0:

Начало: 3 пода v2.1.0
T=0   kubelet создаёт pod v2.2.0 (итого 4 пода)
T=Ns  v2.2.0 проходит readinessProbe → добавлен в endpoints
T=Ns  pod v2.1.0 #1: preStop sleep 10 → SIGTERM → SetNotReady() → Shutdown → exit
      (3 активных пода: 2×v2.1.0 + 1×v2.2.0)
T=Ns  kubelet создаёт pod v2.2.0 #2 (итого 4)
...

Без maxUnavailable: 0 k8s мог бы выключить старый pod до запуска нового — capacity падает, SLO нарушается.

maxSurge: 1 — минимум для небольших сервисов (3-10 replicas). Для ProductService с 50+ replicas — maxSurge: 25%.

Cumulative budget

R-SHUT-OBS-1: все фазы должны уложиться в 60s.

preStop sleep      10s  (k8s, до SIGTERM)
──────────────────────────────────────────  ← SIGTERM
SetNotReady         ~0s
consumerWg.Wait   ~15s  (kafka: текущий batch)
schedulerWg.Wait  ~10s  (outbox relay: итерация)
srv.Shutdown      ~25s  (HTTP: in-flight)
pool.Close          ~1s
──────────────────────────────────────────
Итого              61s  > 60s → нужно сократить

Если budget не помещается — сократить batch (100→20 событий в outbox), не увеличивать terminationGracePeriodSeconds. Большой budget скрывает медленные операции; правильное решение — ускорить их.

Структурный лог и метрика (R-SHUT-OBS-2, R-SHUT-OBS-3):

// обёртка в Run(), до shutdown-последовательности:
start := time.Now()
slog.InfoContext(ctx, "graceful shutdown начат")
defer func() {
    slog.InfoContext(ctx, "graceful shutdown завершён",
        "duration_s", time.Since(start).Seconds(),
    )
    shutdownDuration.Set(time.Since(start).Seconds()) // promauto Gauge
}()

Что запрещено

АнтипаттернПравилоЧто взамен
Нет preStop в lifecycleR-SHUT-K8S-X1exec: ["/bin/sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 30 (default)R-SHUT-K8S-X260 явно
Один /health для обеих probesR-SHUT-K8S-2отдельные /health/live и /health/ready
srv.Close() вместо srv.Shutdown(ctx)R-SHUT-CFG-1http.Server.Shutdown с явным таймаутом
var shuttingDown bool без atomic.BoolR-SHUT-CFG-X1health.State с atomic.Bool
pool.Close() до consumerWg.Wait()R-SHUT-DB-X1pgxpool — последним в shutdown-последовательности
Liveness зависит от состояния БДR-SHUT-K8S-2liveness всегда 200; БД-зависимость — в readiness
maxUnavailable: 1 на productionR-SHUT-K8S-3maxUnavailable: 0

Куда дальше

  • Бюджеты и observability — cumulative budget, app_shutdown_duration_seconds, структурный лог.
  • HTTP drain — srv.Shutdown vs srv.Close, preStop детали, долгие эндпоинты.
  • БД и persistence — порядок закрытия pgxpool, транзакции на SIGTERM.
  • Kafka shutdown — kafka-go consumer через context.Context, CommitMessages, writer.Close.
  • Фоновые задачи и outbox — sync.WaitGroup, outbox relay, критичная секция с context.Background().
  • Идемпотентность in-flight операций — Idempotency-Key, ON CONFLICT DO NOTHING по event_id.