Graceful shutdown в Go HTTP-сервисе: что обычно забывают
Когда HTTP-сервис на Go получает SIGTERM, мало просто закрыть слушающий сокет. Между «решил выключиться» и «полностью выгрузился» происходит десяток вещей, которые легко проспать. На проде это вылезает в виде 502 у пользователей, потерянных in-flight запросов и ошибок «connection reset by peer» в логах балансировщика.
За девять лет я несколько раз возвращался к этой теме после инцидентов: каждый раз казалось, что у нас уже всё корректно, но всплывал новый угол. Соберу в одном месте, что обычно упускают, плюс рабочий шаблон, который переношу из проекта в проект.
Что такое graceful shutdown на самом деле
Если коротко: после получения сигнала о выключении сервис должен:
- перестать принимать новые соединения;
- дать текущим запросам корректно завершиться или прервать их по таймауту;
- дождаться завершения фоновых задач;
- закрыть подключения к внешним системам (БД, очереди, кэш);
- дослать логи и метрики;
- выйти с кодом 0.
Половина проблем в проде растёт из того, что забывают про пункты 3–5.
Базовый шаблон с http.Server.Shutdown
Структура, от которой я обычно начинаю:
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "err", err)
}
}
Это работает для одинокого сервиса без зависимостей. На проде такого не бывает.
Что обычно забывают
SIGTERM приходит раньше, чем балансировщик уведёт трафик
В k8s после SIGTERM под ещё какое-то время получает запросы: kubelet и kube-proxy узнают об удалении пода через несколько секунд, конфигурация обновляется асинхронно. Если сразу вызвать Shutdown и закрыть сокет, часть запросов прилетит в уже закрытый порт.
Решение — pre-stop пауза. Самое простое: между сигналом и реальным Shutdown подержать сервис в режиме «readiness=false, но liveness=true», чтобы балансировщик увёл трафик.
var ready atomic.Bool
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
ready.Store(true)
// ...
<-ctx.Done()
ready.Store(false)
time.Sleep(5 * time.Second) // успеть, чтобы readiness probe увидел 503
Альтернатива — preStop-хук в манифесте пода с sleep 5. На больших кластерах я предпочитаю явный таймер в коде: меньше зависимости от того, как настроен манифест.
Фоновые горутины никто не отменяет
Допустим, в сервисе есть воркер, который читает из очереди и пишет в БД. Если на shutdown мы только закрываем HTTP-сервер, воркер продолжает крутиться: его горутина живёт, пока не упадёт по таймауту kubelet (обычно 30 секунд) и не получит SIGKILL. Все недоделанные сообщения в очереди возвращаются на повторную обработку — на проде это выглядит как всплеск дубликатов.
Решение — общий root-context, который отменяется на сигнале, и явное ожидание завершения через WaitGroup или errgroup:
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return runWorker(gctx) })
g.Go(func() error { return runMetricsFlusher(gctx) })
g.Go(func() error {
<-gctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
})
if err := g.Wait(); err != nil {
slog.Error("shutdown error", "err", err)
}
Нюанс: воркер должен сам уметь корректно реагировать на отмену контекста — не «вышел из цикла», а «дописал текущее сообщение, закоммитил оффсет, потом вышел».
Подключения к внешним системам
Postgres pool, Redis-клиент, Kafka producer — все они держат соединения и буферы. Самый коварный случай — Kafka producer с linger.ms: на момент shutdown в буфере может лежать несколько тысяч сообщений, которые ещё не отправлены в брокер. Если просто завершить процесс, они пропадут.
Порядок закрытия я обычно делаю такой:
- HTTP-сервер (новые запросы не принимаются, текущие дорабатываются).
- Фоновые потребители очередей (перестают читать новые сообщения).
- Producer-ы и пишущие клиенты — у них вызываем
Flushили эквивалент. - Соединения с БД и кэшем.
Перепутаешь порядок — закроешь БД, пока воркер ещё пишет, и получишь логи с connection refused.
Метрики и логи тоже асинхронные
Если используешь OpenTelemetry или Prometheus pushgateway, у них есть собственные буферы. tracerProvider.Shutdown() и meterProvider.Shutdown() надо вызвать после того, как все юзеры этих провайдеров перестали писать. Иначе последние секунды жизни сервиса остаются без трейсов — ровно те, которые обычно интересуют, когда разбираешь инцидент.
Аналогично с асинхронными логгерами: zap.Sync() или slog с асинхронным sink — без явного flush последние строчки уйдут в /dev/null.
Слишком большой shutdown timeout
Видел такой паттерн: ставят terminationGracePeriodSeconds: 60 и shutdown timeout в коде на 55 секунд. Логика «пусть всё дойдёт». На практике это означает, что при rolling update под уезжает минуту, а если в этот момент редеплоится 50 подов сразу, окно деградации растягивается. Я держу shutdown timeout около 25–30 секунд: больше — значит, что-то не так в архитектуре, и стоит чинить причину, а не лечить таймаутом.
Случай с проды
В одной команде был платёжный сервис на Go. После rolling update раз в неделю всплывали ошибки «context canceled» в логах БД, по 30–50 штук за деплой. Долго думали, что это проблема пула соединений.
Покопал pprof и goroutine dump на момент SIGTERM — увидел, что у воркера, обрабатывающего вебхуки, отмена контекста прилетала в середине транзакции. Воркер ловил ctx.Err(), ронял транзакцию и выходил. Деньги, естественно, не терялись (idempotency-ключи спасали), но в Sentry летели алерты.
Починили так: воркер при получении сигнала перестаёт брать новые задачи из канала, но уже взятую дорабатывает с отдельным контекстом, который не привязан к shutdown-сигналу. Дополнительный таймаут — 10 секунд, после этого транзакция честно роллбэкается. Алерты ушли.
Мораль: «отмена контекста на shutdown» — не серебряная пуля. Иногда нужно дать задаче доработать, а не убивать на полпути.
Чек-лист, который держу под рукой
- Есть ли readiness-probe и пауза перед закрытием сокета?
- Все ли фоновые горутины подписаны на root-context и завершаются явно?
- Producer-ы вызывают Flush в правильном порядке?
- OpenTelemetry и log sink вызывают Shutdown/Sync?
- Shutdown timeout меньше, чем terminationGracePeriodSeconds, с запасом 5–10 секунд?
- Что произойдёт при SIGKILL? Идемпотентны ли in-flight операции?
Последний пункт особенно полезен. SIGKILL рано или поздно случится — kubelet не церемонится. Если архитектура переживает SIGKILL без потерь данных, шанс «нечестных» багов на graceful shutdown минимален.
Куда копать дальше
Если интересно глубже — посмотри на http.Server.RegisterOnShutdown для хуков, на http.Server.SetKeepAlivesEnabled(false) в начале shutdown, и на флаг terminationGracePeriodSeconds в манифестах k8s. Для отладки реальных таймингов помогает eBPF-трейсинг syscalls на close — видно, в каком порядке закрываются дескрипторы.
Лучший способ убедиться, что shutdown работает — устроить chaos-эксперимент: убивать поды раз в неделю и смотреть на дашборд ошибок. Если после этого никто не звонит, всё корректно.