---
title: "Graceful shutdown: выключаемся вежливо"
description: "Graceful shutdown на go: srv.Shutdown и Signal.NotifyContext, таймаут выключения, порядок закрытия БД и воркеров, SIGHUP и SIGKILL — деплой без 502 и обрывов."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/16-graceful/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

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

*Курс «Chi: роутер для Go HTTP-сервисов», урок 16 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/16-graceful/*

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

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

- 503 Service Unavailable от вашего сервера
- ✅ Ошибку соединения (connection refused): listener закрыт, новые запросы не принимаются
- Запрос обработается как обычно: Shutdown ждёт только старые
- 502 Bad Gateway

Первый шаг Shutdown — закрыть все listeners: порт больше не слушается, ОС отвечает отказом. Старые idle-соединения закрываются следом, активные добиваются. Обещание graceful распространяется только на запросы, принятые до сигнала, — новый клиент попадает на ещё не поднявшийся процесс или на балансировщик, который снимет под с ротации.

### Что напечатает main?

```go
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))
```

**Что выведет программа?**

- nil: Shutdown дождался отчёта и вернулся без ошибки
- ✅ context deadline exceeded: таймаут вышел раньше отчёта, main завершится и соединение оборвётся
- http.ErrServerClosed: Shutdown сообщает о закрытии listener
- Клиент получит 504 Gateway Timeout от сервера

Shutdown ждёт активные соединения только в пределах переданного контекста: пять секунд меньше десяти — вернёт ctx.Err(). Активные соединения он сам не рвёт, но после возврата main завершается, процесс умирает, отчёт обрывается на середине. Отсюда правило: таймаут shutdown берётся по самым долгим легитимным запросам, а долгие задачи уходят в фон.

### Порядок выключения: сперва HTTP, потом фон

Shutdown закрывает только HTTP-слой, а у сервиса есть и другие ресурсы: пул соединений с БД, воркеры, очереди, файловые буферы. Порядок здесь диктуется зависимостями: пока Shutdown не вернулся, хендлеры ещё пишут в базу — закрывать пул до этого значит превратить последние легитимные запросы в 500, то есть воспроизвести проблему, ради которой всё затевалось. Рабочая последовательность: сигнал → srv.Shutdown(ctx) → остановка воркеров → db.Close(). Воркеры — горутины, которые Shutdown не ждёт: они не связаны с соединениями, и сервер о них не знает. Их останавливает main: канал задач закрывается, WaitGroup дожидается хвостов, и только после этого освобождается БД. Контекст приложения из NotifyContext удобен как общий выключатель: воркеры следят за ctx.Done() в select — тот же приём, что и в отмене контекста из [урока продвинутого курса](/courses/go-advanced/lessons/09-context/). Для библиотек, которым нужен хук в момент выключения, у 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 активных запросов. Что будет?

- Ничего: Shutdown дожидается всех соединений и БД тоже
- ✅ Хендлеры начнут получать ошибки от БД и отвечать 500 — ровно то, что graceful должен был предотвратить
- Shutdown упадёт с паникой: закрытый пул нельзя передавать
- Соединения с БД восстановятся автоматически по мере запросов

Shutdown закрывает только HTTP: listener и соединения. О фоновых ресурсах он не знает и знать не может. Пул БД закрывается после возврата Shutdown: сперва перестают приходить новые запросы и добиваются текущие, потом освобождаются ресурсы, которыми они пользовались.

### Сигналы: кто посылает и что делаем

| Сигнал | Кто посылает | Что делаем |
| --- | --- | --- |
| 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 при старте.

> **SIGKILL: таймаут не спасёт.** 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-проба перестаёт отвечать, — затем слать сигнал, чтобы «новые запросы не принимались» совпало с реальностью. Порядок из [прошлого урока](/courses/chi/lessons/15-error-handling/) — единый формат ошибок — пригодится и здесь: отказ в период выключения тоже должен быть 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.

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

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

```go
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)
```

Подсказка: Сигналы перехватывает signal.NotifyContext, пробуждение main — чтение из stop.Done(), а останавливает сервер srv.Shutdown.

Правильные ответы в пропусках: `NotifyContext`, `Done`, `Shutdown`

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

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

Правильный порядок строк:

1. `srv := &http.Server{Addr: ":8080", Handler: r}`
2. `go func() { _ = srv.ListenAndServe() }()`
3. `stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)`
4. `<-stop.Done()`
5. `ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)`
6. `_ = srv.Shutdown(ctx)`

### Карточки: graceful shutdown

- **srv.Shutdown(ctx)** — Закрывает listeners, затем idle-соединения, ждёт активные до истечения ctx; по таймауту возвращает ctx.Err() и сам соединения не рвёт. ListenAndServe сразу вернёт ErrServerClosed.
- **Signal.NotifyContext** — Контекст, чей Done закрывается по приходу сигнала (os.Interrupt, syscall.SIGTERM) или вызову stop(). Идиома со времён Go 1.16 вместо канала signal.Notify.
- **Таймаут shutdown 5–30 с** — Берётся по самым долгим легитимным запросам и согласуется с docker stop (10 с) и terminationGracePeriodSeconds kubernetes (30 с по умолчанию). После истечения — srv.Close().
- **SIGKILL** — kill -9 и kubernetes после grace period. Не перехватывается: defer, recover и Shutdown не выполняются. Защита — уложиться в grace period и ретраи на клиенте.
- **SIGHUP** — Классика демонов: «перечитай конфиг» (nginx reload). Без обработчика Go завершает процесс — не путайте с graceful выключением.
- **Порядок выключения** — Listener → активные хендлеры (Shutdown) → фоновые задачи (wg.Wait) → БД (db.Close). Ресурс освобождает тот, кто им больше не пользуется.

Сервис выключается вежливо: сигнал → закрытый listener → добитые хендлеры → закрытые воркеры и БД. Двадцать строк в main превращают деплой из лотереи в процедуру, а 502 на пятничном выкате перестают быть традицией. Проверьте протокол локальным Go на живом сервере: длинный запрос, kill -TERM — и ответ доходит до клиента, а лог main показывает таймлайн выключения. Дальше по курсу — проверка всего этого тестами и вынос за reverse-прокси. Полная карта уроков — на [странице курса chi](/courses/chi/), а как горутины и WaitGroup ведут себя в фоне — подробнее в [уроке про горутины](/courses/go-advanced/lessons/04-gorutiny/).

## Коротко: что запомнить

- 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.
