lenec ru

← все посты

Graceful shutdown: как корректно гасить сервис под нагрузкой

14K

Сервис должен уметь умирать спокойно. На демках и в локальных тестах это выглядит непрактично — приложение работает, ну и пусть работает. В проде разница между корректным завершением и резким kill-9 видна сразу: по всплеску ошибок 502 во время деплоя, по застрявшим транзакциям, по сообщениям, которые потеряли подтверждение и пришли к потребителю дважды.

Тема выглядит инфраструктурной, но в реальности это вопрос архитектуры приложения: как оно выходит на сцену, как обрабатывает запросы, и как уходит со сцены. Расскажу, как я обычно проектирую graceful shutdown, какие ловушки в Kubernetes, и где этот механизм ломается чаще всего.

Что должно произойти при остановке

Идеальный сценарий выглядит так:

  1. Приложение получает сигнал о завершении.
  2. Перестаёт принимать новые входящие запросы (либо отвечает «не готово»).
  3. Дорабатывает уже принятые запросы до конца.
  4. Закрывает фоновые задачи, не оставляя их в недоделанном состоянии.
  5. Закрывает соединения к БД, брокерам, кешам.
  6. Останавливается.

Между шагами есть тайминги. Если что-то застряло, надо иметь жёсткий лимит на завершение, иначе процесс зависнет навсегда. Все эти шаги должны быть явно описаны в коде, не наследоваться «по умолчанию» — потому что по умолчанию обычно делается ничего.

Сигналы и Kubernetes

В Kubernetes остановка пода выглядит так:

  1. Под помечается как Terminating.
  2. Endpoint controller убирает его из Endpoints (через несколько секунд, не мгновенно).
  3. Контейнер получает SIGTERM.
  4. kubelet ждёт terminationGracePeriodSeconds (по умолчанию 30 секунд).
  5. Если процесс не завершился, отправляется SIGKILL.

Главная подстава — между шагами 2 и 3 есть гонка. Endpoint обновляется не мгновенно, kube-proxy на нодах перечитывает iptables-правила тоже не сразу. То есть ваш под уже получил SIGTERM, перестал принимать запросы, а трафик ещё идёт по старым правилам. Получается окно, где пользователи получают connection refused.

Лекарство — preStop hook с задержкой:

lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]

Этот sleep даёт Kubernetes время убрать под из Endpoints до того, как вы начнёте отказываться. На больших кластерах этот sleep делают и 10 секунд. Без него ошибки 502 во время каждого деплоя — норма.

HTTP-сервер: как принимать запросы и не падать

В Go типовой код:

srv := &http.Server{Addr: ":8080", Handler: handler}

go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("server: %v", err)
    }
}()

stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("shutdown: %v", err)
}

Что тут важно. http.Server.Shutdown закрывает listener (новые соединения не принимаются), но даёт активным запросам доработать. Тайм-аут на shutdown должен быть меньше, чем terminationGracePeriodSeconds в Kubernetes (на 5–10 секунд).

В Java/Spring Boot есть server.shutdown=graceful и spring.lifecycle.timeout-per-shutdown-phase. Логика та же.

Если запрос длинный (например, генерация большого отчёта на 30 секунд), тайм-аут shutdown не должен его обрывать на полпути. Тут вариантов два: делать такие запросы асинхронными (вернули taskId, читайте статус), либо повышать grace period. Я предпочитаю первый.

Readiness vs liveness

Эти два пробника решают разные задачи и часто их путают.

Readiness — «готов ли я принимать трафик». Если возвращает не-200, под выводится из Endpoints, трафик не идёт. Под не перезапускается.

Liveness — «жив ли я вообще». Если возвращает не-200, kubelet перезапускает контейнер. Используется для случаев, когда процесс завис и сам себя не починит.

В контексте graceful shutdown полезно: при получении SIGTERM сразу переключайте readiness в not-ready. Это прямой сигнал балансировщику «не присылайте новых». Эндпоинт убирает под из Endpoints, трафик уходит на других, а вы спокойно дорабатываете уже принятые запросы.

var ready atomic.Bool
ready.Store(true)

