Когда 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 принимать трафик?» При
503pod убирается из балансировки. - Liveness probe — «жив ли под?» При
503Kubernetes перезапускает 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.