Опирается на правила:
R-SHUT-HTTP-1…R-SHUT-HTTP-3иR-SHUT-HTTP-X1из Graceful Shutdown Style Guide → раздел 2. HTTP drain.
Важно знать
http.Server.Shutdown(ctx)дожидается всех in-flight запросов;Server.Close()рвёт их немедленно — это разные операции.preStophook со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-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 — раскладка 60s budget, метрика
app_shutdown_duration_seconds. - БД и persistence — порядок закрытия
pgxpool.Poolи транзакции на SIGTERM. - Идемпотентность in-flight —
Idempotency-Keyи retry-safe операции. - Конфигурация runtime Go —
ShutdownTimeout,health.State,os.Signalканал. - Kafka shutdown —
kafka-goconsumer,CommitMessages,writer.Close(). - Kubernetes —
terminationGracePeriodSeconds, probes,maxUnavailable: 0. - Scheduled / async / outbox —
sync.WaitGroup,ctx.Done(), outbox-relay.