Обработка сигналов в Go: разбор graceful shutdown под нагрузкой
Сервис на Go после деплоя обрывал висящие HTTP-запросы и терял записи в БД. Причина — неверная обработка SIGTERM. Разбираем, что происходит внутри и как закрыть соединения без потерь.

Оркестратор шлёт вашему поду SIGTERM, ждёт 30 секунд и добивает процесс через SIGKILL. Если сервис на Go не подписан на сигнал или закрывает ресурсы в неправильном порядке, вы получаете оборванные HTTP-ответы, недописанные транзакции и записи в логах вида context canceled без всякой причины. Разберём на примере HTTP-сервиса с пулом к PostgreSQL и фоновым воркером, как сигнал доходит до процесса, что закрывать в каком порядке и где типовая реализация graceful shutdown ломается под нагрузкой.
Всё ниже — для Go 1.22+. Ключевая функция signal.NotifyContext доступна с Go 1.16, до неё писали через отдельный канал и signal.Notify.
Что происходит, когда приходит сигнал
По умолчанию Go-рантайм на SIGTERM и SIGINT завершает процесс сразу — как будто вызвали os.Exit. Никакие defer не отрабатывают, открытые соединения рвутся на уровне ядра. Чтобы перехватить сигнал, процесс должен явно подписаться на него.
Пакет os/signal регистрирует обработчик и перенаправляет сигнал в Go-канал или отменяет контекст. После этого рантайм уже не убивает процесс — решение за вашим кодом. Отсюда две ошибки-близнеца: забыть подписаться (сигнал убивает процесс мгновенно) и подписаться, но не завершить работу (процесс висит, пока оркестратор не пришлёт SIGKILL).
SIGKILL и SIGSTOP перехватить нельзя ни в одном языке — это гарантия ядра. Всё, что вы успеете сделать, должно уложиться в окно между SIGTERM и последующим SIGKILL.
Базовый каркас через NotifyContext
Самый чистый способ в современном Go — привязать сигнал к контексту. Отмена контекста становится единой точкой, от которой раскручивается остановка всех подсистем.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
// Контекст отменяется при SIGINT или SIGTERM
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: newHandler()}
go func() {
// ListenAndServe вернёт ErrServerClosed после Shutdown — это норма
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
// Блокируемся, пока не придёт сигнал
<-ctx.Done()
log.Println("получен сигнал, начинаем shutdown")
// Отдельный дедлайн на слив соединений, не привязанный к ctx,
// потому что ctx уже отменён
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown не завершён: %v", err)
}
log.Println("сервер остановлен")
}Тонкость, которую пропускают чаще всего: srv.Shutdown нельзя передавать тот же ctx, что отменился по сигналу. Он уже в состоянии Done, и Shutdown вернётся мгновенно, не дожидаясь ни одного запроса. Нужен свежий контекст с собственным таймаутом.
Порядок закрытия ресурсов — где рвутся данные
Реальный сервис — это не только HTTP-сервер. Есть пул к БД, есть фоновые воркеры, есть, возможно, потребитель очереди. Порядок остановки не произвольный: если закрыть пул БД раньше, чем сервер дослал ответы, висящие запросы упадут с ошибкой соединения.
Правильная последовательность выглядит так:
- Перестать принимать новые запросы —
srv.Shutdownзакрывает слушающий сокет сразу, но держит открытыми уже установленные соединения. - Дождаться, пока
Shutdownобслужит запросы в работе или упрётся в таймаут. - Остановить фоновые воркеры — отменить их контекст и дождаться
wg.Wait(). - Только теперь закрыть пул БД, соединения с брокером и прочие внешние ресурсы.
Если поменять шаги 2 и 4 местами, запрос, который был в середине транзакции, получит закрытый пул и не сможет сделать COMMIT. Пользователь увидит 500, а в БД останется откат.
Координация воркеров через WaitGroup
func runWorker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan Job) {
defer wg.Done()
for {
select {
case <-ctx.Done():
// Дорабатываем то, что уже взяли, и выходим
return
case job, ok := <-jobs:
if !ok {
return
}
process(job)
}
}
}При остановке отменяете общий воркер-контекст, затем wg.Wait() с собственным таймаутом. Задача на select без учёта ctx.Done() в теле длинной обработки — частая причина того, что воркер игнорирует сигнал и висит до SIGKILL.
Где это ломается под нагрузкой
Каркас из первого примера работает на демо и падает на реальном трафике. Вот граничные случаи, которые проявляются только под нагрузкой.
| Ситуация | Что происходит | Что делать |
|---|---|---|
| Долгие запросы дольше таймаута Shutdown | Shutdown упирается в дедлайн, соединения рвутся принудительно | Таймаут Shutdown меньше окна оркестратора (например, 25 с при 30 с у Kubernetes), запрос должен уметь прерываться по своему контексту |
| Keep-alive соединения | Клиент держит соединение, Shutdown ждёт его закрытия и упирается в таймаут | Shutdown сам вызывает SetKeepAlivesDisabled, но клиент увидит закрытие только на следующем запросе |
| WebSocket и SSE (hijacked-соединения) | Shutdown их не отслеживает и не закрывает | Закрывать вручную через RegisterOnShutdown или трекать свои долгоживущие соединения |
| Балансировщик ещё шлёт трафик | Сокет закрыт, но LB не узнал — новые запросы получают отказ соединения | Пауза между провалом readiness-пробы и Shutdown: несколько секунд на депропагацию |
Проблема readiness перед shutdown
Самая коварная строка в таблице — балансировщик. В Kubernetes при удалении пода два процесса идут параллельно: kubelet шлёт SIGTERM, а endpoints-контроллер убирает под из сервиса. Они не синхронизированы. Если ваш процесс закрыл сокет мгновенно по сигналу, часть трафика от ещё не обновившегося балансировщика придёт в уже мёртвый порт.
Практика: по сигналу сначала переключить readiness-пробу в состояние not ready, подождать 3–5 секунд (эмпирически — по времени депропагации в вашем кластере) и только потом звать srv.Shutdown. Эти секунды закладываются внутрь окна terminationGracePeriodSeconds.
Как это измерить
Готовых цифр здесь быть не может — они зависят от вашего профиля запросов и железа. Что мерить, чтобы понять, работает ли graceful shutdown:
- Число 5xx во время rolling-деплоя. Правильная реализация даёт ноль оборванных ответов на клиенте. Смотрите метрики балансировщика, а не только сервиса — сервис уже мёртв, когда рвётся соединение.
- Время от
SIGTERMдо фактического выхода процесса. Должно быть меньшеterminationGracePeriodSecondsс запасом. Логируйте метку времени на входе в shutdown и на выходе. - Число откатов транзакций во время деплоя — резкий всплеск говорит, что пул БД закрывается раньше, чем дорабатывают запросы.
Для проверки локально удобно послать сигнал вручную во время нагрузочного теста: запустите hey или vegeta на сервис и в середине прогона отправьте kill -TERM $(pgrep ваш_бинарь). Считайте ошибки на стороне генератора нагрузки.
Что настроить дальше
Соберите остановку всех подсистем в одном месте — оркестратор вроде errgroup.Group с общим контекстом читается лучше, чем разрозненные go func и каналы. Проверьте согласованность двух чисел: таймаут вашего Shutdown должен быть строго меньше terminationGracePeriodSeconds в манифесте пода, иначе оркестратор убьёт процесс до завершения слива. И заведите отдельный дашборд на 5xx именно в окне деплоя — обычные алерты по средним значениям всплеск в 5 секунд не поймают.
Инвалидация in-flight-запросов на уровне БД — отдельная тема; здесь разбирали только порядок закрытия ресурсов приложения.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Go: пакет os/signal — официальная документация, Go: http.Server.Shutdown — документация net/http
Частые вопросы
А если у меня Go старше 1.16 и нет signal.NotifyContext?
Почему srv.Shutdown возвращается сразу, а соединения всё равно рвутся?
Shutdown не закрывает мои WebSocket-соединения. Это баг?
Какой таймаут ставить на Shutdown?
Во время деплоя всё равно ловлю единичные 5xx, хотя shutdown вроде корректный. В чём дело?
Нужно ли вообще обрабатывать SIGKILL?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.