lenec ru

← все посты

Graceful shutdown в Go HTTP-сервисе: что обычно забывают

13K

Когда 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 в буфере может лежать несколько тысяч сообщений, которые ещё не отправлены в брокер. Если просто завершить процесс, они пропадут.

Порядок закрытия я обычно делаю такой:

  1. HTTP-сервер (новые запросы не принимаются, текущие дорабатываются).
  2. Фоновые потребители очередей (перестают читать новые сообщения).
  3. Producer-ы и пишущие клиенты — у них вызываем Flush или эквивалент.
  4. Соединения с БД и кэшем.

Перепутаешь порядок — закроешь БД, пока воркер ещё пишет, и получишь логи с 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-эксперимент: убивать поды раз в неделю и смотреть на дашборд ошибок. Если после этого никто не звонит, всё корректно.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.