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

Когда Kubernetes решает убить pod — он отправляет процессу сигнал SIGTERM. Если сервер просто закрывается в этот момент, клиенты, которые уже отправили запрос, получают разрыв соединения. Браузер покажет ошибку, мобильное приложение упадёт с 502 Bad Gateway.

Graceful shutdown — это упорядоченное завершение: сначала перестать принимать новые запросы, дождаться завершения текущих, потом закрыться. В Go это не одна функция из стандартной библиотеки, а явная последовательность шагов, которую разработчик собирает сам в main.

Как правильно остановить HTTP-сервер

В Go у http.Server есть два метода завершения, и они ведут себя принципиально по-разному.

srv.Close() немедленно закрывает все соединения — включая те, где запрос ещё обрабатывается. Клиент получит разрыв, сервер вернёт 502. Это неправильный выбор для штатного завершения.

srv.Shutdown(ctx) работает иначе: перестаёт принимать новые соединения, но ждёт, пока все текущие запросы дойдут до ответа. Возвращает nil, когда все завершились, или ошибку — если истёк переданный контекст.

// Правильный shutdown-путь
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
    slog.ErrorContext(ctx, "server shutdown", "error", err)
}

Контекст с таймаутом здесь обязателен. Без него Shutdown будет ждать бесконечно — а Kubernetes через 30 секунд пошлёт SIGKILL и убьёт процесс принудительно. Хорошее значение таймаута — 20–25 секунд: достаточно для большинства запросов, но не выходит за рамки terminationGracePeriodSeconds.

Как поймать SIGTERM

Чтобы реагировать на сигнал операционной системы, нужно создать канал и подписаться через signal.Notify:

sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigC)

Буфер 1 у канала важен: если горутина не успела прочитать сигнал мгновенно, он не потеряется. Без буфера возможна ситуация, когда сигнал пришёл, но никто не читал канал — и он просто проигнорирован.

Дальше сервер запускается в горутине, а main ждёт либо сигнала, либо ошибки запуска:

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
}

Readiness — чтобы Kubernetes не слал трафик умирающему поду

Когда Kubernetes получает SIGTERM, он одновременно начинает убирать pod из списка endpoints. Но это не мгновенно: DNS-кэш, балансировщик — всё это занимает несколько секунд. Новые запросы могут продолжать приходить ещё 5–10 секунд после того, как процесс уже начал завершаться.

Решение — переключить readiness-состояние сразу при получении сигнала, до вызова Shutdown. Тогда /health/ready начнёт возвращать 503, Kubernetes убёрет pod из балансировки, и новые запросы перестанут приходить.

// 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() }

atomic.Bool — потокобезопасный примитив без мьютекса. Поскольку readiness читается на каждый HTTP-запрос, блокировка здесь была бы лишней.

Порядок в shutdown-пути:

appState.SetNotReady() // сначала — чтобы k8s убрал pod до начала дрейна
// ... далее shutdown сервера

Два health-эндпоинта вместо одного

Распространённая ошибка — объединить liveness и readiness в один /health эндпоинт. На первый взгляд удобно, но при shutdown это приводит к проблеме.

Kubernetes различает два типа проверок:

  • Readiness probe — «готов ли pod принимать трафик?» При 503 pod убирается из балансировки.
  • Liveness probe — «жив ли под?» При 503 Kubernetes перезапускает pod.

Если они объединены и вы переключаете их оба на 503 при shutdown — Kubernetes увидит сломанный liveness и запустит новый pod. Вместо корректного завершения получите перезапуск.

Правильно: при shutdown readiness возвращает 503, liveness остаётся 200 до конца.

// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, appState *health.State) {
    r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK) // всегда 200, пока процесс жив
    })

    r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
        if !appState.IsReady() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })
}

Полная последовательность в main

Вот как все элементы собираются вместе. Порядок имеет значение:

func main() {
    ctx := context.Background()

    pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
    if err != nil {
        slog.ErrorContext(ctx, "pgxpool init", "error", err)
        os.Exit(1)
    }

    appState := health.NewState()

    // Запуск фоновых задач
    consumerCtx, cancelConsumer := context.WithCancel(ctx)
    var consumerWg sync.WaitGroup
    consumerWg.Add(1)
    go func() {
        defer consumerWg.Done()
        productConsumer.Run(consumerCtx)
    }()

    schedulerCtx, cancelScheduler := context.WithCancel(ctx)
    var schedulerWg sync.WaitGroup
    schedulerWg.Add(1)
    go outboxRelay.Run(schedulerCtx, &schedulerWg)

    // HTTP-сервер
    r := chi.NewRouter()
    health.RegisterHealthRoutes(r, appState)
    srv := &http.Server{Addr: cfg.Addr, Handler: r}

    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:
        slog.ErrorContext(ctx, "server error", "error", err)
        os.Exit(1)
    }

    start := time.Now()

    appState.SetNotReady() // 1. убираем из балансировки
    cancelConsumer()       // 2. останавливаем Kafka-потребитель
    consumerWg.Wait()
    cancelScheduler()      // 3. останавливаем фоновые задачи
    schedulerWg.Wait()

    shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutCtx); err != nil { // 4. дрейним HTTP
        slog.ErrorContext(ctx, "server shutdown", "error", err)
    }

    pool.Close() // 5. БД-пул — последним

    slog.InfoContext(ctx, "graceful shutdown завершён",
        "duration_s", time.Since(start).Seconds())
}

Пул базы данных закрывается последним — к этому моменту все задачи и HTTP-запросы уже завершены и не обращаются к БД.

Коротко

  • srv.Shutdown(ctx) ждёт завершения текущих запросов; srv.Close() рвёт их немедленно — используй только Shutdown.
  • context.WithTimeout на 20–25 секунд — не бесконечный контекст: иначе Shutdown будет ждать вечно, а Kubernetes убьёт процесс принудительно.
  • atomic.Bool в health.State — единственный источник readiness-состояния; потокобезопасен без мьютекса.
  • SetNotReady() вызывается до srv.Shutdown — даёт Kubernetes время убрать pod из балансировки до начала дрейна.
  • /health/live и /health/ready — разные эндпоинты: liveness при 503 перезапускает pod, readiness — только убирает из трафика.
  • os.Signal-канал с буфером 1 — сигнал не потеряется, даже если горутина читает не мгновенно.
  • Порядок завершения: readiness off → потребители → фоновые задачи → HTTP drain → БД-пул.

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

  • HTTP drain и долгие запросы — preStop sleep, 202 Accepted для медленных эндпоинтов.
  • БД и persistence — порядок закрытия pgxpool и транзакции при завершении.
  • Kafka shutdown — остановка потребителя через context и commit последних сообщений.
  • Kubernetes-конфигурация — terminationGracePeriodSeconds, настройка probes, maxUnavailable: 0.