Опирается на правила: R-SHUT-HTTP-1R-SHUT-HTTP-3 и R-SHUT-HTTP-X1 из Graceful Shutdown Style Guide → раздел 2. HTTP drain.

Важно знать

  • http.Server.Shutdown(ctx) дожидается всех in-flight запросов; Server.Close() рвёт их немедленно — это разные операции.
  • preStop hook со sleep 10 обязателен даже при правильном Shutdown — k8s отправляет SIGTERM до того, как kube-proxy на других нодах обновил iptables.
  • Без preStop в окне 5–15 секунд после SIGTERM новый трафик продолжает поступать на умирающий pod → 502.
  • appState.SetNotReady() вызывается первым, до srv.Shutdown/health/ready возвращает 503, k8s убирает pod из endpoints.
  • Долгие синхронные эндпоинты (>10s) — 202 Accepted + polling или постановка задачи в очередь; иначе SIGTERM обрывает их на полпути.
  • Контекст с таймаутом 20–25s для Shutdown — в общем бюджете 60s остаётся место Kafka и pgxpool.
  • На кластерах с 1000+ нод время распространения iptables-обновлений может достигать 20 секунд; sleep 10 — минимум.

HTTP drain — самая заметная часть graceful shutdown. Один 502 в rolling deploy виден клиенту; два — инцидент в метриках. В Go механизм двухслойный: http.Server.Shutdown дожимает in-flight запросы, preStop sleep поглощает окно kube-proxy propagation. Оба обязательны.

In-flight запросы дожимаются

R-SHUT-HTTP-1: http.Server.Shutdown(ctx) прекращает принимать новые соединения и ждёт, пока все активные обработчики вернут ответ.

Последовательность при корректной конфигурации:

T=0    SIGTERM получен
T=0+   appState.SetNotReady() → /health/ready = 503
T=0+   srv.Shutdown начат — новые соединения не принимаются
       Активные горутины-обработчики продолжают работу
T=5s   k8s readiness probe видит 503, убирает pod из Service endpoints
       (на других нодах kube-proxy уже должен был обновиться — см. preStop)
T=N    Все in-flight запросы вернули ответ
T=N    Shutdown завершён, pool.Close(), процесс выходит

Горутина OrderHandler.Create (обрабатывает 6 секунд) продолжает работу. Shutdown ждёт её в рамках переданного контекста (cfg.ShutdownTimeout = 25s).

// internal/server/server.go

func Run(ctx context.Context, srv *http.Server, appState *health.State, cfg Config) error {
	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:
		return err
	}

	appState.SetNotReady()

	shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
	defer cancel()

	if err := srv.Shutdown(shutCtx); err != nil {
		return fmt.Errorf("http shutdown: %w", err)
	}
	return nil
}

appState.SetNotReady() вызывается до Shutdown — health-endpoint немедленно отдаёт 503 и k8s перестаёт слать новый трафик через probe.

// internal/health/state.go

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() }
// chi-маршруты health

r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
})
r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
	if !appState.IsReady() {
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

preStop sleep — обязателен

R-SHUT-HTTP-2: sleep 10 в lifecycle.preStop нужен даже при правильном Shutdown.

# k8s/deployment.yaml
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: order-service
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]

Почему это нужно

T=0    kubelet получил команду удалить pod
T=0+   kubelet вызывает preStop hook (sleep 10)
       Параллельно: endpoints-controller убирает pod из Service
       Параллельно: kube-proxy на каждой ноде обновляет iptables
T=10s  preStop завершился
T=10s  kubelet отправляет SIGTERM в процесс
T=10s+ Run() ловит сигнал, начинает Shutdown

Без preStop SIGTERM приходит немедленно. appState.SetNotReady() вызван, Shutdown начат — но kube-proxy на других нодах ещё не обновил iptables. В это окно (типично 3–10s, на больших кластерах до 15s) новые запросы продолжают поступать на pod. Shutdown их уже не принимает → 502.

sleep 10 даёт kube-proxy время завершить propagation. За эти 10 секунд приложение работает нормально — SIGTERM ещё не послан.

Долгие endpoints

R-SHUT-HTTP-3: синхронный эндпоинт длиннее 10s конфликтует с graceful shutdown.

Типичная ситуация: POST /orders/{id}/submit запускает несколько шагов подбора (резервирование позиций Product, проверка баланса Customer), работает 20–30 секунд. При SIGTERM несколько таких запросов в обработке исчерпывают ShutdownTimeout — часть обрывается.

202 Accepted + polling

