На код-ревью я периодически вижу сервисы, которые при получении SIGTERM просто умирают. Никакой обработки сигналов, никакого ожидания текущих запросов. ListenAndServe в main, Ctrl+C, процесс мёртв. Локально это работает — вы же всё равно перезапускаете руками. А в продакшене, где Kubernetes перекатывает поды несколько раз в день, начинаются проблемы: обрывы соединений, частично записанные данные в базе, потерянные сообщения из очередей.
Graceful shutdown — это когда сервис перестаёт принимать новые запросы, дожидается завершения текущих и только потом останавливается. В Go всё необходимое для этого есть в стандартной библиотеке.
Что идёт не так без graceful shutdown
Без корректного завершения сервис обрывается на полуслове. Клиент получает connection reset — retry-логика может сработать, а может и нет. Транзакция в базе не зафиксирована, но side-эффект (отправка события в очередь, вызов внешнего API) уже произошёл. Consumer вычитал сообщение из Kafka, но не успел закоммитить offset — после рестарта оно обработается повторно (и хорошо, если обработчик идемпотентен, а если нет — получите дубликат). Файловые дескрипторы не закрыты, блокировки в базе не сняты.
При разработке локально вы останавливаете сервис только после ручного теста, что происходит относительно редко. В продакшене деплои могут происходить несколько раз в день, Kubernetes по своим причинам перемещает поды между нодами или проводит рестарты. Некорректное завершение превращается в постоянный источник мелких и крупных багов, которые сложно воспроизвести и ещё сложнее отладить.
Двенадцать факторов и утилизируемость
Методология 12-factor app описывает принципы построения cloud-native приложений. Девятый фактор — Утилизируемость (ориг. Disposability) — про то, что процессы должны запускаться быстро, а завершаться корректно. При получении SIGTERM процесс прекращает приём новых задач, завершает текущие и выходит (на практике стоит обрабатывать и SIGINT — для корректной остановки при Ctrl+C в терминале). Для долгих задач (например: фоновые задачи или воркеры) рекомендуется возвращать задачу обратно в очередь, если процесс не успевает её доделать.
Любая облачная инфраструктура (k8s, его аналоги или serverless решения) исходит из того, что ваш процесс могут убить в любой момент. Примеры: сервер выводят на обслуживание; оркестратор перебалансирует нагрузку; на одном из серверов закончилось место на диске. Если ваш сервис не готов к этому, то гарантированно будут инциденты.
Сигналы в Unix и как Go с ними работает
Ctrl+C в терминале — это SIGINT. Kubernetes при остановке пода отправляет SIGTERM. kill -9 — это SIGKILL, который перехватить невозможно в принципе.
В современном Go удобнее всего перехватывать сигналы через signal.NotifyContext:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// ctx.Done() сработает при получении сигнала
<-ctx.Done()
log.Println("получен сигнал завершения")
signal.NotifyContext появился в Go 1.16 на замену более громоздкому паттерну с signal.Notify и каналом. Возвращает контекст, который отменяется при получении указанного сигнала. Этот контекст можно пробросить вниз по цепочке вызовов, и все операции, которые поддерживают context, завершатся сами.
Graceful shutdown HTTP-сервера
Стандартный http.Server поддерживает graceful shutdown из коробки через метод Shutdown:
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Имитация обработки запроса
time.Sleep(2 * time.Second)
w.Write([]byte("OK"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Контекст, который отменится при получении SIGINT или SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Запускаем сервер в отдельной горутине
go func() {
log.Printf("сервер запущен на %s", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("ошибка сервера: %v", err)
}
}()
// Ждём сигнала
<-ctx.Done()
log.Println("получен сигнал завершения, начинаем shutdown")
// Даём серверу 10 секунд на завершение текущих запросов
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("ошибка при shutdown: %v", err)
}
log.Println("сервер остановлен")
}
Что делает server.Shutdown:
- Закрывает все открытые listener-ы, новые соединения больше не принимаются
- Закрывает idle-соединения (keep-alive без активных запросов)
- Ждёт, пока все активные запросы завершатся
- Возвращает
nil, если запросы завершились до истечения контекста, или ошибку контекста, если не успели
Таймаут — обязательная страховка. Если обработчик завис (ждёт ответа от базы, попал в бесконечный цикл), без таймаута процесс не завершится никогда. В Kubernetes после terminationGracePeriodSeconds (по умолчанию 30 секунд) под получит SIGKILL, и весь graceful shutdown окажется бессмысленным.
Целостность данных при остановке
Целостность это то ключевое, ради чего graceful shutdown вообще нужен. Возьмём обработчик, который создаёт заказ:
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
tx, err := h.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Создаём заказ
orderID, err := h.repo.CreateOrder(r.Context(), tx, req)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Списываем средства
if err := h.repo.DeductBalance(r.Context(), tx, req.UserID, req.Amount); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]int{"order_id": orderID})
}
В этом примере используется единая транзакция, поэтому при гибели процесса PostgreSQL откатит все изменения — частичного состояния в базе не будет. Но проблемы начинаются, когда бизнес-логика сложнее: несколько последовательных коммитов в рамках одного запроса, или обработчик вызывает внешний API между операциями с базой. Если процесс умирает после вызова внешнего API, но до коммита — API уже выполнен, а данные в базе не зафиксированы. Или наоборот: коммит прошёл, а уведомление во внешнюю систему не отправлено.
Graceful shutdown гарантирует, что обработчик отработает предсказуемо и до конца. Транзакция зафиксируется, внешние вызовы завершатся, клиент получит ответ.
Важный момент: обратите внимание на r.Context() в запросах к базе. server.Shutdown не отменяет контексты активных запросов — он ждёт их завершения. Но если вы передаёте в SQL-запросы контекст от signal.NotifyContext, то при получении сигнала все запросы к базе отменятся разом. И получится ровно та ситуация, от которой вы пытались защититься. Я встречал этот баг в продакшене — на первый взгляд всё сделано правильно, graceful shutdown есть, но контекст прокинут не тот, и при деплое транзакции всё равно обрываются. Используйте r.Context() в обработчиках, а не глобальный контекст приложения.
Завершение фоновых горутин
HTTP-сервер — только часть приложения. Обычно рядом крутятся воркеры для очередей, периодические джобы, подписки на события. Всё это тоже нужно останавливать.
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()
g, gCtx := errgroup.WithContext(ctx)
server := &http.Server{Addr: ":8080", Handler: newRouter()}
// HTTP-сервер
g.Go(func() error {
log.Println("HTTP-сервер запущен")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
// Graceful shutdown HTTP-сервера
g.Go(func() error {
<-gCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
})
// Воркер обработки очереди
g.Go(func() error {
return runQueueWorker(gCtx)
})
// Периодическая задача
g.Go(func() error {
return runPeriodicCleanup(gCtx, 5*time.Minute)
})
if err := g.Wait(); err != nil {
log.Printf("завершение с ошибкой: %v", err)
}
log.Println("все компоненты остановлены")
}
func runQueueWorker(ctx context.Context) error {
for {
select {
case <-ctx.Done():
log.Println("воркер: получен сигнал завершения")
return nil
default:
// Обрабатываем следующее сообщение из очереди
// Используем ctx, чтобы прерваться при shutdown
if err := processNextMessage(ctx); err != nil {
log.Printf("воркер: ошибка обработки: %v", err)
}
}
}
}
func runPeriodicCleanup(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("cleanup: остановлен")
return nil
case <-ticker.C:
if err := cleanup(ctx); err != nil {
log.Printf("cleanup: ошибка: %v", err)
}
}
}
}
Если любой компонент падает с ошибкой, контекст группы отменяется и остальные тоже начинают останавливаться. g.Wait() дожидается завершения всех горутин — ни одна не останется висеть. Горутина shutdown в этом примере блокирует завершение группы, пока сервер не остановится или не истечёт таймаут.
Обратите внимание на разницу в поведении при shutdown. HTTP-сервер через server.Shutdown даёт текущим запросам доработать до конца (drain). А вот воркер при отмене контекста прервёт текущую операцию — processNextMessage получит отменённый ctx и должен корректно откатиться. Если очередь поддерживает Nack, воркер может вернуть недообработанное сообщение, чтобы его подхватил другой инстанс. Это разные стратегии завершения, и обе правильные — просто нужно понимать, какая где применяется.
Порядок завершения
Порядок, в котором компоненты останавливаются, имеет значение. Если закрыть пул соединений к базе раньше, чем HTTP-сервер завершит обработку запросов — текущие запросы получат ошибку при обращении к БД.
Правильный порядок — обратный порядку запуска. Сначала перестаём принимать новые запросы (закрываем listener). Потом дожидаемся завершения текущих. Останавливаем фоновые воркеры. Закрываем соединения к внешним сервисам — БД, Redis, Kafka. В последнюю очередь сбрасываем метрики и логи.
log.Println("1. останавливаем HTTP-сервер")
server.Shutdown(shutdownCtx)
log.Println("2. останавливаем воркеры")
cancelWorkers()
workersWg.Wait()
log.Println("3. закрываем соединение с БД")
db.Close()
log.Println("4. закрываем соединение с Redis")
redisClient.Close()
log.Println("5. сбрасываем метрики")
metricsExporter.Flush()
Логика простая: компоненты верхнего уровня (HTTP-сервер, воркеры) зависят от компонентов нижнего уровня (БД, кэш). Закрыли базу раньше сервера — обработчик, который обращается к базе, получит ошибку.
Readiness probe и Kubernetes
В Kubernetes graceful shutdown связан с probes. Когда под получает SIGTERM, он должен перестать отвечать на readiness probe. Это сигнал для Service — перестать слать на этот под трафик.
var isReady atomic.Bool
func init() {
isReady.Store(true)
}
func readinessHandler(w http.ResponseWriter, r *http.Request) {
if !isReady.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
При получении сигнала выставляем isReady в false:
<-ctx.Done()
isReady.Store(false)
// Даём Kubernetes время заметить, что под не ready
time.Sleep(5 * time.Second)
// Теперь начинаем shutdown
server.Shutdown(shutdownCtx)
Пауза в 5 секунд нужна, потому что Kubernetes не мгновенно обновляет endpoints. Между моментом, когда под стал не-ready, и моментом, когда kube-proxy обновит iptables, проходит несколько секунд. Без этой паузы под может получить новые запросы уже после начала shutdown. Мы на это наступили, когда у нас при деплое проскакивали 502 ошибки на балансировщике — добавили sleep, ошибки ушли.
Но time.Sleep в коде приложения — не единственный вариант. Логика ожидания обновления endpoints относится к инфраструктуре, а не к бизнес-логике. В Kubernetes для этого есть lifecycle.preStop hook:
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
Kubernetes выполнит preStop перед отправкой SIGTERM. Приложение при этом ничего не знает про задержку — оно получает сигнал и сразу начинает shutdown. Учтите, что таймер terminationGracePeriodSeconds начинает тикать до выполнения preStop, а не после — время preStop и время shutdown приложения вместе должны уложиться в grace period. Зато это чище в плане разделения ответственности: кластер управляет таймингами остановки трафика, приложение занимается только своими ресурсами. Если в одном кластере endpoints обновляются за 2 секунды, а в другом за 10 — меняется только манифест, код трогать не нужно.
Таймауты и SIGKILL
Таймауты должны складываться в правильную цепочку:
| Параметр | Значение | Что делает |
|---|---|---|
| Пауза перед shutdown | 5 с | Ожидание обновления endpoints в Kubernetes |
Таймаут server.Shutdown | 10 с | Время на завершение текущих запросов |
terminationGracePeriodSeconds | 30 с | Время до SIGKILL от Kubernetes |
Сумма паузы и таймаута shutdown должна быть меньше terminationGracePeriodSeconds. Иначе Kubernetes убьёт процесс SIGKILL раньше, чем он завершится сам, и graceful shutdown не отработает.
Если у вас есть запросы, которые могут работать дольше 10 секунд (загрузка файлов, тяжёлые отчёты) — увеличивайте оба значения соответственно.
Типичные ошибки
Самая частая — вообще не обрабатывать сигналы. Go-рантайм при получении SIGTERM завершает процесс. Deferred-функции не выполняются. Все ваши defer db.Close() и defer tx.Rollback() просто не сработают.
Shutdown без таймаута — тоже классика. Зависший обработчик заблокирует завершение навсегда. В Kubernetes это закончится SIGKILL, в systemd — зависшим сервисом, который придётся убивать руками.
Ещё встречается — закрывать зависимости раньше обработчиков. Закрыли пул БД, а HTTP-сервер ещё обрабатывает запросы. Текущие обработчики получат ошибку при обращении к базе.
Ещё одна — os.Exit в обработчиках или в горутинах. os.Exit завершает процесс немедленно, без выполнения deferred-функций. Я видел код, где при критической ошибке вызывался os.Exit(1) прямо из обработчика, и при этом рядом был аккуратно реализован graceful shutdown. Они друг друга исключают.
И про логгер: если используете async-логгер (zap с buffered writer, например), сбрасывайте буфер в самом конце, после остановки всех компонентов. Иначе последние строки логов, которые как раз описывают процесс завершения, просто потеряются. Для zap это logger.Sync():
// В самом конце main, после остановки всех компонентов
logger.Sync()
Заключение
В Go стандартная библиотека покрывает всё, что нужно для graceful shutdown: signal.NotifyContext перехватывает сигналы, http.Server.Shutdown корректно останавливает сервер, context пробрасывается по всей цепочке вызовов.
На практике чаще всего забывают про три вещи: правильный контекст в SQL-запросах (нужен r.Context(), а не глобальный), порядок завершения компонентов (обратный порядку запуска) и паузу перед shutdown в Kubernetes (чтобы endpoints успели обновиться).
Если вы работаете по 12-factor app — disposability уже в чеклисте. Если нет — graceful shutdown стоит добавить первым. Это небольшое изменение, но оно убирает целый класс багов, которые проявляются только при деплое и которые сложно воспроизвести локально.
Связанные статьи:
- Go http.Client: переиспользование соединений и почему важно читать Body
- PostgreSQL и Go: pgx, database/sql или sqlx
FAQ
Что произойдёт, если не обрабатывать SIGTERM?
Go-рантайм при получении SIGTERM завершает процесс. Deferred-функции не выполняются, финализаторы не вызываются. Процесс просто останавливается. Всё, что вы настроили в defer, не сработает.
SIGKILL перехватить нельзя ни в Go, ни в любом другом языке. Обработка SIGTERM — единственный шанс завершиться корректно.
Как тестировать graceful shutdown?
Интеграционный тест: запускаем сервер, отправляем долгий запрос, вызываем Shutdown и проверяем, что запрос завершился без ошибки:
func TestGracefulShutdown(t *testing.T) {
server := &http.Server{
Addr: ":0",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("OK"))
}),
}
ln, _ := net.Listen("tcp", ":0")
go server.Serve(ln)
// Отправляем запрос, который будет обрабатываться 2 секунды
done := make(chan struct{})
go func() {
resp, err := http.Get("http://" + ln.Addr().String())
if err != nil {
t.Errorf("запрос завершился с ошибкой: %v", err)
} else {
resp.Body.Close()
}
close(done)
}()
// Даём запросу начать обработку
time.Sleep(100 * time.Millisecond)
// Инициируем shutdown
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
// Запрос должен был завершиться успешно
<-done
}
В продакшене полезно логировать время shutdown и количество запросов в момент остановки. Если таймаут регулярно превышается — значит, какие-то обработчики работают слишком долго, и стоит разобраться с ними отдельно.
Нужен ли graceful shutdown для CLI-утилит?
Зависит от того, что утилита делает. Парсинг файла или генерация отчёта — скорее нет. Запись в файл, базу или вызов внешних API — да.
Пример: CLI-утилита, которая мигрирует данные между базами. Без graceful shutdown прерывание на середине оставит данные в неконсистентном состоянии. С ним — утилита завершит текущий batch и выведет, на каком месте остановилась, чтобы можно было продолжить.
Как работает graceful shutdown в Go-фреймворках?
Большинство фреймворков оборачивают http.Server.Shutdown:
Gin:
srv := &http.Server{Addr: ":8080", Handler: router}
go srv.ListenAndServe()
// ... обработка сигнала ...
srv.Shutdown(ctx) // Тот же http.Server.Shutdown
Echo:
go e.Start(":8080")
// ... обработка сигнала ...
e.Shutdown(ctx) // Внутри вызывает http.Server.Shutdown
Fiber (построен на fasthttp, не на net/http):
go app.Listen(":8080")
// ... обработка сигнала ...
app.ShutdownWithTimeout(10 * time.Second)
Fiber — исключение, потому что использует fasthttp вместо net/http. Остальные фреймворки делегируют shutdown стандартному http.Server, так что паттерны из этой статьи применимы напрямую.
Теги: