---
title: "HTTP drain в Go — preStop sleep и долгие endpoints"
nav_title: "HTTP drain"
excerpt: "net/http + chi: Server.Shutdown дожимает in-flight запросы, preStop sleep закрывает окно kube-proxy, 202 Accepted — для эндпоинтов дольше 10 секунд."
keywords: "http.Server.Shutdown Go, preStop sleep k8s chi, in-flight requests graceful shutdown, 202 Accepted polling Go, R-SHUT-HTTP, kube-proxy iptables Go"
focus_keyword: "http.Server.Shutdown graceful shutdown Go"
tags:
  - go
  - graceful-shutdown
  - net/http
  - chi
  - kubernetes
---

# HTTP drain в Go — preStop sleep и долгие endpoints

> **Опирается на правила:** `R-SHUT-HTTP-1` … `R-SHUT-HTTP-3` и `R-SHUT-HTTP-X1` из Graceful Shutdown Style Guide → [раздел 2. HTTP drain](/standards/backend/graceful-shutdown/#2-http-drain--r-shut-http).

> **Важно знать**
> - **`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).

```go
// 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.

```go
// 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() }
```

```go
// 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`.

```yaml
# 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

```go
// 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:

```go
// 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 закрывается последним.

```go
// 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-1` | `Shutdown` с таймаутным контекстом 20–25s |
| Отсутствие `preStop sleep` | `R-SHUT-HTTP-2` | `exec: sleep 10` в lifecycle |
| `preStop sleep` < 5s на multi-node кластере | `R-SHUT-HTTP-2` | минимум 10s, для 1000+ нод — 20s |
| Синхронный endpoint >10s без async | `R-SHUT-HTTP-3` | 202 Accepted + polling или WaitGroup-горутина |
| `var shuttingDown bool` без `atomic.Bool` | `R-SHUT-CFG-X1` | `atomic.Bool` в `health.State` как единственный источник состояния |
| `pool.Close()` до `srv.Shutdown` | `R-SHUT-DB-X1` | pool — последний в shutdown-последовательности |
| Отсутствие `Idempotency-Key` на мутирующих эндпоинтах | `R-SHUT-HTTP-3` | заголовок обязателен для retry-safe операций |

## Куда дальше

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