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

KUBERNETES И GRACEFUL SHUTDOWN Конфигурация Go

Обязательно

Go: сервер и readiness при graceful shutdown

Go · net/http · atomic.Bool

SIGTERM меняет readiness, Shutdown ждёт in-flight, а live и ready остаются разными состояниями.

1. SIGTERM запускает последовательность

1 / 5

signal.NotifyContext ловит TERM и запускает остановку. Код прекращает новые запросы, завершает текущие и только затем закрывает ресурсы.

  • TERM - просьба, не мгновенный kill.
  • Порядок явно задан в main.
  • Deadline ограничивает всю остановку.
SIGTERMNot readyShutdownCloseПотом закрытие ресурсов

2. atomic.Bool мгновенно убирает readiness

2 / 5

ready.Store(false) выполняется до Server.Shutdown. Новые endpoints исключаются, а текущие запросы получают время завершиться.

  • Readiness - трафик можно.
  • Атомарное значение видно всем probes.
  • Переключение идёт до ожидания in-flight.
ready.Store(false)Probe: not readyEndpoints removedIn-flight продолжаются, новых нет

3. Server.Shutdown ждёт активные запросы

3 / 5

Shutdown прекращает приём и ждёт завершения in-flight с контекстом. Close разрывает соединения сразу и оставляет операции незавершёнными.

  • Graceful ожидание ограничено контекстом.
  • Долгие операции получают cancel.
  • После wait закрываются остальные ресурсы.
Shutdownfinish in-flightCloseabort connectionsВыбор - завершить, не оборвать

4. Live и ready имеют разный смысл

4 / 5

Live означает, что процесс работает и не требует рестарта. Ready означает, что запросы можно направлять.

  • При shutdown live ещё true.
  • Ready уже false.
  • Pod завершается сам, не рестартуется по live.
live = trueprocess still runningready = falseno new trafficВо время drain оба состояния сосуществуют

5. Deadline ограничивает всё завершение

5 / 5

context.WithTimeout ограничивает Shutdown и ожидание ресурсов. По истечении фиксируется незавершённая фаза и выполняется принудительный close.

  • Бюджет меньше terminationGracePeriodSeconds.
  • Фазы измеряются отдельно.
  • Ошибка shutdown логируется.
Stop acceptDrainCloseDeadline не позволяет ждать бесконечно

Когда Kubernetes решает убить pod — он отправляет процессу сигнал SIGTERM. 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 по истечении terminationGracePeriodSeconds (по умолчанию 30 секунд) пошлёт SIGKILL и убьёт процесс принудительно. Хорошее значение таймаута — 20–25 секунд: достаточно для большинства запросов. Но в 30 секунд по умолчанию оно вместе с preStop-паузой в 10 секунд уже не помещается, поэтому бюджет в манифесте поднимают до 60 — как его считать, разобрано в статье про Kubernetes.

Как поймать 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 удаляет под, адрес уходит из EndpointSlice сразу, но до kube-proxy на каждой ноде и до внешних балансировщиков это доезжает не мгновенно. Новые запросы могут продолжать приходить ещё 5–10 секунд после того, как процесс уже начал завершаться.

Первая половина решения — пауза preStop в манифесте, о ней в статье про HTTP drain. Вторая — переключить readiness-состояние сразу при получении сигнала, до вызова Shutdown. Тогда /health/ready начнёт возвращать 503. В самом Kubernetes удаляемый под из EndpointSlice уже выпал, но ту же ручку опрашивают ingress-контроллеры и внешние балансировщики, и она же спасает, когда SIGTERM приходит не от kubelet: docker stop, systemd, запуск на голой машине.

// 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 бьёт по обеим. Под, который Kubernetes уже удаляет, liveness не проверяют, но стоит сервису самому уйти в режим слива по команде дежурного или проверке базы внутри /health не пройти — kubelet начнёт перезапускать контейнер, который ничем не болен.

Правильно: при 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-запросы уже завершены и не обращаются к БД.

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

Глубже: signal.NotifyContext и второй сигналрасширенное

Канал с буфером — базовый вариант; с Go 1.16 то же короче через signal.NotifyContext: он возвращает контекст, который отменяется первым сигналом, и его удобно отдавать всем фоновым горутинам как родительский.

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

Полезная деталь: после stop() подписка снимается, и следующий сигнал снова обрабатывается по умолчанию — процесс завершается сразу. Поэтому stop() вызывают первым делом после получения сигнала: второй Ctrl+C в локальном запуске или повторный SIGTERM добьёт зависшую остановку, не дожидаясь SIGKILL. В коде с ручным каналом того же добиваются вызовом signal.Stop(sigC) сразу после чтения из канала.

Коротко

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

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