Когда 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 по истечении 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 принимать трафик?» При
503pod убирается из балансировки. - Liveness probe — «жив ли под?» При
503Kubernetes перезапускает 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.
Что почитать дальше
- HTTP drain и долгие запросы — preStop sleep,
202 Acceptedдля медленных эндпоинтов. - БД и persistence — порядок закрытия
pgxpoolи транзакции при завершении. - Kafka shutdown — остановка потребителя через context и commit последних сообщений.
- Kubernetes-конфигурация —
terminationGracePeriodSeconds, настройка probes,maxUnavailable: 0.