open atlas
↑ К треку
Go с нуля до senior GO · 03 · 04

Graceful shutdown: Shutdown(ctx), проводка SIGTERM и почему деплои всё ещё роняют запросы

Shutdown(ctx) закрывает листенеры, перестаёт принимать и дожидается активных запросов до дедлайна контекста; signal.NotifyContext подключает SIGTERM. Он не покрывает hijacked-соединения и веб-сокеты, а в k8s перед дренажем нужно снять readiness и переждать обновление эндпоинтов.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Каждый деплой рисовал один и тот же график: 90-секундный всплеск 502-х, примерно 0,3% трафика, потом тишина. Команда уже сделала знаменитый фикс — signal.NotifyContext на SIGTERM, srv.Shutdown(ctx) с 30-секундным дренажем — всплеск сжался, но умирать отказался. Оставшиеся ошибки были запросами, пришедшими после SIGTERM: их слали балансировщики, ещё не знавшие, что под уходит. Kubernetes удаляет терминируемый под из Endpoints асинхронно; несколько секунд правила kube-proxy и ingress продолжают вести новые соединения к серверу, который уже закрыл листенер. Shutdown работал идеально — отказывая ровно тем соединениям, которые кластер ещё посылал. Фикс был унизительно прост: перевести readiness-пробу в failing, поспать ~5 секунд в preStop, чтобы распространение эндпоинтов завершилось, и потом звать Shutdown. 502-е ушли в ноль. Graceful shutdown — это рукопожатие распределённой системы, и Shutdown(ctx) — лишь ваша половина.

Что Shutdown(ctx) делает на самом деле

srv.Shutdown(ctx) исполняет точную последовательность: закрыть все листенеры (новые подключения отвергаются), закрыть простаивающие keep-alive-соединения, затем ждать — опросом, — пока активные запросы завершатся. Возвращается он, когда сервер полностью дренирован, либо когда истёк ctx — что раньше. По истечении он возвращает ctx.Err(), но не убивает насильно соединения в полёте; для жёсткой остановки после льготного периода добавьте srv.Close(). Тем временем заблокированный ListenAndServe немедленно возвращает http.ErrServerClosed — ожидаемое значение, не ошибка:

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

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

<-shutdownSignal // см. следующий раздел

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("дренаж не завершён: %v", err) // ctx истёк с запросами в полёте
	srv.Close()                                // жёстко закрыть отставших
}

Две детали, о которых спрашивают сеньоров. Первая — порядок: Shutdown обязан выполняться в другой горутине, нежели ListenAndServe, потому что последний заблокирован обслуживанием. Вторая — дедлайн дренажа это число-политика: не меньше вашего самого медленного легитимного запроса и не больше того, что позволяет платформа, — Kubernetes шлёт SIGKILL по истечении terminationGracePeriodSeconds (по умолчанию 30 с), а SIGKILL не ведёт переговоров.

Проводка сигнала: signal.NotifyContext

О своей смерти процесс узнаёт из сигнала — SIGTERM от Kubernetes/systemd, SIGINT от человеческого Ctrl-C. С Go 1.16 идиоматический мост от сигнала к отмене — одна строка:

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

// ... запуск сервера ...

<-ctx.Done() // блокируется до SIGTERM/SIGINT
stop()       // вернуть дефолтную обработку: ВТОРОЙ сигнал убивает сразу

NotifyContext возвращает контекст, отменяемый первым подходящим сигналом. stop() после Done() — осознанный ход эксплуатации: он снимает обработчик, и второй Ctrl-C минует ваш дренаж и убивает процесс — аварийный люк для зависшего shutdown. Без него нетерпеливый оператор, молотящий Ctrl-C, становится заложником вашего 30-секундного дренажа. Ещё один острый край: пока вы дренируете, хендлеры в полёте продолжают работать — если они проверяют глобальный флаг «выключаемся» или вы слишком рано отменяете их базовый контекст, вы превращаете аккуратный дренаж в самострел из 500-х. Дренаж значит дать им закончить, с их собственными дедлайнами как границей.

Викторина

srv.Shutdown(ctx) вызван с 30-секундным контекстом, в полёте один запрос. Запрос завершается за 4 секунды. Когда вернётся Shutdown и что произошло с ListenAndServe?

Чего Shutdown не покрывает

Прежде чем считать логику выключения завершённой, спроси себя: есть ли в этом сервисе веб-сокеты или долгоживущие стримы? Если да — одного Shutdown недостаточно.

Shutdown ждёт соединения в обычном HTTP-цикле запроса. Два класса трафика явно вне его:

  • Hijacked-соединения — всё, что забрало сырое TCP-соединение через http.Hijacker, то есть каждый веб-сокет. После hijack net/http больше не отслеживает состояние соединения; Shutdown его ни ждёт, ни закрывает.
  • Долгоживущие стримы, которые сами не заканчиваются — SSE-хендлер в цикле for это активный запрос, и голый Shutdown будет ждать его до дедлайна дренажа, на каждом деплое, пока вы не скажете ему сворачиваться.

