Graceful shutdown: как корректно гасить сервис под нагрузкой
Сервис должен уметь умирать спокойно. На демках и в локальных тестах это выглядит непрактично — приложение работает, ну и пусть работает. В проде разница между корректным завершением и резким kill-9 видна сразу: по всплеску ошибок 502 во время деплоя, по застрявшим транзакциям, по сообщениям, которые потеряли подтверждение и пришли к потребителю дважды.
Тема выглядит инфраструктурной, но в реальности это вопрос архитектуры приложения: как оно выходит на сцену, как обрабатывает запросы, и как уходит со сцены. Расскажу, как я обычно проектирую graceful shutdown, какие ловушки в Kubernetes, и где этот механизм ломается чаще всего.
Что должно произойти при остановке
Идеальный сценарий выглядит так:
- Приложение получает сигнал о завершении.
- Перестаёт принимать новые входящие запросы (либо отвечает «не готово»).
- Дорабатывает уже принятые запросы до конца.
- Закрывает фоновые задачи, не оставляя их в недоделанном состоянии.
- Закрывает соединения к БД, брокерам, кешам.
- Останавливается.
Между шагами есть тайминги. Если что-то застряло, надо иметь жёсткий лимит на завершение, иначе процесс зависнет навсегда. Все эти шаги должны быть явно описаны в коде, не наследоваться «по умолчанию» — потому что по умолчанию обычно делается ничего.
Сигналы и Kubernetes
В Kubernetes остановка пода выглядит так:
- Под помечается как Terminating.
- Endpoint controller убирает его из Endpoints (через несколько секунд, не мгновенно).
- Контейнер получает
SIGTERM. - kubelet ждёт
terminationGracePeriodSeconds(по умолчанию 30 секунд). - Если процесс не завершился, отправляется
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. Иначе брокер сочтёт сообщение неподтверждённым, передаст другому потребителю, и оно обработается дважды (если у вас не настроена идемпотентность — это баг).
Алгоритм:
- При получении сигнала перестаёте брать новые сообщения (commit/poll-цикл прекращается).
- Дорабатываете то, что уже взяли.
- Делаете финальный commit.
- Закрываете соединение.
В 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 эти соединения нужно явно закрыть, иначе клиенты будут ждать таймаута и видеть «обрыв». Шаги:
- Перестать принимать новые подключения.
- Послать существующим клиентам close frame с понятным кодом (1001 «going away» в WebSocket).
- Дать клиентам короткое окно на корректное завершение.
- Принудительно закрыть оставшиеся соединения.
Хорошо спроектированный клиент при 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-ошибок, инциденты — в загадки про потерянные сообщения, а пользовательский опыт — в «иногда нажимаешь и ничего не происходит». Стоимость реализации — несколько дней работы на сервис, выгода — часы недосыпа во время инцидентов.