Когда Kubernetes решает убрать pod — обновить версию или масштабировать — он отправляет процессу сигнал SIGTERM. Если приложение просто завершается по этому сигналу, все запросы, которые шли в этот момент, оборвутся на полпути. Клиент получит ошибку.
Чтобы этого не происходило, нужно правильно «осушить» HTTP-сервер: дождаться завершения текущих запросов и только потом выйти. Это и называют HTTP drain.
Почему простой выход ломает запросы
Представьте: клиент отправил запрос, сервер начал его обрабатывать — и в этот момент process получил SIGTERM и вышел. Соединение оборвалось посередине, клиент увидел 502 или сброс соединения.
В Go для корректной остановки сервера есть http.Server.Shutdown(ctx). В отличие от Close(), который немедленно рвёт все соединения, Shutdown делает три вещи:
- Перестаёт принимать новые соединения.
- Ждёт, пока все активные обработчики вернут ответ.
- Завершается только после того, как все запросы дошли до конца.
Как устроен правильный shutdown
Минимальная структура выглядит так:
// 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(), и только потом — srv.Shutdown. Это переключает health-endpoint в состояние «не готов» ещё до того, как сервер начнёт отказывать.
Зачем нужен health endpoint
Когда Kubernetes решает, отправлять ли трафик на pod, он проверяет readiness probe — специальный endpoint, который сервис сам регистрирует.
// chi-маршруты
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)
})
/health/live отвечает всегда — он говорит «процесс жив». /health/ready возвращает 503 после вызова SetNotReady() — это сигнал Kubernetes убрать pod из списка адресов для балансировки и прекратить слать новые запросы.
// 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() }
Обратите внимание: используется atomic.Bool, а не обычная переменная. Health-endpoint вызывается из нескольких горутин параллельно, поэтому нужна потокобезопасная операция.
Проблема kube-proxy и почему нужен preStop sleep
Даже при правильно написанном Shutdown в Kubernetes есть скрытая ловушка.
Когда kubelet решает удалить pod, он делает несколько вещей одновременно:
- отправляет SIGTERM в процесс,
- сообщает остальным компонентам кластера, что pod нужно убрать из балансировки.
Но «убрать из балансировки» — это не мгновенно. На каждой ноде кластера работает kube-proxy, который обновляет правила iptables. На это уходит несколько секунд. Если кластер большой (сотни нод), может пройти 10–15 секунд до того, как все ноды узнают, что pod недоступен.
В итоге процесс уже получил SIGTERM и начал Shutdown (новые соединения не принимаются), а другие поды в кластере всё ещё шлют на него запросы через старые правила iptables. Эти запросы получат ошибку.
Решение — preStop hook в манифесте Kubernetes:
# k8s/deployment.yaml
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
Kubernetes выполняет preStop до отправки SIGTERM. Пока idёт sleep 10, приложение работает нормально и принимает трафик. За эти 10 секунд kube-proxy на всех нодах успевает обновить правила. Только после этого приходит SIGTERM — и к тому моменту никто уже не шлёт трафик на этот pod.
Последовательность с preStop:
T=0 kubelet решает удалить pod
T=0+ preStop запущен (sleep 10)
Параллельно: kube-proxy обновляет iptables на нодах
T=10s preStop завершился
T=10s kubelet отправляет SIGTERM
T=10s+ приложение ловит сигнал, вызывает SetNotReady() и Shutdown()
T=N все in-flight запросы завершены, процесс выходит
Долгие операции — отдельная история
Server.Shutdown ждёт, пока завершатся все активные HTTP-обработчики. Если операция занимает 20–30 секунд (например, сложная бизнес-логика с несколькими шагами), это создаёт проблему: при SIGTERM несколько таких запросов в обработке могут не уложиться в бюджет времени на остановку.
202 Accepted + polling
Хорошее решение для долгих операций — немедленно вернуть 202 Accepted и поставить задачу в очередь:
// 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 отвечает за миллисекунды. Клиент периодически проверяет GET /orders/{id}/status. При остановке сервера в HTTP нет долгих горутин — Shutdown отрабатывает быстро.
Фоновая горутина с WaitGroup
Если нужно продолжить вычисление в фоне и дождаться его при остановке:
// 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() вызывается в последовательности остановки после srv.Shutdown — долгая операция успевает завершиться в рамках общего бюджета времени.
Порядок остановки в main
// 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 — то есть после того, как Shutdown завершился и все горутины из WaitGroup закончили работу. Это важно: если закрыть пул базы данных раньше, запросы, которые ещё обрабатываются, потеряют соединение.
Частые ошибки
srv.Close() вместо srv.Shutdown(ctx) — Close() немедленно обрывает все соединения, не дожидаясь обработчиков. Всегда используйте Shutdown с таймаутным контекстом (20–25 секунд).
Нет preStop sleep — даже при правильном Shutdown без preStop в окне 5–15 секунд после SIGTERM новые запросы будут попадать на pod, который уже не принимает соединения. Ошибка проявляется в rolling deploy и сложно воспроизводится.
preStop sleep меньше 5 секунд — на больших кластерах (1000+ нод) распространение обновлений iptables может занять до 20 секунд. Минимум — 10 секунд.
Синхронный долгий endpoint без async — операции дольше 10 секунд стоит переводить на 202 Accepted + polling, иначе несколько таких запросов в полёте могут не уложиться в бюджет времени на остановку.
pool.Close() до srv.Shutdown — базу закрываем последней, после HTTP и всех фоновых горутин.
Коротко
http.Server.Shutdown(ctx)дожидается завершения всех активных запросов;Close()рвёт их немедленно — это разные вещи.- Перед
ShutdownвызывайтеSetNotReady()— health-endpoint начнёт возвращать 503, Kubernetes перестанет слать трафик. preStop: sleep 10в манифесте обязателен: он даёт kube-proxy время обновить iptables до того, как придёт SIGTERM.- Долгие операции (>10 секунд) переводите на 202 Accepted + polling или фоновую горутину с WaitGroup.
- Пул базы данных закрывается последним — после HTTP drain и всех фоновых задач.
Что почитать дальше
- Бюджеты и observability — как распределить 60 секунд между фазами остановки.
- БД и persistence — порядок закрытия pgxpool и транзакции на SIGTERM.
- Kafka shutdown — остановка consumer и корректный commit офсетов.
- Kubernetes —
terminationGracePeriodSeconds, probes,maxUnavailable: 0.