Модуль: 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. Решение — preStop hook со 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:

  1. Не рвать активные запросы — дать им доработать.
  2. Дренаж (draining) — перестать брать новую работу, доделать старую.
  3. Корректно освободить ресурсы — закрыть пулы соединений к БД, flush буферов, commit/ack сообщений, дерегистрация из service discovery.
  4. Нулевой даунтайм — координация с балансировщиком, чтобы трафик не шёл на умирающий инстанс.

Signal handling в Go#

Процесс получает сигналы от ОС/оркестратора. Ключевые:

СигналНомерМожно перехватить?Семантика
SIGTERM15Да«Завершись штатно». Шлёт k8s, kill, systemd. Основной сигнал для shutdown.
SIGINT2ДаCtrl+C в терминале.
SIGKILL9НетМгновенное убийство ядром. Не доходит до программы.
SIGSTOP19НетЗаморозка процесса. Не перехватывается.
SIGHUP1ДаЧасто используют для reload конфига.
SIGQUIT3Да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:

  1. Закрывает все listener’ы → новые TCP-коннекты не принимаются.
  2. Закрывает все idle keep-alive соединения.
  3. Ждёт, пока активные запросы завершатся, периодически опрашивая (poll). Как только запрос завершён и соединение становится idle — закрывает его.
  4. Если 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 / hijackedShutdown их не отслеживает. Нужно вести свой учёт (например, через broadcast-канал «closing» во все хендлеры или sync.WaitGroup), чтобы попросить их закрыться.
  • Deadline/timeout на запрос должен быть согласован: request timeoutshutdown timeoutterminationGracePeriodSeconds. Иначе либо запросы рвутся раньше времени, либо процесс не успевает до SIGKILL.

Kubernetes: жизненный цикл завершения пода#

Когда под удаляется (rollout, scale-down, eviction), происходит параллельно две вещи:

  1. Control plane: под помечается Terminating, API-сервер шлёт обновление, что под больше не endpoint. EndpointSlice контроллер удаляет под из endpoints → kube-proxy на всех нодах обновляет iptables/IPVS → под выпадает из ротации Service. Это асинхронно и небыстро (десятки-сотни мс, иногда секунды).
  2. kubelet на ноде:
    • Запускает preStop hook (если задан) и ждёт его завершения.
    • Затем шлёт контейнеру 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).