← назад к разделу

Когда Kubernetes решает убрать pod — обновить версию или масштабировать — он отправляет процессу сигнал SIGTERM. Если приложение просто завершается по этому сигналу, все запросы, которые шли в этот момент, оборвутся на полпути. Клиент получит ошибку.

Чтобы этого не происходило, нужно правильно «осушить» HTTP-сервер: дождаться завершения текущих запросов и только потом выйти. Это и называют HTTP drain.

Почему простой выход ломает запросы

Представьте: клиент отправил запрос, сервер начал его обрабатывать — и в этот момент process получил SIGTERM и вышел. Соединение оборвалось посередине, клиент увидел 502 или сброс соединения.

В Go для корректной остановки сервера есть http.Server.Shutdown(ctx). В отличие от Close(), который немедленно рвёт все соединения, Shutdown делает три вещи:

  1. Перестаёт принимать новые соединения.
  2. Ждёт, пока все активные обработчики вернут ответ.
  3. Завершается только после того, как все запросы дошли до конца.

Как устроен правильный 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.