Graceful shutdown в Go: семь способов и какой выбрать
Сервис на Go получает SIGTERM и у него есть секунды, чтобы дописать ответы, закрыть коннекты к базе и не потерять данные. Разбираем семь реальных подходов к graceful shutdown — от signal.Notify до errgroup — и сравниваем, чем каждый платит за аккуратность.

Почему SIGTERM — это не катастрофа, а контракт
Kubernetes отправляет поду SIGTERM и ждёт 30 секунд (по умолчанию terminationGracePeriodSeconds). Systemd делает то же самое. Балансировщик уже убрал под из пула, но in-flight запросы ещё летят. Если процесс просто умрёт — клиенты получат обрыв соединения, незакрытая транзакция в PostgreSQL повиснет, а очередь сообщений решит, что consumer пропал, и перебалансирует партиции. Всё это — потеря данных и лишний шум в алертах.
Graceful shutdown — это не вежливость, а контракт с оркестратором: «дай мне N секунд, и я уйду чисто». Go даёт для этого хороший набор примитивов, но ни один из них не решает задачу целиком. Ниже — семь подходов, от минимального до продвинутового, с кодом на Go 1.22+.
Способ 1: голый signal.Notify и os.Exit
Самый наивный вариант. Ловим сигнал, печатаем лог, выходим.
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
// ... запуск сервиса ...
s := <-sig
fmt.Printf("получен сигнал: %s, завершаемся\n", s)
os.Exit(0)
}
Проблема очевидна: os.Exit не вызывает деферы, не ждёт горутины, не закрывает листенеры. Подходит только для CLI-утилит без состояния.
Способ 2: signal.NotifyContext
Начиная с Go 1.16 появился signal.NotifyContext, который возвращает контекст, отменяемый по сигналу. Это уже ближе к делу: контекст можно прокинуть в http.Server.Shutdown.
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
log.Println("завершение: ждём до 5 секунд")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
Ключевой момент: http.Server.Shutdown перестаёт принимать новые соединения, но дожидает активные. Таймаут в 5 секунд — это ваш «бюджет» на дожидание. Если запрос не успеет — его оборвут.
Чем этот способ платит
Он закрывает только HTTP-сервер. Если в процессе ещё живут gRPC-сервер, воркеры очередей и пул соединений к базе — о них никто не позаботится. Для монолитного HTTP-сервиса без фоновых горутин этого достаточно, для всего остального — нет.
Способ 3: явный список Closer-ов
Идея проста: собираем в слайс всё, что умеет Close() или Shutdown(ctx), и при получении сигнала обходим его в обратном порядке — как стек деферов.
type ShutdownFunc func(ctx context.Context) error
var shutdowns []ShutdownFunc
func registerShutdown(fn ShutdownFunc) {
shutdowns = append(shutdowns, fn)
}
func runShutdown(ctx context.Context) {
// Обратный порядок: последний зарегистрированный — первый закрытый
for i := len(shutdowns) - 1; i >= 0; i-- {
if err := shutdowns[i](ctx); err != nil {
log.Printf("shutdown step %d: %v", i, err)
}
}
}
Плюс — порядок закрытия предсказуем и явен. Минус — всё последовательно. Если закрытие пула к базе занимает 3 секунды, а закрытие gRPC-сервера ещё 3, вы уже не влезаете в 5-секундный бюджет.
Способ 4: параллельный shutdown через errgroup
Когда ресурсы независимы, их можно закрывать параллельно. golang.org/x/sync/errgroup для этого подходит идеально.
import "golang.org/x/sync/errgroup"
func parallelShutdown(ctx context.Context, fns ...ShutdownFunc) error {
g, gCtx := errgroup.WithContext(ctx)
for _, fn := range fns {
fn := fn // захват переменной цикла (не нужен с Go 1.22, но явность не помешает)
g.Go(func() error {
return fn(gCtx)
})
}
return g.Wait()
}
Важный нюанс: не всё можно закрывать параллельно. HTTP-сервер должен остановиться раньше, чем пул к базе, иначе in-flight хендлер попытается выполнить запрос к уже закрытому пулу. Чистая параллельность — это для независимых ресурсов: метрики, трейсинг, отдельные кеши.
Способ 5: фазовый shutdown
Комбинация третьего и четвёртого подходов. Shutdown разбивается на фазы, внутри каждой фазы ресурсы закрываются параллельно, фазы идут последовательно.
Порядок закрытия — это зеркало порядка запуска. Если вы запустили базу, потом кеш, потом HTTP — закрывайте HTTP, потом кеш, потом базу. Нарушение этого порядка — самая частая причина паники при shutdown.
Типичные фазы:
- Стоп входа — HTTP-сервер, gRPC-сервер, consumer очереди перестают принимать новые запросы.
- Дожидание in-flight — ждём, пока текущие запросы доработают.
http.Server.Shutdownделает это за вас, для gRPC естьGracefulStop(). - Flush буферов — сброс батч-процессоров, отправка накопленных метрик и трейсов.
- Закрытие пулов — соединения к базе, Redis, внешние SDK.
Способ 6: отдельная горутина-супервизор
Вместо того чтобы размазывать логику shutdown по main, выделяется отдельная структура, которая владеет жизненным циклом всех компонентов. Паттерн часто называют application или lifecycle manager.
type App struct {
phases [][]ShutdownFunc
}
func (a *App) AddPhase(fns ...ShutdownFunc) {
a.phases = append(a.phases, fns)
}
func (a *App) Shutdown(ctx context.Context) error {
for i, phase := range a.phases {
g, gCtx := errgroup.WithContext(ctx)
for _, fn := range phase {
fn := fn
g.Go(func() error { return fn(gCtx) })
}
if err := g.Wait(); err != nil {
return fmt.Errorf("phase %d: %w", i, err)
}
}
return nil
}
Плюс — тестируемость: App.Shutdown можно вызвать в интеграционном тесте, не отправляя процессу реальный сигнал. Минус — ещё одна абстракция, которую нужно поддерживать.
Способ 7: библиотека uber-go/fx или oklog/run
Если проект уже использует DI-контейнер вроде uber-go/fx, lifecycle-хуки (fx.Hook{OnStop: ...}) дают graceful shutdown «бесплатно». Библиотека oklog/run решает ту же задачу без DI: каждый актор регистрирует пару (execute, interrupt), и run.Group останавливает всех, когда любой из акторов завершился.
Цена — зависимость. oklog/run крошечная (один файл), fx тянет рефлексию и контейнер. Если у вас два сервиса в main — это оверкилл. Если двадцать — экономит десятки строк и, главное, помогает не пропустить ни один компонент при остановке.
Сравнение подходов
| Способ | Сложность | Параллельность | Порядок фаз | Тестируемость | Зависимости |
|---|---|---|---|---|---|
| 1. signal.Notify + os.Exit | минимум | нет | нет | нет | stdlib |
| 2. signal.NotifyContext | низкая | нет | нет | средняя | stdlib |
| 3. Список Closer-ов | низкая | нет | да (обратный) | да | stdlib |
| 4. errgroup параллельный | средняя | да | нет | да | x/sync |
| 5. Фазовый shutdown | средняя | внутри фазы | да | да | x/sync |
| 6. Супервизор-структура | средняя | внутри фазы | да | да | x/sync |
| 7. fx / oklog/run | выше | зависит | да | да | внешняя |
Где подход ломается
Таймаут короче, чем самый длинный запрос
Если ваш terminationGracePeriodSeconds в Kubernetes — 30 секунд, а фаза дожидания in-flight занимает 25, на flush буферов и закрытие пула остаётся 5. Любой запрос, который занимает больше 25 секунд, будет убит. Решение — либо увеличить таймаут оркестратора, либо сделать длинные операции идемпотентными и перезапускаемыми.
Двойной SIGTERM
Некоторые среды (и нетерпеливые операторы) отправляют SIGTERM дважды или сразу SIGKILL. Хорошая практика — на второй SIGTERM вызывать os.Exit(1) немедленно, чтобы не зависнуть.
go func() {
<-ctx.Done() // первый сигнал — graceful
stop()
<-sig // второй сигнал — принудительный выход
log.Println("принудительное завершение")
os.Exit(1)
}()
Горутина-зомби
Если фоновая горутина не проверяет ctx.Done(), никакой shutdown её не остановит. Это не проблема механизма — это проблема дисциплины: каждая горутина, живущая дольше одного запроса, обязана принимать контекст и реагировать на его отмену.
Когда что выбирать
- CLI-утилита, скрипт — способ 1 или 2. Нет состояния — нечего закрывать.
- HTTP-сервис без фона — способ 2.
signal.NotifyContext+srv.Shutdown— три строки, и вы покрыты. - Сервис с базой и кешем — способ 5 (фазовый). Порядок критичен, реализация — 30 строк.
- Микросервис с gRPC, Kafka, метриками — способ 6 (супервизор). Фазы и тестируемость окупают абстракцию.
- Большой монолит с DI — способ 7.
fxуже управляет запуском — пусть управляет и остановкой.
Graceful shutdown — контролируемое завершение процесса, при котором сервис прекращает принимать новую работу, дожидается окончания текущей и освобождает ресурсы, прежде чем выйти.
Что делать дальше
Напишите интеграционный тест, который вызывает ваш Shutdown(ctx) с коротким таймаутом и проверяет, что все ресурсы закрыты. Добавьте метрику shutdown_duration_seconds — она покажет, укладываетесь ли вы в бюджет оркестратора. Проверьте, что terminationGracePeriodSeconds в вашем деплойменте больше суммы всех фаз хотя бы на 2–3 секунды. И убедитесь, что каждая долгоживущая горутина в проекте слушает свой контекст — иначе никакой механизм вас не спасёт.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: net/http — Go standard library documentation (Server.Shutdown), os/signal — Go standard library documentation (NotifyContext)
Частые вопросы
Чем http.Server.Shutdown отличается от http.Server.Close?
Какой таймаут ставить на Shutdown?
А если у меня другая версия Go — signal.NotifyContext не заработает?
Нужен ли graceful shutdown, если сервис stateless?
Что произойдёт, если Shutdown не успеет в отведённый таймаут?
Как тестировать graceful shutdown без отправки SIGTERM?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.