// internal/handler/order_handler.go

func (h *OrderHandler) Submit(w http.ResponseWriter, r *http.Request) {
	orderID := chi.URLParam(r, "id")
	idempotencyKey := r.Header.Get("Idempotency-Key")

	taskID, err := h.tasks.Enqueue(r.Context(), SubmitOrderTask{
		OrderID:        orderID,
		IdempotencyKey: idempotencyKey,
	})
	if err != nil {
		httperr.Write(w, r, err)
		return
	}

	w.Header().Set("Location", "/orders/"+orderID+"/status")
	w.WriteHeader(http.StatusAccepted)
	_ = json.NewEncoder(w).Encode(map[string]string{"taskId": taskID})
}

func (h *OrderHandler) SubmitStatus(w http.ResponseWriter, r *http.Request) {
	orderID := chi.URLParam(r, "id")
	status, err := h.orders.GetSubmitStatus(r.Context(), orderID)
	if err != nil {
		httperr.Write(w, r, err)
		return
	}
	_ = json.NewEncoder(w).Encode(status)
}

POST возвращает 202 + taskId за миллисекунды. Клиент polling-ает GET /orders/{id}/status до status: COMPLETED. На shutdown в активном HTTP нет долгих горутин — только короткий POST, Shutdown отрабатывает быстро.

Горутина с WaitGroup

Если нужно продолжить вычисление в фоне и привязать к shutdown:

// internal/handler/product_handler.go

func (h *ProductHandler) Reindex(w http.ResponseWriter, r *http.Request) {
	catalogID := chi.URLParam(r, "catalogID")

	h.wg.Add(1)
	go func() {
		defer h.wg.Done()
		if err := h.catalog.Reindex(context.Background(), catalogID); err != nil {
			slog.Error("reindex catalog", "catalog_id", catalogID, "error", err)
		}
	}()

	w.WriteHeader(http.StatusAccepted)
}

h.wg.Wait() вызывается в shutdown-последовательности после srv.Shutdown. Долгая операция завершается в рамках terminationGracePeriodSeconds.

Порядок shutdown в main

R-SHUT-DB-1, R-SHUT-CFG-3: порядок фаз критичен — pool закрывается последним.

// cmd/order-service/main.go

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
	if err != nil {
		slog.Error("pgxpool init", "error", err)
		os.Exit(1)
	}

	appState := health.NewState()

	r := chi.NewRouter()
	r.Get("/health/live", liveHandler())
	r.Get("/health/ready", readyHandler(appState))
	// ... бизнес-маршруты

	srv := &http.Server{Addr: cfg.Addr, Handler: r}

	if err := server.Run(ctx, srv, appState, cfg); err != nil {
		slog.Error("server run", "error", err)
	}

	slog.Info("закрываем pool")
	pool.Close()
}

pool.Close() стоит после server.Run — то есть после завершения srv.Shutdown и всех горутин, привязанных к WaitGroup.

Что запрещено

АнтипаттернПравилоЧто взамен
srv.Close() вместо srv.Shutdown(ctx)R-SHUT-HTTP-1Shutdown с таймаутным контекстом 20–25s
Отсутствие preStop sleepR-SHUT-HTTP-2exec: sleep 10 в lifecycle
preStop sleep < 5s на multi-node кластереR-SHUT-HTTP-2минимум 10s, для 1000+ нод — 20s
Синхронный endpoint >10s без asyncR-SHUT-HTTP-3202 Accepted + polling или WaitGroup-горутина
var shuttingDown bool без atomic.BoolR-SHUT-CFG-X1atomic.Bool в health.State как единственный источник состояния
pool.Close() до srv.ShutdownR-SHUT-DB-X1pool — последний в shutdown-последовательности
Отсутствие Idempotency-Key на мутирующих эндпоинтахR-SHUT-HTTP-3заголовок обязателен для retry-safe операций

Куда дальше

  • Бюджеты и observability — раскладка 60s budget, метрика app_shutdown_duration_seconds.
  • БД и persistence — порядок закрытия pgxpool.Pool и транзакции на SIGTERM.
  • Идемпотентность in-flight — Idempotency-Key и retry-safe операции.
  • Конфигурация runtime GoShutdownTimeout, health.State, os.Signal канал.
  • Kafka shutdown — kafka-go consumer, CommitMessages, writer.Close().
  • Kubernetes — terminationGracePeriodSeconds, probes, maxUnavailable: 0.
  • Scheduled / async / outbox — sync.WaitGroup, ctx.Done(), outbox-relay.