Graceful shutdown без 502

Перед началом: модули 6–10 (конвейеры, пулы, таймауты, HTTP). Инструменты: Go 1.22+, docker или kubectl для деплоя. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: деплой раз в час — и каждый раз пачка 502

Сервис выкатывается каждый час — так устроен CI. После каждого деплоя балансировщик фиксирует всплеск 502: часть запросов приходит ровно в момент, когда под уже получил SIGTERM, но приложение ещё не перестало принимать новое и не дообработало начатое. Одновременно теряются фоновые задачи: воркер писал отчёты из очереди, процесс завершился — и половина очереди испарилась.

Под умирает по SIGTERM не «за секунду»: у него есть terminationGracePeriodSeconds, и всё это время приложение обязано вести себя так, чтобы наружу не ушло ни одного 502, а принятая работа была либо выполнена, либо честно возвращена в источник.

📖 Теория

11.1 Сигналы: signal.NotifyContext как выключатель

SIGTERM — вежливая просьба завершиться; SIGKILL перехватить нельзя, поэтому вся надежда на первые секунды. Вместо ручного signal.Notify и канала используйте signal.NotifyContext: он возвращает ctx, который отменяется по сигналу, и stop для освобождения подписки.

Graceful shutdown
go
ctx, stop := signal.NotifyContext(
    context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()

go func() {
    <-ctx.Done() // сигнал получен — начинаем хореографию
}()

Отменённый ctx — это ещё не остановка: он лишь разрешает начать остановку. Все долгоживущие горутины должны смотреть на ctx.Done() и завершаться сами; os.Exit из обработчика сигнала обходит все defer и превращает graceful в жёсткий.

11.2 Порядок: readiness → Shutdown → drain → зависимости

Порядок не декоративный: каждый шаг защищает следующий.

  1. Readiness off. /ready начинает отвечать 503, и балансировщик перестаёт слать новое. Это асинхронно: между «выключили readiness» и «перестали приходить запросы» проходит время, поэтому в preStop-хуке часто ставят паузу на 5–10 с.
  2. http.Server.Shutdown с дедлайном. Сервер закрывает слушатели и ждёт завершения in-flight запросов; новые соединения не принимаются. Close вместо Shutdown рвёт активные запросы — это и есть источник 502.
  3. Drain воркеров. Задачи из очереди дообрабатываются до дедлайна; не успевшие фиксируются и возвращаются в источник.
  4. Закрытие зависимостей — в обратном порядке потоку данных: сначала продьюсеры, затем консьюмеры, БД последней. Если закрыть БД раньше воркеров, drain начнёт сыпать ошибками.

Отдельная ловушка — WebSocket и hijacked-соединения: Shutdown их не ждёт. Для них нужен свой реестр соединений и собственный дедлайн закрытия.

11.3 Drain с дедлайном и честный сброс необработанного

Drain — это «дочитать очередь, пока не истёк дедлайн, и честно признать, что не успели». Дедлайн берётся из бюджета деплоя: terminationGracePeriodSeconds минус время на Shutdown и закрытие зависимостей.

Важно не «молча выбросить» остаток, а вернуть его в источник: задача либо остаётся в брокере (если вы читали с ack), либо пишется в outbox или таблицу повторов, либо помечается failed. Счётчик сброшенных задач — обязательная метрика: он отличает «плавный деплой» от «тихую потерю работы».

go
deadline := time.Now().Add(grace)
for {
    select {
    case t, ok := <-queue:
        if !ok {
            return // владелец закрыл очередь
        }
        if time.Now().After(deadline) {
            persist(t) // честный сброс: задача не потеряна
            dropped.Add(1)
            continue
        }
        handle(t)
    case <-ctx.Done():
        return
    }
}

11.4 WaitGroup на воркеров: Shutdown не ждёт фон

http.Server.Shutdown возвращается, когда закончились HTTP-хендлеры. Фоновые воркеры, writer в БД, тикеры — вне его юрисдикции. Если после Shutdown сразу выйти из main, процесс умрёт вместе с ними, а вы будете думать, что drain был.

Поэтому у воркеров свой WaitGroup, и главный поток ждёт его отдельно — тоже с дедлайном, чтобы зависшая задача не держала под до SIGKILL. Классическая ошибка — wg.Wait() без таймаута: одна горутина, застрявшая в клиенте без таймаута, блокирует деплой навсегда.

go
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()

select {
case <-done:
case <-time.After(10 * time.Second): // не ждём вечно
    log.Println("drain не успел: остаток сохранён")
}

11.5 Идемпотентность: перезапуск умеет повторять

Graceful shutdown уменьшает окно потерь, но не делает доставку ровно один раз. Причины:

  • балансировщик мог отправить запрос, который уже выполнился, но ответ не дошёл — клиент повторит;
  • задача обработана, а подтверждение (ack) не сохранилось — после рестарта она придёт снова;
  • SIGKILL в конце grace-периода обрывает всё, что не успело зафиксироваться.

Значит, обработчики обязаны быть идемпотентными: idempotency key из запроса, upsert вместо insert, таблица дедупликации или транзакционный outbox. «Мы сделали graceful shutdown» — не гарантия доставки, а всего лишь способ не терять работу в штатном деплое.

11.6 Компромиссы

  • Grace-период короткий vs длинный: короткий ускоряет деплой, но увеличивает число оборванных запросов и сброшенных задач; длинный безопаснее, но замедляет выкатку и держит ресурсы.
  • Readiness-пауза vs мгновенный Shutdown: пауза стоит секунд деплоя, но закрывает гонку с балансировщиком; без неё 502 остаются даже при идеальном Shutdown.
  • Дедлайн drain vs бесконечное ожидание: дедлайн ограничивает деплой, но требует честного сброса; бесконечное ожидание превращает зависшую задачу в зависший под.
  • Синхронная обработка vs фоновая очередь: синхронная проще и не теряет при рестарте, но удлиняет ответ; фоновая быстрее для пользователя, но требует идемпотентности и хранилища задач.
  • Своя хореография vs платформенные хуки: preStop и terminationGracePeriodSeconds дают базу, но не отменяют необходимость drain внутри приложения.

💻 Практика

  1. Воспроизведите 502. HTTP-сервер с хендлером на 2 с и нагрузкой 50 rps. Пошлите SIGTERM и сравните два варианта: srv.Close() и srv.Shutdown(ctx) с дедлайном 10 с. Считайте 502 и оборванные соединения на клиенте.
  2. Соберите хореографию. /ready с атомарным флагом, signal.NotifyContext, Shutdown с дедлайном, drain воркеров, закрытие зависимостей в обратном порядке. Логируйте таймлайн: сигнал → readiness off → последний запрос → drain → выход.
  3. Проверьте честный сброс. Очередь из 100 задач, обработчик медленнее grace-периода. Убедитесь, что необработанные задачи попали в хранилище повторов и посчитаны метрикой; перезапустите процесс и обработайте их повторно.
  4. Запустите сниппет модуля: он печатает пять шагов хореографии, затем drain очереди из 8 задач с дедлайном в 5 тиков — в выводе будет «обработано 5, сброшено 3».

🧩 Quiz

1. Зачем выключать readiness до вызова Shutdown?
2. Почему после graceful shutdown всё равно нужна идемпотентность?
3. Что делать с задачами, не успевшими до дедлайна drain?
4. Чего http.Server.Shutdown НЕ ждёт?
5. В каком порядке закрывать зависимости?

✅ Итоги модуля

Студент должен:

  • Ловить SIGTERM через signal.NotifyContext
  • Выстраивать порядок: readiness → Shutdown → drain
  • Ставить дедлайн на остановку и не ждать вечно
  • Возвращать необработанное в источник и считать метрикой
  • Ждать фоновые воркеры отдельно от Shutdown
  • Делать обработчики идемпотентными к повторам
  • Объяснить, откуда при деплое берутся 502
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 12

Корректная остановка спасает свой под, но не спасает от чужой деградации: если партнёрский API перестал отвечать, запросы копятся в хендлерах и таймаутах, пока не съедят пул соединений. Дальше — circuit breaker и bulkhead: как перестать ходить в мёртвый сервис и изолировать ресурсы по зависимостям.