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

KUBERNETES И GRACEFUL SHUTDOWN HTTP drain

Обязательно

HTTP drain в Go: остановить сервер без обрыва запросов

Go · Shutdown · preStop

Readiness убирает новые запросы, Shutdown ждёт in-flight, preStop даёт endpoints время обновиться.

1. Сначала прекращается новый трафик

1 / 5

Ready=false прекращает добавление endpoints. kube-proxy и ingress должны успеть убрать pod до последнего нового запроса.

  • Флаг меняется до Shutdown.
  • Endpoints обновляются асинхронно.
  • preStop sleep закрывает окно маршрутизации.
ready=falsepreStop sleepEndpoints removedЗатем Server.Shutdown

2. Shutdown дожидается in-flight

2 / 5

Сервер прекращает accept и ждёт завершения текущих handlers. Долгий handler получает отмену контекста и обязан завершить работу.

  • Request context отменяется.
  • Работа имеет собственный deadline.
  • Новых запросов больше нет.
No new requestsHandlers finishShutdown ждёт ограниченное время

3. Долгая операция не держит сервер вечно

3 / 5

Экспорт или streaming переводится в async или ограничивается дедлайном. По его истечении операция возвращает понятный отказ.

  • 202 подходит долгой задаче.
  • Статус опрашивается отдельно.
  • Бюджет меньше grace period.
202 AcceptedjobID + status URLWorkerbounded or resumableHTTP не ждёт всю долгую работу

4. preStop не заменяет graceful shutdown

4 / 5

Sleep лишь даёт маршрутам обновиться. Само завершение handlers и ресурсов продолжает Server.Shutdown и отмена workers.

  • Sleep ограничен и не бесконечный.
  • Drain измеряется отдельно.
  • Затем закрываются БД и очереди.
preStop sleepServer.ShutdownClose resourcesРазные фазы одного бюджета

5. Деплой проверяется отсутствием 502

5 / 5

Во время rolling update клиентские запросы продолжаются. Тест доказывает, что старые поды завершают in-flight, новые принимают трафик.

  • Метрики считают in-flight.
  • 502 не появляется при корректном drain.
  • Grace period включает все фазы.
Old pod drainsNew pod readyКлиент не видит 502

Когда 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 под при удалении выпадает из EndpointSlice сам, ещё до первой проверки, так что для kubelet этот 503 ничего не меняет. Флаг нужен всем остальным: внешнему балансировщику или ingress-контроллеру, который опрашивает ручку сам, и запуску вне кластера — docker stop, systemd, голая машина, — где SIGTERM приходит, а из списка адресов сервис никто не убирает.

// 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 есть скрытая ловушка.

Когда под удаляют, параллельно происходят две независимые вещи:

  • API-сервер помечает под как Terminating, и контроллер EndpointSlice убирает его адрес из списка адресов сервиса;
  • kubelet на ноде запускает остановку контейнера: preStop-хук, если он есть, и затем SIGTERM.

Но «убрать из балансировки» — это не мгновенно. На каждой ноде кластера работает 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. Пока идёт sleep 10, приложение работает нормально и принимает трафик. За эти 10 секунд kube-proxy на всех нодах успевает обновить правила. Только после этого приходит SIGTERM — и к тому моменту никто уже не шлёт трафик на этот pod.

Последовательность с preStop:

T=0 kubelet решает удалить pod T=0+ запущен preStop: sleep 10, трафик ещё идёт T=0…10s kube-proxy обновляет iptables на нодах T=10s preStop завершился, kubelet шлёт SIGTERM T=10s+ SetNotReady() и Shutdown(): новых соединений нет T=N начатые запросы дошли до конца, процесс выходит

Пауза в preStop нужна не приложению, а кластеру: за эти секунды kube-proxy на всех нодах успевает убрать pod из правил балансировки, и SIGTERM приходит уже в тишину. Платим фиксированными десятью секундами к каждой остановке.

Долгие операции — отдельная история

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 и всех фоновых горутин.

Дополнительно: при первом чтении можно пропустить

Глубже: чего Shutdown не делаетрасширенное

Shutdown закрывает слушающие сокеты и простаивающие соединения, а занятые ждёт, пока обработчик не вернёт ответ. Два случая проходят мимо него. Первый — перехваченные соединения (Hijack, а значит и WebSocket): их Shutdown не ждёт и не закрывает; за ними следят отдельно, через RegisterOnShutdown, и рвут сами. Второй — истёкший контекст: Shutdown вернёт context.DeadlineExceeded, но зависшие обработчики останутся жить, пока процесс не выйдет; если после таймаута их всё же нужно оборвать, следом вызывают srv.Close().

Ещё одна деталь про контекст: Shutdown не отменяет r.Context() у запросов, которые уже обрабатываются, — они дорабатывают со своим контекстом. А для фоновой горутины, запущенной из обработчика, вместо context.Background() лучше context.WithoutCancel(r.Context()) (Go 1.21): значения контекста — trace, идентификатор запроса — сохраняются, а отмена по закрытию соединения снимается; срок жизни ей задают отдельным context.WithTimeout под бюджет остановки.

Коротко

  • http.Server.Shutdown(ctx) дожидается завершения всех активных запросов; Close() рвёт их немедленно — это разные вещи.
  • Перед Shutdown вызывайте SetNotReady() — health-endpoint начнёт возвращать 503; из EndpointSlice под убирает само удаление, флаг страхует балансировщики, которые опрашивают ручку сами, и запуск вне кластера.
  • preStop: sleep 10 в манифесте обязателен: он даёт kube-proxy время обновить iptables до того, как придёт SIGTERM.
  • Долгие операции (>10 секунд) переводите на 202 Accepted + polling или фоновую горутину с WaitGroup.
  • Пул базы данных закрывается последним — после HTTP drain и всех фоновых задач.
  • Shutdown не ждёт перехваченных соединений (WebSocket) и по истечении контекста оставляет зависшие обработчики жить; для фоновой работы из обработчика берите context.WithoutCancel(r.Context()) со своим таймаутом.

Что почитать дальше