← назад к разделу

Go-сервис может аккуратно завершать обработку запросов при остановке — но этого недостаточно. Без правильной настройки Kubernetes клиенты всё равно получат ошибки 502: kube-proxy не успевает убрать под из списка активных до того, как процесс перестаёт отвечать. Три параметра в манифесте Deployment закрывают этот пробел.

Почему 502 при перезапуске — это проблема конфигурации

Когда Kubernetes останавливает под, он делает два действия одновременно: убирает под из Service endpoints (чтобы новый трафик не шёл туда) и отправляет процессу сигнал SIGTERM. Проблема в том, что обновление таблиц маршрутизации через kube-proxy занимает несколько секунд. В этом окне балансировщик ещё направляет запросы на под, который уже начал завершаться.

Решение — дать kube-proxy время синхронизироваться до того, как SIGTERM дойдёт до процесса. Для этого используется preStop hook.

terminationGracePeriodSeconds и preStop

terminationGracePeriodSeconds — общий бюджет времени, который Kubernetes отводит на всю последовательность завершения пода. Если процесс не завершился за это время, Kubernetes отправляет SIGKILL.

Значение по умолчанию — 30 секунд. Этого часто недостаточно: preStop занимает 10 секунд, а Go-процессу остаётся только 20 секунд на завершение всех горутин, HTTP-соединений и закрытие пула БД.

Правильная конфигурация:

# 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"]

Как работает последовательность завершения:

T=0s    Kubernetes начинает останавливать под:
          - запускает preStop hook
          - убирает под из Service endpoints
T=10s   preStop завершён → Kubernetes отправляет SIGTERM
T=10s   Go-процесс получает SIGTERM:
          - помечает себя как не готовый (readiness → 503)
          - завершает фоновые горутины
          - дожидается in-flight HTTP-запросов
          - закрывает пул соединений с БД
T=35s   процесс завершился (25 секунд на shutdown после preStop)
T=60s   если процесс всё ещё жив → SIGKILL

Важно понимать: terminationGracePeriodSeconds отсчитывается с T=0, то есть с самого начала. preStop sleep 10 секунд — это часть этого бюджета, а не отдельное время сверх него.

Без preStop: Kubernetes отправит SIGTERM немедленно, kube-proxy ещё направляет трафик на под, процесс уже не отвечает — клиенты получают 502 в течение 5-15 секунд.

Два отдельных health-эндпоинта

Типичная ошибка — один эндпоинт /health для обеих проверок. Kubernetes использует два разных типа проверок с разным поведением при сбое:

  • readinessProbe: если под не готов, Kubernetes убирает его из endpoints. Pod продолжает работать.
  • livenessProbe: если под не отвечает, Kubernetes его перезапускает.

При graceful shutdown нужно разное поведение: readiness должна вернуть 503 (чтобы Kubernetes перестал слать трафик), liveness должна остаться 200 (чтобы Kubernetes не перезапустил под посередине завершения).

Состояние готовности через атомарный флаг:

// 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. Kubernetes проверяет readiness каждые 5 секунд, и при двух последовательных 503 убирает под из endpoints. Новый трафик перестаёт поступать; запросы, уже принятые сервером, дожимаются через srv.Shutdown.

atomic.Bool здесь важен: флаг читается и пишется из разных горутин (HTTP-обработчики и shutdown-горутина), поэтому нужна атомарная операция — обычный bool без синхронизации даст неопределённое поведение.

Настройка probes в Deployment

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

Go-сервисы стартуют быстро — обычно менее 5 секунд. Поэтому initialDelaySeconds: 15 для liveness достаточно. После старта Kubernetes начнёт проверять readiness через 5 секунд: если сервис поднялся, под сразу добавится в endpoints.

SIGTERM-handler и порядок завершения

