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

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 и убирает под из endpoints T=10s preStop кончился — процессу приходит SIGTERM T=10s readiness отвечает 503: новых запросов сюда уже не шлют дальше фоновые горутины остановлены, текущие запросы дослужены, пул БД закрыт T=35s процесс вышел сам — 25 секунд после preStop хватило T=60s не успел бы — прилетел бы SIGKILL

Бюджет terminationGracePeriodSeconds считается с самого начала, и preStop съедает его первые 10 секунд: процессу остаётся не 60, а 50. Зато за эти 10 секунд под успевает пропасть из endpoints, и клиенты не ловят 502.

Важно понимать: 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 (чтобы балансировщики, которые опрашивают ручку сами, перестали слать трафик), liveness должна остаться 200 (чтобы kubelet не перезапустил контейнер, который сам сливает трафик и ничем не болен).

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

// 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. Из EndpointSlice удаляемый под Kubernetes убирает сам, в момент удаления и без всяких проб: к приходу SIGTERM новый трафик через Service туда уже не идёт, а запросы, уже принятые сервером, дожимаются через srv.Shutdown. Пробу с периодом 5 секунд и failureThreshold: 2 kubelet заметил бы только через 10 секунд, и на удаление это не влияет. Флаг готовности работает на других: на ingress-контроллер и внешний балансировщик, которые опрашивают /health/ready сами, и на запуск вне кластера, где SIGTERM приходит, а из списка адресов сервис никто не убирает.

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 принимают трафик шаг 1 поднимается под v2.2.0 — временно их четыре шаг 2 v2.2.0 прошёл readinessProbe и попал в endpoints шаг 3 первый v2.1.0 гасится: preStop, SIGTERM, завершение шаг 4 активны 2×v2.1.0 и 1×v2.2.0 — цикл повторяется

Сначала поднять новый под, потом гасить старый — так число готовых подов на всём выкате не падает ниже заявленных трёх. Платим временем выката и одним лишним подом, который на пике занимает память и соединения к базе.

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

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

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

Один /health для обеих probes — любое 503 бьёт сразу по обеим. Под, который Kubernetes уже удаляет, liveness не проверяют, но стоит сервису самому уйти в режим слива по команде дежурного или проверке базы внутри /health не пройти — kubelet перезапустит контейнер, который ничем не болен.

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

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

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

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

Дополнительно: при первом чтении можно пропустить

Глубже: округление стратегии, хук не по бюджету и откуда 502расширенное

Округление в стратегии по умолчанию. Без явных maxSurge и maxUnavailable Kubernetes берёт 25 % и 25 %: лишние поды округляет вверх, недоступные — вниз. Для трёх реплик это уже maxSurge: 1, maxUnavailable: 0, а начиная с четырёх недоступным становится один под, и выкат идёт с просадкой. Явные значения в манифесте нужны, чтобы поведение не менялось от числа реплик.

Хук, который не уложился. Если preStop сам не завершился за бюджет, kubelet обрывает хук, шлёт SIGTERM и оставляет процессу минимальные две секунды до SIGKILL — на остановку с дрейном и пачками этого не хватит. Поэтому паузу в хуке держат заведомо короче бюджета и не делают в нём ничего, что может зависнуть.

Откуда 502 без preStop. Под выпадает из EndpointSlice в момент удаления, но kube-proxy на каждой из сотен нод перестраивает правила сам и с задержкой, а ingress-контроллеры и внешние балансировщики обновляют список адресов ещё позже. Пауза в 10 секунд закрывает эту рассинхронизацию, и никакая проба её не заменит.

Коротко

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

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