Грациозная остановка в Go: сравнение подходов
SIGTERM пришёл, а сервис оборвал соединения на середине запроса и потерял три платежа. Разбираем, как останавливать Go-сервис так, чтобы он дожил незавершённые запросы и закрыл ресурсы в правильном порядке.

Kubernetes посылает поду SIGTERM и даёт по умолчанию 30 секунд до SIGKILL. Если ваш Go-сервис в этот момент просто вызывает os.Exit или падает по завершению main, вы обрываете открытые HTTP-соединения, теряете сообщения из очереди в обработке и оставляете транзакции в базе незакоммиченными. Ниже — три подхода к грациозной остановке на Go 1.22+, с ценой каждого и понятным критерием выбора.
Задача и почему наивный вариант ломается
Грациозная остановка — это последовательность: перестать принимать новую работу, дождаться завершения текущей в разумный срок, закрыть внешние ресурсы, выйти с кодом 0. Наивный код выглядит так:
package main
import "net/http"
func main() {
http.ListenAndServe(":8080", handler())
}
Проблема: ListenAndServe блокируется до фатальной ошибки и никак не реагирует на сигнал. Когда оркестратор шлёт SIGTERM, рантайм Go выполняет действие по умолчанию — завершает процесс немедленно. Запрос, который в этот момент обрабатывался 200 мс, обрывается на середине. Клиент получает разрыв соединения, а не ответ.
Три подхода ниже отличаются тем, как они ловят сигнал и как координируют остановку между несколькими подсистемами (HTTP-сервер, воркеры, потребитель очереди).
Что такое SIGTERM и SIGINT
SIGTERM — сигнал «заверши работу штатно», который посылают kill и оркестраторы. SIGINT — то же самое от Ctrl+C в терминале. Оба можно перехватить и обработать. SIGKILL перехватить нельзя: процесс убивается принудительно, поэтому весь порядок остановки должен уложиться в отведённое до него время.
Подход 1: signal.Notify и Shutdown
Классика для одиночного HTTP-сервера. Вы создаёте канал, подписываете его на сигналы через signal.Notify, а в отдельной горутине ждёте сигнал и вызываете http.Server.Shutdown.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler()}
// Канал для сигналов остановки
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
log.Println("сервер запущен на :8080")
<-stop // блокируемся до сигнала
log.Println("получен сигнал, останавливаемся")
// Даём 10 секунд на завершение активных запросов
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("принудительная остановка: %v", err)
}
log.Println("сервер остановлен")
}
Shutdown перестаёт принимать новые соединения, но ждёт, пока завершатся активные запросы, — до истечения контекста. Если контекст истёк, Shutdown вернёт ошибку, а недоработанные соединения будут закрыты.
Цена. Просто и надёжно для одного сервера. Но если у вас, кроме HTTP, крутится потребитель Kafka и пул фоновых воркеров, координировать их вручную через один канал становится громоздко: порядок остановки размазывается по коду.
Подход 2: signal.NotifyContext
Начиная с Go 1.16 в стандартной библиотеке есть signal.NotifyContext. Он возвращает контекст, который отменяется при получении сигнала. Это убирает ручной канал и делает сигнал частью привычного механизма отмены через context.Context.
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: handler()}
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // ждём отмены контекста сигналом
log.Println("получен сигнал, останавливаемся")
// Новый контекст для таймаута остановки:
// ctx уже отменён, его нельзя использовать для Shutdown
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("принудительная остановка: %v", err)
}
}
Ключевой момент, на котором ошибаются: контекст от NotifyContext уже отменён к моменту, когда вы дошли до Shutdown. Передавать его в Shutdown нельзя — он завершится мгновенно с context deadline exceeded и оборвёт активные запросы. Нужен свежий контекст с собственным таймаутом.
Цена. Чуть чище первого варианта и естественно ложится в код, где отмена уже проброшена контекстом (например, в фоновые воркеры передаётся тот же ctx). Но координация нескольких подсистем и порядок их остановки всё ещё на вас.
Подход 3: errgroup для координации нескольких подсистем
Когда останавливать нужно не один сервер, а связку из HTTP, gRPC, потребителя очереди и фоновых задач, помогает golang.org/x/sync/errgroup. Группа с контекстом позволяет запустить подсистемы, дождаться сигнала и остановить их согласованно, а первая же ошибка отменит общий контекст.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: handler()}
g, gCtx := errgroup.WithContext(ctx)
// 1. Запуск HTTP-сервера
g.Go(func() error {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
// 2. Остановка при отмене контекста (сигнал или ошибка соседа)
g.Go(func() error {
<-gCtx.Done()
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
})
if err := g.Wait(); err != nil {
log.Printf("остановка с ошибкой: %v", err)
}
log.Println("все подсистемы остановлены")
}
errgroup.WithContext даёт gCtx, который отменяется в двух случаях: пришёл сигнал (через родительский ctx) или одна из горутин группы вернула ошибку. Остальные подсистемы добавляются такими же вызовами g.Go, каждая слушает gCtx.Done() и делает свою остановку.
Порядок остановки в errgroup не гарантирован: все горутины реагируют на отмену контекста одновременно. Если потребителю очереди важно остановиться строго до закрытия базы, порядок задают вручную — цепочкой контекстов или последовательными этапами, а не одной группой.
Цена. Добавляется внешняя зависимость и чуть больше кода, но координация нескольких компонентов становится читаемой, а падение любого из них штатно гасит остальные.
Сравнение подходов
| Критерий | signal.Notify | NotifyContext | errgroup |
|---|---|---|---|
| Зависимости | только stdlib | только stdlib | x/sync |
| Число подсистем | одна | одна-две | много |
| Отмена пробрасывается в контексте | нет | да | да |
| Гашение соседей при ошибке | вручную | вручную | автоматически |
| Управление порядком остановки | вручную | вручную | вручную |
Когда что выбирать
- Один HTTP-сервер, без фоновых задач — берите
signal.NotifyилиNotifyContext. Разница косметическая;NotifyContextпредпочтительнее, если контекст уже пробрасывается в обработчики. - HTTP плюс фоновые воркеры, которым нужен тот же сигнал отмены —
NotifyContext: передавайте одинctxи воркерам, и на завершение. - Несколько независимых серверов и потребителей —
errgroup. Он снимает ручную обвязку и корректно гасит всё при падении одного компонента.
Что ломается на практике
- Таймаут остановки больше окна оркестратора. Если вы дали
Shutdown30 секунд, а Kubernetes убивает под черезterminationGracePeriodSeconds: 20, часть запросов всё равно оборвётся поSIGKILL. Держите таймаут заведомо меньше окна. - Передача уже отменённого контекста в Shutdown. Самая частая ошибка с
NotifyContext:Shutdownполучает мёртвый контекст и завершается мгновенно. Всегда создавайте новый контекст с таймаутом. - Долгие запросы без учёта отмены.
Shutdownждёт завершения активных обработчиков, но не прерывает их. Если хендлер игнорируетr.Context()и висит минуту на внешнем вызове, он растянет остановку до таймаута. - Второй сигнал во время остановки. Если оператор нажал Ctrl+C дважды, вторым сигналом можно принудительно завершить процесс, не дожидаясь таймаута. С
NotifyContextпосле первого сигнала можно вызватьstop()и подписаться на сигнал заново для быстрого выхода. - Балансировщик ещё шлёт трафик. Между
SIGTERMи исключением пода из эндпоинтов есть лаг. Если начатьShutdownсразу, часть новых запросов упрётся в закрытый слушатель. Практика — короткая задержка передShutdownили readiness-проба, отдающая fail при получении сигнала.
Что делать дальше
Настройте terminationGracePeriodSeconds в манифесте пода и держите таймаут Shutdown на пару секунд меньше. Проверьте, что фоновые воркеры и потребители очередей действительно реагируют на отмену контекста, а не игнорируют его. Замерьте реальное время остановки под нагрузкой: снимите метрику от получения сигнала до выхода процесса и сравните с окном оркестратора — если они близки, часть запросов теряется. Инвалидация readiness-пробы при получении сигнала — отдельная тема, здесь рассматриваем только сам процесс остановки.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: net/http — пакет http.Server.Shutdown, документация Go, os/signal — signal.NotifyContext, документация Go
Частые вопросы
Отличается ли поведение в старых версиях Go?
Что делать, если Shutdown возвращает context deadline exceeded?
Чем Shutdown отличается от Close?
Нужен ли errgroup, если у меня только HTTP-сервер?
Гарантирует ли errgroup порядок остановки подсистем?
Почему запросы теряются, хотя Shutdown вызывается корректно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.