// readiness handler
func(w http.ResponseWriter, r *http.Request) {
    if !ready.Load() {
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
}

// при сигнале
<-stop
ready.Store(false)
time.Sleep(5 * time.Second) // дать времени на распространение
// дальше srv.Shutdown

Очереди и потребители

Если ваш сервис читает из Kafka или RabbitMQ, при остановке нужно правильно завершить consumer.

Важно: не закрывайте соединение в момент, когда сообщение в работе, и не отдали ack. Иначе брокер сочтёт сообщение неподтверждённым, передаст другому потребителю, и оно обработается дважды (если у вас не настроена идемпотентность — это баг).

Алгоритм:

  1. При получении сигнала перестаёте брать новые сообщения (commit/poll-цикл прекращается).
  2. Дорабатываете то, что уже взяли.
  3. Делаете финальный commit.
  4. Закрываете соединение.

В Kafka на Go это обычно через флаг или контекст в потребителе. В Java/Kotlin при использовании Spring Kafka — ContainerStoppingErrorHandler и обработка через жизненный цикл контейнера.

Базы данных и транзакции

Открытые транзакции при kill — отдельный класс боли. Postgres откатит, но если транзакция длинная и держала локи, во время отката будут ошибки у других клиентов.

Что я делаю:

  • В коде не запускаю длинных транзакций без явной причины. Большие пачки — разбиваются.
  • В пуле соединений (HikariCP, pgxpool, etc.) задаю разумный connectionTimeout и maxLifetime, чтобы соединения не висели вечно.
  • При shutdown сначала ждём завершения активных запросов, потом закрываем пул.

Закрытие пула — отдельная операция. В большинстве пулов есть метод close, который ждёт текущих и закрывает. У него тоже есть тайм-аут.

Фоновые задачи

Если у вас в сервисе крутятся фоновые таски (cron, scheduler, периодические jobs), они должны уметь останавливаться. Не «выйти прямо сейчас и оставить полработы», а «доделать текущую итерацию и не начинать новую».

func runWorker(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            doWork(ctx)
        }
    }
}

Контекст передаётся вниз везде, и при shutdown отменяется. Worker завершает текущую doWork и выходит. doWork сама должна уметь работать с отменой контекста: длинные операции внутри тоже принимают ctx.

WebSocket и long-poll

Особый случай — соединения, которые держатся долго. WebSocket-сессии, server-sent events, long-poll-запросы.

При shutdown эти соединения нужно явно закрыть, иначе клиенты будут ждать таймаута и видеть «обрыв». Шаги:

  1. Перестать принимать новые подключения.
  2. Послать существующим клиентам close frame с понятным кодом (1001 «going away» в WebSocket).
  3. Дать клиентам короткое окно на корректное завершение.
  4. Принудительно закрыть оставшиеся соединения.

Хорошо спроектированный клиент при 1001 просто переподключается к другому экземпляру и продолжает работу. Плохо спроектированный считает это ошибкой и ругается. Тут уже не ваша работа — но в идеале клиент тоже умеет.

Тестирование

Graceful shutdown без тестов — это надежда. Что я обычно делаю:

  • Локальный тест: запустил под нагрузкой (ab/wrk/k6), послал SIGTERM, проверил, что все запросы завершились с кодом 2xx, не 5xx.
  • Интеграционный тест: в pre-prod окружении кручу деплой раз в минуту под нагрузкой, смотрю на 5xx-ошибки. Цель — 0.
  • Тест на длинные операции: запустил долгий запрос, послал shutdown через 1 секунду, убедился, что тайм-аут на shutdown позволяет ему завершиться или прерывает корректно.

Эти тесты раз в квартал спасают от регрессий, когда новый код в shutdown забыл что-то закрыть.

Чек-лист graceful shutdown

  • preStop hook с задержкой 5–10 секунд.
  • Обработчики SIGTERM явно описаны.
  • Readiness переключается в not-ready при остановке.
  • HTTP-сервер закрывается с тайм-аутом меньше grace period.
  • Consumer-ы очередей корректно дорабатывают батч и коммитят.
  • Фоновые задачи получают cancel и завершают текущую итерацию.
  • Пулы соединений закрываются после остановки серверов.
  • Тесты на shutdown под нагрузкой пройдены.

Что запомнить

Graceful shutdown — это не «галочка в чеклисте Kubernetes», это требование к коду приложения. Всё, что зависит от внешних ресурсов и принимает запросы, должно уметь корректно завершаться. Без этого деплой превращается в источник 5xx-ошибок, инциденты — в загадки про потерянные сообщения, а пользовательский опыт — в «иногда нажимаешь и ничего не происходит». Стоимость реализации — несколько дней работы на сервис, выгода — часы недосыпа во время инцидентов.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.