Крючок для обоих — srv.RegisterOnShutdown(f): каждая зарегистрированная функция запускается (в своей горутине), когда начинается Shutdown. Используйте его, чтобы разослать «закрываемся» websocket-сессиям, отменить контекст стримов и дать им мгновение на close frame:

srv.RegisterOnShutdown(func() {
	hub.CloseAll(1 * time.Second) // close frames веб-сокетам, затем сброс
	streamCancel()                // циклы SSE делают select на этом ctx и выходят
})

Без этого деплой либо рвёт каждый веб-сокет на полукадре (клиенты видят abnormal closure, дальше шторм реконнектов), либо подвешивает дренаж, пока SIGKILL не сделает разрыв за вас — и тогда умирают и HTTP-запросы в полёте, ради которых Shutdown и затевался.

Викторина

У сервиса 2 000 открытых веб-сокетов, на деплое вызывается srv.Shutdown с 30-секундным контекстом. Что произойдёт с веб-сокетами?

Kubernetes: сначала остановить трафик, потом сервер

В кластере Shutdown — второй шаг танца, и исполнение его первым даёт 502-е из приёма-крючка. Когда под входит в Terminating, две вещи происходят параллельно: kubelet шлёт SIGTERM (после preStop), а контроллер эндпоинтов начинает удалять под из Endpoints — но ingress-ы и правила kube-proxy сходятся с лагом в секунды. Закройте листенер в этот лаг — и отфутболите живой трафик. Надёжная последовательность:

  • Сразу завалить readiness по SIGTERM (перевернуть флаг, который читает ваш хендлер /readyz). Readiness управляет членством в балансировке; liveness — нет: проваленный liveness просто перезапустит вас.
  • Переждать распространениеpreStop-хук со sleep 5 (или сон в процессе перед Shutdown) покрывает типичную сходимость эндпоинтов; кластерам с медленными ingress-контроллерами нужно больше.
  • Затем Shutdown(ctx) с дедлайном, влезающим в terminationGracePeriodSeconds, с запасом: грейс 30 с, preStop 5 с, дренаж ≤ 20 с, буфер 5 с.

Числа, которые стоит помнить: SIGKILL безусловен в конце льготного периода; отказ новым соединениям во время распространения виден как 502/503 на ingress в объёме примерно (лаг распространения × частота запросов) на деплой — при 1 000 rps и 3 секундах лага это ~3 000 упавших запросов на под, ровно разница между «деплоя никто не заметил» и постмортемом.

Почему это работает

Почему Kubernetes просто не удалит под из Endpoints сначала, а сигнал пошлёт потом? Потому что control plane эвентуально согласован по дизайну — контроллер эндпоинтов, kube-proxy на каждом узле и внешние ingress-ы наблюдают и сходятся независимо, без транзакции поверх них. Поду велят завершаться одновременно с распространением удаления. SIGTERM поэтому означает не «трафика больше не будет», а «трафик скоро прекратится, готовься». Каждый корректный сервер в k8s поглощает этот зазор явно — снятый readiness плюс сон на распространение — или платит за него 502-ми на каждом деплое.

Вспомните перед уходом
  1. 01
    Разбери точную механику srv.Shutdown(ctx): что закрывается, что ждёт, что возвращает каждая сторона и что остаётся за бортом.
  2. 02
    Почему хрестоматийная реализация Shutdown всё равно роняет запросы на каждом деплое в Kubernetes, и какова правильная последовательность?
Итог

Graceful shutdown в Go — один метод с точной семантикой и двумя знаменитыми дырами. Shutdown(ctx) закрывает листенеры, пожинает простаивающие keep-alive-соединения и ждёт запросы в полёте, возвращая nil при полном дренаже или ctx.Err() на дедлайне с ещё живыми отставшими — srv.Close() это жёсткая остановка, а ListenAndServe отдаёт http.ErrServerClosed в момент начала процесса: ожидаемое значение для фильтрации, а не ошибка для пейджера. Сигнальная сторона — signal.NotifyContext: контекст, отменяемый на SIGTERM или SIGINT, со stop(), возвращающим дефолтную обработку, чтобы второй сигнал убивал зависший дренаж, а не оператора, который его ждёт. Дыра первая: hijacked-соединения. Каждый веб-сокет покинул учёт net/http в момент вызова Hijack, и Shutdown его не ждёт и не закрывает — RegisterOnShutdown это место, где вы рассылаете close frames и отменяете SSE-циклы, иначе деплои заканчиваются штормами реконнектов и SIGKILL делает протокольный teardown за вас. Дыра вторая: кластер. SIGTERM не означает, что трафик прекратился; удаление из эндпоинтов распространяется через kube-proxy и ingress-ы секундами, пока ваш под уже получает сигнал. Сначала завалить readiness, переждать распространение в preStop, потом дренировать — всё в бюджете terminationGracePeriodSeconds, потому что SIGKILL приходит по расписанию в любом случае. Теперь, когда видишь всплеск 502 на каждом деплое, задай два вопроса: сняли ли readiness до начала дренажа, и есть ли у веб-сокетов обработчик RegisterOnShutdown?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.