Содержание урока · 10 разделов
- Почему kill обрывает запросы: graceful shutdown в go
- Механика Shutdown: listener, idle-соединения, таймаут
- Порядок выключения: сперва HTTP, потом фон
- Сигналы: кто посылает и что делаем
- SIGHUP и горячая перезагрузка конфига
- chi graceful: выключается сервер, а не роутер
- Чек-лист вежливого выключения
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Деплой в пятницу вечером шёл по сценарию: сборка собралась, CI зелёный, в 19:04 деплой-скрипт делает kill старого процесса и поднимает новый. Три секунды — а на мониторинге всплеск 502 Bad Gateway у половины активных пользователей: reverse-прокси передал запрос умирающему процессу, тот умер, не ответив, прокси вернул 502. Кому-то пропали черновики, у кого-то — отправленная форма: запросы, принятые до kill и не успевшие дойти до ответа, погибли вместе с процессом. Классическая ошибка выключения: net/http без подготовки завершается по сигналу немедленно — все обрабатываемые хендлеры обрываются на середине, и ни один 502 не виноват сам по себе. Лекарство называется graceful shutdown: сервер по сигналу перестаёт принимать новые соединения, дожидается текущих ответов и только потом отпускает ресурсы. В go это двадцать строк в main — и деплой перестаёт быть лотереей с пятничным розыгрышем.
Почему kill обрывает запросы: graceful shutdown в go#
Сигнал без обработчика в Go действует жёстко: процесс завершается немедленно, defer не выполняется, соединения закрываются ОС-ом посреди ответов. Первая половина решения — перестать пользоваться обёрткой http.ListenAndServe: она удобна, но не даёт доступа к самому серверу, а выключать нужно именно его. Вместо неё собирается объект http.Server с Addr и Handler — chi-роутер здесь обычный http.Handler — и запускается в горутине. Вторая половина — перехват сигналов: signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) возвращает контекст, чей Done закрывается по Ctrl+C (SIGINT) или kill (SIGTERM). main засыпает на <-stop.Done(), просыпается по сигналу и вызывает srv.Shutdown(ctx). С этого момента и до возврата Shutdown процесс обязан жить: документация net/http прямо предупреждает, что ListenAndServe сразу вернёт ErrServerClosed, а программа не должна завершаться, не дождавшись Shutdown. Это и есть graceful shutdown в go — не библиотека, а протокол из четырёх шагов в main.
Минимальный main с Shutdown
GOfunc main() {
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// Ctrl+C и kill — оба сигнала закрывают stop.Done()
stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-stop.Done()
log.Print("сигнал получен: новые запросы не принимаются")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown: %v — закрываем принудительно", err)
_ = srv.Close()
}
}Роутер вынесен в newRouter() — там chi.NewRouter со стеком middleware из прошлых уроков. ListenAndServe в горутине: после вызова Shutdown он вернёт ErrServerClosed, и горутина тихо завершится. Fatalf ловит только настоящие ошибки старта — например, занятый порт. Таймаут десять секунд ограничивает терпение Shutdown; srv.Close() после истечения — принудительная точка, рвущая остатки соединений.
Механика Shutdown: listener, idle-соединения, таймаут#
Shutdown описан в документации net/http как трёхшаговая процедура, и знание шагов отвечает сразу на несколько «а что если». Шаг первый: закрываются все открытые listeners — порт перестаёт слушаться, новые соединения получают отказ на уровне ОС. Шаг второй: закрываются idle-соединения — те, что держат keep-alive, но не обрабатывают запрос. Шаг третий: Shutdown ждёт, пока активные соединения — хендлеры в работе — вернутся в idle, и только потом завершается. Ждёт он ровно до истечения переданного контекста: таймаут пять-тридцать секунд задаётся context.WithTimeout, и defer cancel() рядом обязателен. Если контекст истёк раньше, Shutdown возвращает ctx.Err() — context.DeadlineExceeded — но активные соединения сам не рвёт: это делает либо srv.Close(), либо завершение main. Отдельная оговорка доков: hijacked-соединения — websockets, long-poll — Shutdown не ждёт вообще, их закрывает ваш код. И главное: graceful касается только HTTP — фоновые горутины он не видит, о них следующий блок.
Проверка: новое соединение во время Shutdown
Shutdown начал работу: listener закрыт. Новый клиент открыл соединение и отправил запрос. Что получит клиент?
Что напечатает main?
Прочитайте код и выберите, что он выведет:
srv := &http.Server{Addr: ":8080", Handler: r}
go func() { _ = srv.ListenAndServe() }()
// хендлер /report готовит ответ 10 секунд:
r.Get("/report", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second)
w.Write([]byte("готово"))
})
// в этот момент приходит SIGTERM и запускается:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
fmt.Println(srv.Shutdown(ctx))Порядок выключения: сперва HTTP, потом фон#
Shutdown закрывает только HTTP-слой, а у сервиса есть и другие ресурсы: пул соединений с БД, воркеры, очереди, файловые буферы. Порядок здесь диктуется зависимостями: пока Shutdown не вернулся, хендлеры ещё пишут в базу — закрывать пул до этого значит превратить последние легитимные запросы в 500, то есть воспроизвести проблему, ради которой всё затевалось. Рабочая последовательность: сигнал → srv.Shutdown(ctx) → остановка воркеров → db.Close(). Воркеры — горутины, которые Shutdown не ждёт: они не связаны с соединениями, и сервер о них не знает. Их останавливает main: канал задач закрывается, WaitGroup дожидается хвостов, и только после этого освобождается БД. Контекст приложения из NotifyContext удобен как общий выключатель: воркеры следят за ctx.Done() в select — тот же приём, что и в отмене контекста из урока продвинутого курса. Для библиотек, которым нужен хук в момент выключения, у http.Server есть RegisterOnShutdown — функция вызывается при старте Shutdown.
Воркеры и БД: закрываем после Shutdown
GO// воркер-пул: забирает задачи, пока жив контекст
func runWorkers(ctx context.Context, tasks <-chan task) *sync.WaitGroup {
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // сигнал выключения
case t, ok := <-tasks:
if !ok {
return // канал закрыт — задач больше нет
}
process(t)
}
}
}()
}
return &wg
}
// последовательность main после <-stop.Done():
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx) // HTTP: новые отклонены, текущие добиты
close(tasks) // новые задачи больше не принимаются
wg.Wait() // воркеры доделали хвосты
_ = db.Close() // и только теперь пул БДselect с ctx.Done() и чтением канала — типовой паттерн останавливаемого воркера: сигнал и задачи обрабатываются в одной точке. close(tasks) после Shutdown — сигнал «потока задач больше не будет»; чтение через ok увидит закрытие. wg.Wait гарантирует, что последняя обработанная задача дописала свои данные до db.Close(). Направление освобождения одно: сначала тот, кто пользуется, потом ресурс.
Проверка: порядок закрытия ресурсов
В main сначала закрыли пул БД, затем вызвали srv.Shutdown. Хендлеры ещё отвечают на 200 активных запросов. Что будет?
Сигналы: кто посылает и что делаем#
| Сигнал | Кто посылает | Что делаем |
|---|---|---|
| SIGINT | человек: Ctrl+C в терминале | запускаем graceful shutdown |
| SIGTERM | kill, docker stop, kubernetes delete pod | запускаем graceful shutdown |
| SIGHUP | закрытие терминала; классика «перечитай конфиг» (nginx reload) | свой обработчик: перечитать конфиг или выключиться |
| SIGQUIT | kill -QUIT или Ctrl+\ | Go runtime печатает дамп всех горутин и завершает процесс |
| SIGKILL | kubernetes после grace period, kill -9, OOM killer | перехватить нельзя: graceful не успеет — спасают ретраи и запас grace period |
| Сигнал без обработчика | любой непойманный | Go завершает процесс немедленно, активные ответы рвутся |
SIGHUP и горячая перезагрузка конфига#
Не всякий сигнал — повод умирать. SIGHUP исторически означал «терминал отвалился», но демоны давно переосмыслили его как «перечитай конфиг»: nginx по SIGHUP перечитывает настройки, не разрывая соединений. В Go без обработчика SIGHUP завершает процесс — если сервис живёт под systemd или в контейнере, решите заранее, что делает ваш SIGHUP: то же выключение, перечитывание конфига или ничего. С сигнальной перезагрузкой соседствует файловая: viper из курса по конфигам умеет WatchConfig и OnConfigChange — вотчер следит за файлом настроек и перечитывает его без участия сигналов. Граница простая: сигнал — внешний ручной триггер, файловый вотчер — автоматика на изменение; оба не требуют рестарта процесса, и graceful shutdown остаётся для настоящих перезапусков — смены бинарника, а не настроек. Если конфиг меняется горячо, проверьте, что зависимости — пул, лимиты, таймауты — тоже перечитываются, а не считываются один раз в main при старте.
Graceful shutdown работает, пока сигнал доходит. Kubernetes давит по grace period: terminationGracePeriodSeconds — 30 секунд по умолчанию — после них идёт SIGKILL, который не перехватывается принципиально: ни defer, ни recover, ни Shutdown. Docker stop — десять секунд по умолчанию, дальше тоже SIGKILL. Если ваши отчёты живут дольше grace period, поднимайте лимит явно в спеке пода или укорачивайте длинные задачи. И закладывайте защиту на стороне клиента: идемпотентные повторные запросы и ретраи — единственное, что переживёт kill -9.
Сколько ждать после сигнала? Короткий таймаут — пять секунд — держит деплой резким: очередь не копится, автоскейлер не ждёт, но длинные отчёты и выгрузки обрываются ровно там, где graceful должен был их спасти. Длинный — тридцать-шестьдесят — бережёт долгие запросы, но замедляет деплой, а kubernetes всё равно убьёт под по своему grace period, если тот короче. Ориентир: таймаут shutdown по p99 времени ответа хендлеров, а действительно долгие работы — уходить в фон с сохранением прогресса. Оба лагеря правы ровно до появления в API первого запроса на две минуты.
chi graceful: выключается сервер, а не роутер#
В поиске гуляет запрос «chi graceful», и стоит разложить, что тут чьё: chi — только Handler, вся механика выключения живёт в net/http. Роутеру нечего останавливать — он просто перестаёт получать запросы, когда Shutdown закрыл listener. Настоящая связка chi с выключением — в дедлайнах: middleware.Timeout из урока про таймауты кладёт лимит в контекст каждого запроса, и хендлеры, следящие за r.Context().Done(), завершаются сами — без них один зависший long-poll растянет Shutdown на весь таймаут. Websockets и SSE chi не закрывает — hijacked-соединения Shutdown не ждёт: держите реестр активных подключений и закрывайте его в своей последовательности. Ещё одна деталь деплоя за балансировщиком: сначала снять под с балансировки — readiness-проба перестаёт отвечать, — затем слать сигнал, чтобы «новые запросы не принимались» совпало с реальностью. Порядок из прошлого урока — единый формат ошибок — пригодится и здесь: отказ в период выключения тоже должен быть JSON-ом, а не обрывом.
Каркас целиком: chi, стек, shutdown
GOr := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(errorHandler) // из прошлого урока: единые ошибки
r.Use(middleware.Logger)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/notes", listNotes)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
}
stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-stop.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
_ = srv.Close()
}
workers.Stop() // wg.Wait внутри
_ = db.Close()Финальный каркас собирает курс в один main: боевой стек middleware, дедлайны Timeout, health-check для балансировщика и протокол выключения. healthz отвечает и в момент выключения — пока listener не закрыт; после снятия с балансировки поток новых проб иссякнет сам. Проверьте выключение локальным Go: запустите, отправьте долгий запрос и kill -TERM — ответ должен дойти, а лог показать обе строки: про сигнал и про остановку.
Чек-лист вежливого выключения#
- http.Server вместо ListenAndServe: объект с Addr и Handler, запуск ListenAndServe в горутине.
- signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) — stop.Done() как точка пробуждения main.
- srv.Shutdown с context.WithTimeout на 5–30 секунд и defer cancel(); после истечения — srv.Close().
- Фоновое закрытие после Shutdown: close(tasks), wg.Wait(), db.Close() — сначала тот, кто пользуется, потом ресурс.
- Дедлайны Timeout в стеке и реестр websockets: долгие соединения не должны управлять длительностью Shutdown.
- Grace period инфраструктуры согласован с таймаутом: docker stop — 10 с, kubernetes — 30 с по умолчанию; дальше SIGKILL.
Соберите протокол выключения
Восстановите три ключевых вызова: перехват сигнала, ожидание и остановку сервера.
Соберите последовательность main
Расставьте строки main в порядке вежливого выключения: от запуска до остановки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: graceful shutdown
Сервис выключается вежливо: сигнал → закрытый listener → добитые хендлеры → закрытые воркеры и БД. Двадцать строк в main превращают деплой из лотереи в процедуру, а 502 на пятничном выкате перестают быть традицией. Проверьте протокол локальным Go на живом сервере: длинный запрос, kill -TERM — и ответ доходит до клиента, а лог main показывает таймлайн выключения. Дальше по курсу — проверка всего этого тестами и вынос за reverse-прокси. Полная карта уроков — на странице курса chi, а как горутины и WaitGroup ведут себя в фоне — подробнее в уроке про горутины.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Graceful shutdown: сигнал → закрыть listener → дожидаться активных → закрыть фон; без него kill рвёт ответы на середине.
- http.Server вместо ListenAndServe: у объекта сервера есть Shutdown; chi — лишь Handler, выключение — работа net/http.
- Signal.NotifyContext с os.Interrupt и syscall.SIGTERM: Done закрывается по сигналу, stop() возвращает обычное поведение.
- Таймаут Shutdown 5–30 с: по истечении — ctx.Err(); длинные хендлеры и hijacked-соединения Shutdown не ждёт.
- БД, воркеры и буферы закрываются после srv.Shutdown: ресурс освобождает тот, кто им больше не пользуется.
- SIGHUP — сигнал перечитать конфиг, а не завершиться; viper WatchConfig реагирует на файл, сигналами не пользуется.
- SIGKILL после grace period (docker 10 с, kubernetes 30 с) обходит весь graceful — спасают ретраи и запас на таймаут.
Домашнее задание
Соберите каркас урока: chi-роутер со стеком из прошлого урока, http.Server, NotifyContext и Shutdown с таймаутом десять секунд, воркер-горутина и заглушка БД, закрываемые после Shutdown. Проверьте на своей машине: долгий запрос, затем kill -TERM — ответ должен дойти целиком, а лог показать сигнал и обе фазы выключения.
Личные заметкиПусто
Вопросы и ответы по уроку
Сколько секунд ставить в Shutdown?
По самым долгим легитимным запросам: пять секунд для чистого CRUD, тридцать — для сервиса с отчётами. И всегда не больше grace period инфраструктуры: docker stop держит десять секунд, kubernetes — тридцать по умолчанию. Таймаут больше grace period бессмысленен: SIGKILL всё равно завершит под раньше.
Shutdown дожидается websocket-соединений?
Нет: hijacked-соединения — websockets, SSE, long-poll — Shutdown не ждёт и не закрывает. Держите реестр активных подключений, рассылайте по нему close при получении сигнала и дожидайтесь своим WaitGroup. Middleware.Timeout на такие маршруты тоже не действует — дедлайн в контексте hijacked не останавливает.
Зачем NotifyContext, если есть signal.Notify?
Notify отдаёт сырой канал, который приходится городить с select-ами по всему main. NotifyContext возвращает обычный контекст: Done встраивается в существующие select, stop() возвращает сигналам стандартное поведение, а отмена родителя автоматически доезжает до наследников. Это идиома со времён Go 1.16.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Валидация параметров и тел запросов
- Порядок middleware: почему он важен
- Subrouters и структура большого сервиса
- Тестирование хендлеров: httptest и chi
- Централизованные ошибки: errorHandler и NotFound
- CORS: чиним браузерные запросы
- Timeout и Throttle: защита от перегрузки
- Собственный middleware: таймер, лог, авторизация
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter