Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~18 мин +50 XP

Graceful shutdown: выключаемся вежливо

Graceful shutdown в go — договор о выключении: по сигналу сервер перестаёт принимать новые запросы, дожидается текущих и только потом освобождает БД и воркеров. Разберём srv.Shutdown, Signal.NotifyContext, таймаут выключения, порядок закрытия фоновых задач и связку chi graceful с сигналами — без 502 на очередном деплое.

Содержание урока · 10 разделов

Деплой в пятницу вечером шёл по сценарию: сборка собралась, 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

GO
func 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
SIGTERMkill, docker stop, kubernetes delete podзапускаем graceful shutdown
SIGHUPзакрытие терминала; классика «перечитай конфиг» (nginx reload)свой обработчик: перечитать конфиг или выключиться
SIGQUITkill -QUIT или Ctrl+\Go runtime печатает дамп всех горутин и завершает процесс
SIGKILLkubernetes после grace period, kill -9, OOM killerперехватить нельзя: graceful не успеет — спасают ретраи и запас grace period
Сигнал без обработчикалюбой непойманныйGo завершает процесс немедленно, активные ответы рвутся

SIGHUP и горячая перезагрузка конфига#

Не всякий сигнал — повод умирать. SIGHUP исторически означал «терминал отвалился», но демоны давно переосмыслили его как «перечитай конфиг»: nginx по SIGHUP перечитывает настройки, не разрывая соединений. В Go без обработчика SIGHUP завершает процесс — если сервис живёт под systemd или в контейнере, решите заранее, что делает ваш SIGHUP: то же выключение, перечитывание конфига или ничего. С сигнальной перезагрузкой соседствует файловая: viper из курса по конфигам умеет WatchConfig и OnConfigChange — вотчер следит за файлом настроек и перечитывает его без участия сигналов. Граница простая: сигнал — внешний ручной триггер, файловый вотчер — автоматика на изменение; оба не требуют рестарта процесса, и graceful shutdown остаётся для настоящих перезапусков — смены бинарника, а не настроек. Если конфиг меняется горячо, проверьте, что зависимости — пул, лимиты, таймауты — тоже перечитываются, а не считываются один раз в main при старте.

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

GO
r := 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.

Соберите протокол выключения

Восстановите три ключевых вызова: перехват сигнала, ожидание и остановку сервера.

srv := &http.Server{Addr: ":8080", Handler: r} go func() { _ = srv.ListenAndServe() }() stop := signal.(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() <-stop.() err := srv.(ctx)

Соберите последовательность main

Расставьте строки main в порядке вежливого выключения: от запуска до остановки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: graceful shutdown

Термин

srv.Shutdown(ctx)

нажмите, чтобы перевернуть
Определение

Закрывает listeners, затем idle-соединения, ждёт активные до истечения ctx; по таймауту возвращает ctx.Err() и сам соединения не рвёт. ListenAndServe сразу вернёт ErrServerClosed.

1 / 6

Сервис выключается вежливо: сигнал → закрытый 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 — ответ должен дойти целиком, а лог показать сигнал и обе фазы выключения.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Graceful shutdown: выключаемся вежливо)

Вопросы и ответы по уроку

Сколько секунд ставить в 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.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Redis25 уроков~12 чпрактика25 уроков уже доступны

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter