10. Graceful shutdown

В реальном мире ни один сервер не работает вечно. Сервисы непрерывно перезапускаются при развертывании новых версий (rolling updates), масштабируются оркестраторами вроде Kubernetes при колебаниях нагрузки или перемещаются между физическими нодами во время регламентных работ в дата-центре.

Внезапное выключение процесса вызовом os.Exit(0) или перехват грубого SIGKILL подобны выдергиванию сетевого шнура из розетки прямо во время записи на диск: активные транзакции обрываются, клиенты получают уродливые ошибки 502 Bad Gateway, а незавершенные фоновые задачи оставляют базу данных в рассогласованном состоянии. Настоящая культура проектирования бэкенда требует грациозности — детерминированного и безопасного завершения работы (Graceful Shutdown).

sequenceDiagram
    autonumber
    actor K8s as Kubernetes / Оркестратор
    participant Ingress as Ingress / Nginx LB
    participant Srv as Go http.Server
    participant Handlers as Active Requests
    participant Workers as Background Workers (sync.WaitGroup)
    participant DB as DB Pool / Redis

    K8s->>Srv: 1. Отправка сигнала SIGTERM
    Note over Srv: 2. Перехват сигнала (signal.Notify)
    K8s->>Ingress: 3. Удаление Pod из Endpoints (iptables/IPVS)
    Srv->>Srv: 4. srv.Shutdown(ctx): close(listener)
    Note over Srv: Новые SYN отклоняются,<br/>idle-соединения закрываются
    Handlers-->>Srv: 5. Завершение активных HTTP-запросов (200 OK)
    Srv->>Workers: 6. cancelWorkerCtx()
    Workers-->>Srv: 7. workerWG.Wait() (Очереди дочитаны)
    Srv->>DB: 8. db.Close(), flush metrics/traces
    Srv-->>K8s: 9. Process exit 0 (Безопасное завершение)
  

Graceful shutdown. Корректное завершение сервиса

Graceful shutdown — это не просто вызов os.Exit(0). Это детерминированный процесс завершения работы сервиса, который гарантирует:

  • Отправку валидных HTTP-ответов на уже принятые запросы
  • Корректное закрытие пулов соединений (БД, Redis, очереди)
  • Завершение фоновых задач без потери данных
  • Бесшовное взаимодействие с балансировщиками нагрузки и оркестраторами (Kubernetes, Docker Swarm)

Без корректного завершения rolling-обновления приводят к 502 Bad Gateway, незавершенным транзакциям и повреждению состояния в распределенных системах.


Уровень ОС и TCP-стейтмашина

Когда оркестратор или администратор останавливает контейнер, ядро ОС отправляет процессу сигнал SIGTERM. По умолчанию Go не обрабатывает сигналы самостоятельно — процесс завершается мгновенно. Но нам нужно перехватить этот сигнал и запустить процедуру очистки.

С точки зрения сетевого стека:

  1. Сервер закрывает слушающий сокет (close(listener)). Новые TCP SYN-пакеты получают Connection Refused или RST.
  2. Load Balancer (nginx, AWS ALB, kube-proxy) обнаруживает ошибку подключения и выводит инстанс из пула.
  3. Уже установленные TCP-соединения остаются в состоянии ESTABLISHED. Сервер продолжает читать/писать в них до завершения обработки или истечения таймаута.
  4. При принудительном разрыве отправляется TCP FIN (грациозно) или RST (жестко). FIN позволяет клиенту получить оставшиеся байты, RST обрывает поток мгновенно.

Под капотом. Как работает http.Server.Shutdown

Внутренний механизм http.Server в Go построен вокруг централизованного трекинга открытых сокетов. В исходном коде (src/net/http/server.go) сервер хранит единую хеш-таблицу соединений под защитой мьютекса:

  • activeConn map[*conn]struct{}: множество всех открытых клиентских TCP-соединений. Текущий статус сокета (StateNew, StateActive, StateIdle, StateClosed) хранится непосредственно в самом экземпляре *conn и защищается его внутренним атомарным состоянием (c.getState()).

