Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- Graceful shutdown — это управляемая остановка сервиса: перестать принимать новые соединения, дождаться завершения уже идущих запросов (в пределах таймаута), корректно закрыть ресурсы (БД, брокеры, пулы), и только потом завершить процесс. Цель — нулевой даунтайм при деплое и отсутствие оборванных запросов / потерянных данных.
- В Go ловим сигналы через
signal.NotifyContext(Go 1.16+): он отменяетcontext.ContextприSIGTERM/SIGINT.SIGKILL(kill -9) иSIGSTOPперехватить нельзя — ОС убивает процесс мгновенно. - HTTP:
srv.Shutdown(ctx)— мягко (дренаж активных запросов с таймаутом контекста),srv.Close()— жёстко (рвёт всё).ListenAndServeвозвращаетhttp.ErrServerClosedпри штатной остановке. - gRPC:
GracefulStop()— ждёт завершения RPC и дренирует стримы;Stop()— рвёт немедленно. На практикеGracefulStop()оборачивают в таймаут с fallback наStop(). - Kubernetes: при удалении пода kubelet шлёт
SIGTERM, ждётterminationGracePeriodSeconds, затемSIGKILL. Главная проблема — race между удалением пода из Endpoints/EndpointSlice и остановкой сервера: трафик ещё долетает после SIGTERM. Решение —preStophook соsleep+ завалreadinessProbe. - Правильный порядок: fail readiness → подождать, пока LB/kube-proxy уберут под из ротации → shutdown HTTP/gRPC → закрыть зависимости (БД, очереди).
Теория#
Зачем нужен graceful shutdown#
Когда процесс просто падает (os.Exit, паника, kill -9), все открытые TCP-соединения резко рвутся (RST), активные запросы недоисполняются, транзакции откатываются на стороне БД по таймауту, in-flight сообщения брокера могут потеряться или дублироваться. Для клиента это connection reset / 502 / EOF.
При деплое в оркестраторе (k8s) реплики постоянно пересоздаются. Без graceful shutdown каждый rollout = всплеск 5xx. Цель graceful shutdown:
- Не рвать активные запросы — дать им доработать.
- Дренаж (draining) — перестать брать новую работу, доделать старую.
- Корректно освободить ресурсы — закрыть пулы соединений к БД, flush буферов, commit/ack сообщений, дерегистрация из service discovery.
- Нулевой даунтайм — координация с балансировщиком, чтобы трафик не шёл на умирающий инстанс.
Signal handling в Go#
Процесс получает сигналы от ОС/оркестратора. Ключевые:
| Сигнал | Номер | Можно перехватить? | Семантика |
|---|---|---|---|
SIGTERM | 15 | Да | «Завершись штатно». Шлёт k8s, kill, systemd. Основной сигнал для shutdown. |
SIGINT | 2 | Да | Ctrl+C в терминале. |
SIGKILL | 9 | Нет | Мгновенное убийство ядром. Не доходит до программы. |
SIGSTOP | 19 | Нет | Заморозка процесса. Не перехватывается. |
SIGHUP | 1 | Да | Часто используют для reload конфига. |
SIGQUIT | 3 | Да | Go runtime по умолчанию печатает stack dump всех горутин и падает. |
Современный способ (Go 1.16+) — signal.NotifyContext. Возвращает производный контекст, который отменяется при приходе одного из перечисленных сигналов:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop() // важно: освобождает ресурсы и восстанавливает дефолтную обработку сигналов
<-ctx.Done() // блокируемся, пока не прилетит сигнал
log.Println("получен сигнал, начинаем shutdown")Тонкость NotifyContext: после первого сигнала контекст отменяется и обработчик снимается. Второй такой же сигнал пойдёт по дефолтному пути (для SIGTERM/SIGINT это немедленное завершение процесса). Это удобно: первый Ctrl+C — graceful, второй — «убей сейчас же».
Классический способ (до 1.16, всё ещё валиден) через буферизованный канал:
sigCh := make(chan os.Signal, 1) // ОБЯЗАТЕЛЬНО буфер >= 1: signal.Notify не блокируется на отправке,
// несбуферизованный канал может пропустить сигнал
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
log.Printf("получен %v", sig)http.Server.Shutdown(ctx) vs Close()#
Shutdown(ctx) | Close() | |
|---|---|---|
| Новые соединения | Отклоняет | Отклоняет |
| Активные запросы | Ждёт завершения (до отмены ctx) | Рвёт немедленно |
| Idle keep-alive соединения | Закрывает | Закрывает |
| Hijacked / WebSocket / SSE | НЕ ждёт и не закрывает — ваша ответственность | Рвёт listener, но не hijacked conn |
| Таймаут | Через context | Нет |
Механика Shutdown:
- Закрывает все listener’ы → новые TCP-коннекты не принимаются.
- Закрывает все idle keep-alive соединения.
- Ждёт, пока активные запросы завершатся, периодически опрашивая (poll). Как только запрос завершён и соединение становится idle — закрывает его.
- Если
ctxотменяется (таймаут) раньше — возвращаетctx.Err()(context.DeadlineExceeded), оставив незавершённые соединения. Тогда обычно вызываютClose()для force kill.
Важная связка с ListenAndServe: при штатной остановке через Shutdown/Close функция ListenAndServe возвращает http.ErrServerClosed — это не ошибка, её нужно явно игнорировать:
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}Полный пример HTTP-сервера с graceful shutdown#
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
// 1. Контекст, который отменится по SIGTERM/SIGINT.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
mux := http.NewServeMux()
mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
// Долгий запрос: учитываем отмену контекста запроса.
select {
case <-time.After(3 * time.Second):
w.Write([]byte("done"))
case <-r.Context().Done():
// клиент ушёл / соединение рвётся
}
})
// Флаг готовности для readiness probe.
var ready atomic.Bool
ready.Store(true)
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
g, gctx := errgroup.WithContext(ctx)
// 2. Запуск сервера в горутине.
g.Go(func() error {
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
// 3. Горутина shutdown: ждёт отмену gctx (сигнал ИЛИ ошибка сервера).
g.Go(func() error {
<-gctx.Done()
log.Println("shutdown initiated")
// 3a. Завалить readiness, чтобы LB/k8s перестали слать трафик.
ready.Store(false)
// 3b. (в k8s это делает preStop) дать время на дренаж endpoints.
// В bare-metal/standalone — можно просто продолжить.
// 3c. Shutdown с жёстким дедлайном. Новый контекст — gctx уже отменён!
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
// Таймаут дренажа — рвём принудительно.
log.Printf("graceful shutdown failed: %v; forcing close", err)
_ = srv.Close()
return err
}
log.Println("server stopped gracefully")
return nil
})
if err := g.Wait(); err != nil {
log.Fatalf("exit with error: %v", err)
}
log.Println("bye")
}Ключевые моменты примера:
errgroup.WithContext(ctx): если любая горутина вернёт ошибку,gctxотменится → каскадно завершит остальные. Если упалListenAndServe(например, порт занят), shutdown тоже запустится.- Контекст для
Shutdownсоздаётся отcontext.Background(), а не от уже отменённогоgctx— иначеShutdownмгновенно вернётDeadlineExceeded. Частая ошибка. - Таймаут shutdown (25s) меньше
terminationGracePeriodSecondsпода (см. ниже) — чтобы успеть доSIGKILL.
gRPC: GracefulStop() vs Stop()#
GracefulStop() | Stop() | |
|---|---|---|
| Новые RPC | Отклоняет | Отклоняет |
| Активные unary RPC | Ждёт завершения | Рвёт |
| Активные стримы | Ждёт закрытия стрима (дренаж) | Рвёт |
| Блокирующий | Да (до завершения всех) | Возвращается быстро |
| Таймаут | Нет встроенного | — |
GracefulStop() блокируется без ограничения по времени, пока все RPC/стримы не завершатся. Для долгоживущих server-streaming или bidi-стримов это может «висеть» бесконечно — поэтому оборачивают в таймаут:
func shutdownGRPC(srv *grpc.Server, timeout time.Duration) {
stopped := make(chan struct{})
go func() {
srv.GracefulStop() // дренирует RPC и стримы
close(stopped)
}()
select {
case <-stopped:
log.Println("grpc stopped gracefully")
case <-time.After(timeout):
log.Println("grpc graceful timeout, forcing Stop()")
srv.Stop() // принудительно рвём всё
}
}Дополнительно для стримов: сервер должен сам проверять stream.Context().Done() в циклах Send/Recv и корректно завершать стрим при отмене. GracefulStop ждёт, но не «прерывает» вашу бизнес-логику — это делаете вы через контекст.
Для health-checks gRPC есть google.golang.org/grpc/health — на shutdown переводят сервис в NOT_SERVING, чтобы клиенты с health-aware балансировкой перестали слать трафик до GracefulStop.
Дренаж соединений и долгие запросы#
«Дренаж» = довести до конца уже принятую работу, не принимая новой.
- Короткие запросы (< таймаута) — успевают завершиться внутри
Shutdown. - Долгие запросы (большие выгрузки, отчёты, стримы) — если не уложились в таймаут
Shutdown, послеDeadlineExceededмы делаемClose()→ force kill. Это компромисс: лучше оборвать единицы зависших, чем держать процесс вечно и сорвать деплой. - WebSocket / SSE / hijacked —
Shutdownих не отслеживает. Нужно вести свой учёт (например, через broadcast-канал «closing» во все хендлеры илиsync.WaitGroup), чтобы попросить их закрыться. - Deadline/timeout на запрос должен быть согласован:
request timeout≤shutdown timeout≤terminationGracePeriodSeconds. Иначе либо запросы рвутся раньше времени, либо процесс не успевает до SIGKILL.
Kubernetes: жизненный цикл завершения пода#
Когда под удаляется (rollout, scale-down, eviction), происходит параллельно две вещи:
- Control plane: под помечается
Terminating, API-сервер шлёт обновление, что под больше не endpoint. EndpointSlice контроллер удаляет под из endpoints → kube-proxy на всех нодах обновляет iptables/IPVS → под выпадает из ротации Service. Это асинхронно и небыстро (десятки-сотни мс, иногда секунды). - kubelet на ноде:
- Запускает
preStophook (если задан) и ждёт его завершения. - Затем шлёт контейнеру
SIGTERM. - Ждёт
terminationGracePeriodSeconds(по умолчанию 30s). Отсчёт идёт от момента удаления (включая время preStop). - Если процесс не завершился — шлёт
SIGKILL.
- Запускает
delete pod
├── (control plane) remove from EndpointSlice → kube-proxy → out of LB rotation [async, ~сотни мс+]
└── (kubelet) preStop hook ──► SIGTERM ──► wait grace period ──► SIGKILLГлавная проблема — race condition. SIGTERM может прийти контейнеру раньше, чем под реально убрали из endpoints. Если сервер мгновенно начнёт shutdown, то трафик, который kube-proxy ещё шлёт на старый под, упрётся в закрытый порт → connection refused / 5xx у клиента.
Решение — preStop hook со sleep (или эквивалентная задержка перед закрытием listener’а):
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]Логика: kubelet сначала выполнит preStop (10s паузы), и только потом пошлёт SIGTERM. За эти 10 секунд control plane успеет убрать под из endpoints и kube-proxy обновит правила. Сервер всё это время продолжает обслуживать «в полёте» трафик, но новый уже не приходит. После preStop приходит SIGTERM → запускается реальный shutdown.
Альтернатива/дополнение — завалить readinessProbe на старте shutdown. Readiness=false тоже убирает под из endpoints. Но preStop надёжнее как «таймер дренажа», потому что readiness опрашивается с интервалом (periodSeconds), а это лишняя задержка.
readinessProbe vs livenessProbe при shutdown:
- На shutdown readiness должен начать падать → под выводится из ротации.
- liveness трогать не надо. Если liveness упадёт во время дренажа, kubelet может рестартнуть контейнер — нежелательно. Поэтому endpoint readiness и liveness должны быть разными.
Порядок остановки (правильная последовательность)#
1. Получили SIGTERM (или preStop стартовал).
2. readinessProbe → 503 (под выводится из endpoints).
3. ПОДОЖДАТЬ, пока LB/kube-proxy уберут нас из ротации
(preStop sleep ИЛИ явный sleep перед Shutdown).
4. http.Server.Shutdown(ctx) / grpc GracefulStop(ctx) — дренаж активных запросов.
5. Закрыть зависимости в обратном порядке инициализации:
- воркеры/консьюмеры брокера (дождаться ack/commit offset),
- пул соединений к БД (db.Close()),
- кэши/Redis, файлы, метрики (flush),
- трейсинг (tracerProvider.Shutdown()).
6. Процесс завершается до истечения terminationGracePeriodSeconds.Важно: сначала перестаём принимать (readiness fail + закрыть listener), потом дренируем, потом закрываем БД. Если закрыть БД до того, как дренированы запросы — активные хендлеры упадут с «connection pool closed».
YAML: под с preStop и grace period#
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
template:
spec:
# Должен быть БОЛЬШЕ, чем preStop sleep + shutdown timeout.
# Здесь: 10 (preStop) + 25 (Shutdown) = 35 < 45. Запас на закрытие зависимостей.
terminationGracePeriodSeconds: 45
containers:
- name: api
image: registry.example.com/api:1.2.3
ports:
- containerPort: 8080
lifecycle:
preStop:
exec:
# Пауза, чтобы control plane успел убрать под из endpoints
# ДО прихода SIGTERM. Не выходить из ротации с горячим трафиком.
command: ["/bin/sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /healthz # ОТДЕЛЬНЫЙ от readiness, не падает при дренаже
port: 8080
periodSeconds: 10
failureThreshold: 3
# Чтобы SIGTERM дошёл до Go-процесса (PID 1), а не до shell-обёртки:
# либо exec-форма ENTRYPOINT, либо init-процесс (tini / shareProcessNamespace).Бюджет времени: terminationGracePeriodSeconds (45) ≥ preStop sleep (10) + Shutdown timeout (25) + закрытие зависимостей (~5). Если не уложиться — SIGKILL оборвёт всё на полуслове.
Подводные камни / gotchas#
- SIGKILL/SIGSTOP не перехватываются. Любая логика shutdown работает только для SIGTERM/SIGINT.
kill -9и OOM-killer убивают мгновенно. - PID 1 в контейнере не получает SIGTERM, если ENTRYPOINT — это shell (
sh -c "app"), и shell не пробрасывает сигналы. Результат: graceful не срабатывает, всегда SIGKILL по таймауту. Лечится exec-формойENTRYPOINT ["./app"]или init-процессом (tini). - Контекст для
Shutdownот уже отменённого контекста. Если передать вsrv.Shutdown(ctx)тот жеctx, что отменился по сигналу, дренаж не произойдёт — мгновенныйDeadlineExceeded. Нужен свежийcontext.WithTimeout(context.Background(), ...). ErrServerClosedпринимают за ошибку и логируют как fatal/panic. Это штатный возврат.- Race с endpoints в k8s — самый частый источник 5xx на rollout. Без preStop sleep даже идеальный
Shutdownне спасёт: трафик прилетает на закрытый порт. GracefulStop()в gRPC висит вечно на долгих стримах без таймаута и без проверкиstream.Context()в коде.- Несбуферизованный канал в
signal.Notifyможет пропустить сигнал (Notify не блокируется на отправке). Всегда буфер ≥ 1. СNotifyContextпроблема не актуальна. - Закрытие зависимостей до дренажа запросов → активные хендлеры падают (закрытый пул БД, отменённый контекст приложения).
terminationGracePeriodSecondsменьше суммарного времени shutdown → SIGKILL посреди дренажа.os.Exit()в горутине игнорирует deferred-функции и обрывает всё. Завершение должно идти через возврат из main.- Метрики/трейсы не сфлашены перед выходом → теряется хвост телеметрии о самом shutdown.
- In-flight Kafka/брокер сообщения: остановить consumer нужно так, чтобы закоммитить уже обработанные offset’ы и не закоммитить необработанные (at-least-once). Резкий выход → дубли или потери.
Вопросы на собеседовании#
В: Какие сигналы нельзя перехватить в Go и почему это важно для shutdown?
О: SIGKILL (9) и SIGSTOP (19) обрабатываются ядром и до процесса не доходят. Поэтому любая graceful-логика срабатывает только на SIGTERM/SIGINT. В k8s это означает: всё, что не успели за terminationGracePeriodSeconds, будет оборвано SIGKILL. OOM-killer и kill -9 тоже не дают шанса на дренаж.
В: В чём разница между http.Server.Shutdown(ctx) и Close()?
О: Shutdown — мягкий: закрывает listener’ы и idle-соединения, ждёт завершения активных запросов до отмены контекста, после таймаута возвращает ctx.Err(). Close — жёсткий: немедленно рвёт listener’ы и все активные соединения. Типичный паттерн: Shutdown с таймаутом, и если он вернул ошибку — Close() как force kill. Оба не отслеживают hijacked-соединения (WebSocket/SSE).
В: Почему ListenAndServe возвращает http.ErrServerClosed и как это обрабатывать?
О: Это штатный маркер того, что сервер остановлен через Shutdown/Close, а не упал. Его нужно отфильтровать: if err != nil && !errors.Is(err, http.ErrServerClosed) { ... }. Иначе нормальная остановка логируется как фатальная ошибка.
В: Опишите race condition в Kubernetes между SIGTERM и удалением из endpoints. Как решается?
О: Удаление пода из EndpointSlice (и обновление iptables через kube-proxy) идёт асинхронно и параллельно с отправкой SIGTERM. SIGTERM часто приходит раньше, чем под убрали из ротации, поэтому свежий трафик летит на уже закрывающийся под → connection refused/5xx. Решение — preStop hook со sleep (например, 5-15s): kubelet выполняет preStop до SIGTERM, давая control plane время убрать под из endpoints. Дополнительно — завал readinessProbe.
В: Почему контекст для Shutdown нельзя брать от того же контекста, что отменился по сигналу?
О: Контекст от signal.NotifyContext уже в состоянии Done() к моменту начала shutdown. Если передать его в Shutdown, тот сразу получит DeadlineExceeded и не выполнит дренаж. Нужен новый context.WithTimeout(context.Background(), <timeout>).
В: GracefulStop() vs Stop() в gRPC. Какие риски у GracefulStop?
О: GracefulStop отклоняет новые RPC, ждёт завершения активных unary и дренажа стримов, блокируется без таймаута. Stop рвёт всё немедленно. Риск GracefulStop — зависание на долгоживущих server-streaming/bidi стримах. Поэтому его запускают в горутине с select на таймаут и fallback на Stop(), а в коде стримов проверяют stream.Context().Done().
В: Какой правильный порядок действий при shutdown сервиса с БД и Kafka-консьюмером?
О: (1) readiness=false; (2) подождать выход из ротации LB (preStop/sleep); (3) Shutdown/GracefulStop — дренаж HTTP/gRPC; (4) остановить consumer, дождаться commit offset’ов обработанных сообщений; (5) закрыть пул БД, Redis, flush метрик/трейсов. Зависимости закрываются в порядке, обратном инициализации, и только после дренажа запросов — иначе активные хендлеры упадут на закрытом пуле.
В: Почему в контейнере graceful shutdown может вообще не работать?
О: Если PID 1 — это shell (sh -c "app"), он по умолчанию не пробрасывает SIGTERM дочернему процессу, и Go-приложение его не получает → всегда SIGKILL по grace period. Лечится exec-формой ENTRYPOINT ["./app"] или init-процессом (tini). Также частая причина — приложение слушает не тот сигнал или таймаут shutdown больше terminationGracePeriodSeconds.
В: Как соотносятся terminationGracePeriodSeconds, preStop и таймаут Shutdown?
О: terminationGracePeriodSeconds — общий бюджет от удаления пода до SIGKILL, и включает время preStop. Должно выполняться: grace ≥ preStop_sleep + shutdown_timeout + время_закрытия_зависимостей. Иначе SIGKILL прервёт дренаж. Таймаут Shutdown ставят заведомо меньше остатка grace после preStop.
В: Чем readinessProbe и livenessProbe отличаются по роли при shutdown? О: На shutdown readiness должен падать — это выводит под из endpoints (останавливает новый трафик). Liveness трогать нельзя: его падение спровоцирует рестарт контейнера kubelet’ом посреди дренажа. Поэтому readiness и liveness должны указывать на разные endpoint’ы с разной логикой.
На что копают на senior+#
- Координация с балансировщиком вне k8s: при работе за внешним L7 LB (nginx/Envoy/ALB) нужно учитывать health-check интервалы LB и его connection draining; preStop sleep подбирается под worst-case интервал health-check + propagation. Для service mesh (Istio/Linkerd) — порядок завершения sidecar’а относительно app-контейнера (sidecar не должен умереть раньше приложения; в k8s 1.28+ — native sidecar containers).
- Долгоживущие соединения: как дренировать WebSocket/SSE/gRPC-стримы, которые
Shutdownне отслеживает. Свой реестр соединений, broadcast «server is going away» (WebSocket close code 1001),GOAWAYдля HTTP/2 и gRPC. - HTTP/2 и keep-alive: поведение
GOAWAY, переиспользование соединений клиентом, почему один долгий HTTP/2 коннект может держать несколько стримов. - Идемпотентность и retry: даже идеальный graceful не исключает обрывы (SIGKILL, network); клиенты должны ретраить идемпотентные операции, сервер — защищаться от дублей (idempotency key, exactly-once на уровне БД).
- Брокеры сообщений: корректная остановка consumer group (rebalance), commit стратегия, in-flight сообщения, at-least-once vs at-most-once trade-off при shutdown.
- Распределённые транзакции / саги: что делать с наполовину выполненными бизнес-процессами при внезапной остановке; компенсирующие транзакции, outbox pattern.
- Наблюдаемость shutdown: метрики времени дренажа, счётчик оборванных запросов, алерт если shutdown регулярно упирается в таймаут (значит запросы дольше бюджета — нужно тюнить).
- Zero-downtime деплой целиком: связка readiness gate +
maxSurge/maxUnavailableв RollingUpdate + PodDisruptionBudget, чтобы во время rollout всегда хватало здоровых реплик. - Сигналы и runtime Go: что делает
SIGQUIT(stack dump),GOTRACEBACK, как форсить дамп горутин зависшего сервиса для диагностики, почему нельзя делать тяжёлую работу прямо в signal handler. - Graceful restart / zero-downtime без оркестратора: передача listener’а новому процессу через
SO_REUSEPORTили fd-passing (как вtableflip/overseer/systemd socket activation).