// internal/server/server.go
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() // readiness → 503 первым делом

    cancelConsumerCtx()  // сигнал фоновым горутинам остановиться
    <-consumerDone       // ждём завершения текущего batch
    <-schedulerDone      // ждём текущей итерации scheduler

    shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer cancel()
    if err := srv.Shutdown(shutCtx); err != nil {
        slog.ErrorContext(ctx, "http shutdown error", "error", err)
    }

    closePool() // пул соединений — последним
    return nil
}

Несколько важных деталей:

srv.Shutdown(ctx), а не srv.Close()Shutdown ждёт завершения всех активных запросов, Close обрывает соединения немедленно.

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

Пул соединений (pgxpool) закрывается последним: горутины-потребители Kafka и scheduler ещё могут обращаться к БД во время своего завершения.

Rolling update без потери трафика

При деплое новой версии Kubernetes по умолчанию может остановить старый под раньше, чем новый успеет принять трафик. Чтобы этого не происходило:

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

maxUnavailable: 0 — Kubernetes не выключает старые поды, пока не поднял новые. Количество активных подов никогда не падает ниже заявленного.

maxSurge: 1 — разрешает временно иметь один лишний под. Новый под запускается, проходит readinessProbe, добавляется в endpoints — и только после этого начинается остановка одного старого.

Последовательность деплоя с тремя репликами:

Старт: 3 пода v2.1.0
  Kubernetes создаёт pod v2.2.0 (итого 4)
  v2.2.0 прошёл readinessProbe → добавлен в endpoints
  pod v2.1.0 #1: preStop sleep → SIGTERM → завершение
  (активных: 2×v2.1.0 + 1×v2.2.0)
  Kubernetes создаёт pod v2.2.0 #2...

Частые ошибки

Нет preStop — SIGTERM приходит до того, как kube-proxy убрал под из endpoints. Клиенты получают 502 в течение нескольких секунд после начала остановки.

terminationGracePeriodSeconds: 30 (значение по умолчанию) при preStop 10 секунд — Go-процессу остаётся только 20 секунд на завершение. Если shutdown занимает больше, Kubernetes убивает процесс принудительно.

Один /health для обеих probes — при graceful shutdown liveness возвращает 503, Kubernetes решает, что под завис, и перезапускает его посередине завершения.

srv.Close() вместо srv.Shutdown(ctx) — Close обрывает соединения немедленно, клиенты получают ошибки.

Закрыть пул БД до завершения горутин — если pgxpool закрыт, а горутины ещё пытаются выполнять запросы, они получают ошибки вместо нормального завершения.

maxUnavailable: 1 на production — при деплое количество активных подов временно сокращается, нагрузка на оставшиеся возрастает, SLO может нарушиться.

Liveness зависит от доступности БД — если БД недоступна, liveness возвращает 503, Kubernetes перезапускает поды. Но перезапуск не починит БД, и поды начнут рестартовать в цикле. Liveness должна возвращать 200 всегда; проверку доступности БД кладут в readiness.

Коротко

  • preStop: sleep 10 даёт kube-proxy время убрать под из endpoints до того, как SIGTERM дойдёт до процесса. Без этого гарантированы 502 при каждом деплое.
  • terminationGracePeriodSeconds: 60 — явно в Deployment. Значение по умолчанию (30) не оставляет достаточно времени при preStop 10 секунд.
  • /health/live всегда возвращает 200; /health/ready возвращает 503 с первого же момента shutdown — это сигнал Kubernetes убрать под из endpoints.
  • atomic.Bool для флага готовности — флаг читается из HTTP-горутин и пишется из shutdown-горутины одновременно.
  • Порядок завершения: readiness → 503, фоновые горутины, srv.Shutdown, пул БД последним.
  • maxSurge: 1, maxUnavailable: 0 — новый под добавляется в rotation до того, как старый начинает останавливаться.

Что почитать дальше

  • HTTP drain и in-flight запросы — как srv.Shutdown дожидается активных соединений.
  • Фоновые горутины и outbox — context.Context и sync.WaitGroup для фоновых задач.
  • БД и persistence — порядок закрытия pgxpool и транзакции при завершении.