При вызове Shutdown(ctx):

  1. Атомарно выставляется флаг завершения работы s.inShutdown.Store(true).
  2. Закрываются все слушающие сокеты net.Listener, что прекращает прием новых входящих запросов и заставляет Serve() немедленно вернуть ErrServerClosed.
  3. В отдельных горутинах вызываются зарегистрированные хуки s.onShutdown.
  4. Метод closeIdleConns() обходит s.activeConn под s.mu.Lock() и немедленно закрывает все соединения, находящиеся в статусе StateIdle.
  5. Сервер запускает цикл ожидания с экспоненциальным поллингом и джиттером (с шагом от 1 мс до 500 мс), ожидая перехода оставшихся соединений в статус завершенных и их удаления из activeConn.
  6. Если переданный контекст ctx истекает до того, как все горутины обработчиков закончили работу, метод Shutdown завершается с возвратом ошибки ctx.Err(). Для жесткого принудительного обрыва оставшихся сокетов используется вызов server.Close().

Production-реализация

Полноценный канонический шаблон graceful shutdown с раздельными контекстами, логированием и очисткой внешних ресурсов:

Go
package main

import (
    "context"
    "errors"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // 1. Инициализация сервера с адекватными таймаутами
    srv := &http.Server{
        Addr:              ":8080",
        ReadTimeout:       10 * time.Second,
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       60 * time.Second,
        Handler:           setupRouter(),
    }

    // 2. Запуск в отдельной горутине
    go func() {
        log.Println("Starting HTTP server on :8080")
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("HTTP server fatal error: %v", err)
        }
    }()

    // 3. Ожидание сигнала завершения ОС
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    <-quit
    log.Println("Shutdown signal received. Starting graceful shutdown...")

    // 4. Контекст с жестким таймаутом на весь процесс остановки
    // Важно: таймаут должен быть меньше terminationGracePeriodSeconds в K8s
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer shutdownCancel()

    // 5. Остановка HTTP-сервера
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("HTTP server shutdown error: %v", err)
    }

    // 6. Очистка внешних зависимостей
    if err := cleanupDependencies(shutdownCtx); err != nil {
        log.Printf("Dependencies cleanup failed: %v", err)
    }

    log.Println("Server exited gracefully")
}

func cleanupDependencies(ctx context.Context) error {
    // Закрытие пула БД, отправка финальных метрик, flush логов
    // db.Close(), redis.Close(), otel.Shutdown(ctx)
    return nil
}

func setupRouter() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(50 * time.Millisecond)
        w.Write([]byte("OK"))
    })
    return mux
}

Управление фоновыми процессами и зависимостями

http.Server.Shutdown не останавливает горутины, запущенные внутри обработчиков. Если ваш хендлер запускает фоновую задачу (отправка email, асинхронная запись в Kafka), вы обязаны передать r.Context() и отслеживать его отмену.

Go
func longTaskHandler(w http.ResponseWriter, r *http.Request) {
    // Контекст автоматически отменится при graceful shutdown или разрыве соединения
    ctx := r.Context()

    // Асинхронная работа должна проверять ctx.Done()
    select {
    case <-ctx.Done():
        log.Println("Background task cancelled due to shutdown/client disconnect")
        return
    case <-time.After(5 * time.Second):
        // Завершение задачи
        log.Println("Task completed")
    }
}

Для тяжелых фоновых воркеров используйте sync.WaitGroup и отдельный сигнал отмены:

Go
var workerWG sync.WaitGroup

func startWorker(ctx context.Context) {
    workerWG.Add(1)
    go func() {
        defer workerWG.Done()
        for {
            select {
            case <-ctx.Done():
                log.Println("Worker stopping gracefully")
                return
            default:
                // Обработка задачи
                time.Sleep(100 * time.Millisecond)
            }
        }
    }()
}

// В main, после srv.Shutdown:
// cancelWorkerCtx()
// workerWG.Wait()

Короче: signal.NotifyContext (Go 1.16+)

Связку chan os.Signal + signal.Notify с Go 1.16 заменяет контекст, который отменяется при получении сигнала. Он сразу встраивается в цепочку контекстов приложения:

Go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        slog.Error("server failed", "error", err)
        os.Exit(1)
    }
}()

<-ctx.Done() // SIGINT или SIGTERM
stop()       // вернуть обработку сигналов по умолчанию: второй Ctrl+C завершит процесс сразу

shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    slog.Error("graceful shutdown failed", "error", err)
}

Контекст завершения создаётся от context.Background(), а не от ctx: тот уже отменён сигналом, и Shutdown с ним вернулся бы мгновенно.


Kubernetes: почему запросы теряются даже с правильным Shutdown

Под завершают по многим причинам: rolling update новой версии, сжатие автоскейлером при спаде нагрузки, выселение с ноды на обслуживание (Node Drain, Eviction), перезапуск после превышения лимита памяти. Так же устроены и другие оркестраторы (AWS ECS, Nomad). Kubernetes выводит под из эксплуатации в три фазы:

  1. SIGTERM. Kubelet отправляет процессу (PID 1 контейнера) POSIX-сигнал 15 — вежливую просьбу завершиться.
  2. Окно terminationGracePeriodSeconds (по умолчанию 30 секунд). Процесс жив и доделывает работу.
  3. SIGKILL. Если процесс не завершился, ядро убивает его сигналом 9. Этот сигнал нельзя перехватить, заблокировать или проигнорировать.

Код выше всё делает правильно, и всё же при первом rolling update клиенты видят 502 Bad Gateway и Connection refused. Причина в том, что Kubernetes — асинхронная распределённая система. Решив удалить под, Control Plane одновременно отправляет SIGTERM и сообщает kube-proxy и Ingress-контроллерам на всех нодах: «уберите IP этого пода из маршрутов (EndpointSlice, iptables/IPVS/eBPF)».

sequenceDiagram
    autonumber
    participant K8s as K8s Control Plane
    participant Ingress as Ingress / kube-proxy
    participant Pod as Go Сервер (Pod)

    K8s->>Pod: 1. Отправка SIGTERM (Мгновенно!)
    K8s->>Ingress: 2. Асинхронное обновление EndpointSlice

    Note over Pod: Go ловит сигнал за 1 мс<br>srv.Shutdown() закрывает порт :8080!

    Note over Ingress: Обновление iptables занимает 2–4 секунды!
    Ingress->>Pod: 3. Новый клиентский HTTP-запрос!
    Pod--xIngress: 💥 Connection Refused (TCP RST) -> 502 Bad Gateway!
  

Сигнал процесс получает за доли миллисекунды и тут же закрывает слушающий сокет. А новость об удалении пода расходится по нодам кластера 2–5 секунд. Всё это время балансировщик считает под живым и шлёт в него запросы на уже закрытый порт.


Ожидание фоновых задач с дедлайном

workerWG.Wait() из раздела выше ждёт бесконечно. Если один воркер завис, процесс так и не дойдёт до db.Close(), а через 30 секунд его убьёт SIGKILL. Ожидание стоит ограничить тем же контекстом завершения:

Go
package worker

import (
    "context"
    "sync"
)

type BackgroundWorker struct {
    wg sync.WaitGroup
}

// RunTask безопасно регистрирует и запускает фоновую задачу
func (w *BackgroundWorker) RunTask(task func()) {
    w.wg.Add(1)
    go func() {
        defer w.wg.Done()
        task() // Выполнение бизнес-логики
    }()
}

// Wait ожидает завершения всех запущенных горутин или истечения дедлайна контекста
func (w *BackgroundWorker) Wait(ctx context.Context) error {
    done := make(chan struct{})
    go func() {
        w.wg.Wait()
        close(done)
    }()

    select {
    case <-done:
        return nil // Все фоновые воркеры завершились штатно
    case <-ctx.Done():
        return ctx.Err() // Таймаут Graceful Shutdown: принудительный выход
    }
}

Вызов worker.Wait(shutdownCtx) ставится после srv.Shutdown и перед закрытием базы данных.


gRPC: GracefulStop() и Stop()

Проверить, что всё это действительно работает, можно только убивая поды под нагрузкой: как поставить такой эксперимент, разобрано в 35. Chaos testing API.


Итог

  1. Graceful shutdown — обязательный атрибут production-сервиса, работающего в кластере.
  2. http.Server.Shutdown(ctx) закрывает listener, ждет завершения активных запросов и корректно закрывает соединения.
  3. Всегда используйте контекст с таймаутом, меньшим, чем terminationGracePeriodSeconds оркестратора.
  4. Фоновые горутины и зависимости должны явно отслеживать отмену контекста или использовать sync.WaitGroup.
  5. В Kubernetes остановку listener'а задерживают на несколько секунд (The Sleep Hack: пауза в коде или хук preStop), чтобы маршруты успели забыть под.
  6. Ожидание фоновых задач и gRPC GracefulStop() ограничиваются тем же дедлайном: по его истечении — ctx.Err() и принудительный Stop().
  7. Связка SIGTERM -> пауза -> Shutdown -> cleanup -> exit 0 гарантирует нулевую потерю данных и отсутствие 502 при деплое.

Следующая статья: 11. Конфигурация приложения