Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
18.06.2026
Обновлено:
09.07.2026

Graceful shutdown в Go - пошаговый разбор с кодом для продакшена

Данил Мануйлов

Каждый раз при рестарте сервиса часть пользователей получает ошибку. Не потому что упал сам сервис - просто он не умеет правильно прощаться. В этой статье разберём, как реализовать graceful shutdown в Go: от перехвата сигнала до закрытия базы данных - с рабочим кодом и разбором типичных ловушек.

Что такое graceful shutdown и почему сервис теряет запросы

Как Go завершает процесс по умолчанию

Когда вы деплоите новую версию сервиса, Kubernetes отправляет процессу сигнал SIGTERM. Если в коде нет обработчика - Go runtime завершит приложение немедленно. Все запросы, которые в этот момент обрабатывались, оборвутся на полуслове. Клиент получит 502 Bad Gateway или сброс соединения.

Graceful shutdown решает это в три шага:

  1. Закрыть точку входа - перестать принимать новые запросы.
  2. Дождаться текущих - дать активным обработчикам завершиться.
  3. Освободить ресурсы - закрыть соединения с БД, брокерами, кешами.

Разница между graceful и hard shutdown

server.Close() - жёсткое завершение. Он немедленно закрывает все активные соединения. Обработчик, пишущий в БД, получит ошибку сети прямо посередине транзакции. Клиент получит ECONNRESET (socket hang up).

server.Shutdown(ctx) - мягкий вариант. Он закрывает listeners (новые соединения невозможны), но ждёт, пока существующие обработчики сами закончат работу. Только после этого метод возвращает управление.

Нюанс: даже после server.Shutdown() обработчики, не читающие из сети, могут продолжать работу в фоне. Поэтому одного Shutdown недостаточно - нужен ещё и сигнал через context.

Когда особенно важно: Kubernetes и деплой

При rolling update Kubernetes отправляет поду SIGTERM. Параметр terminationGracePeriodSeconds задаёт, сколько времени есть у сервиса на завершение - по умолчанию 30 секунд. Если процесс не завершился сам, Kubernetes отправит SIGKILL. SIGKILL нельзя перехватить или обработать - процесс просто убивается.

Перехват системных сигналов в Go

SIGTERM, SIGINT, SIGHUP - что важно знать

Три сигнала важны для практики:

  • SIGTERM - «вежливая» просьба завершиться. Kubernetes, Docker, systemd посылают именно его.
  • SIGINT - Ctrl+C из терминала. Удобен для локальной разработки.
  • SIGKILL - принудительное убийство. Нельзя перехватить или заблокировать.

SIGHUP раньше означал «терминал отключился», сейчас чаще используется для перезагрузки конфигурации без рестарта.

signal.Notify: буферизированный канал

Пакет os/signal позволяет перехватить сигналы и направить их в канал:

signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

<-signalChan
fmt.Println("Получен сигнал завершения, начинаем shutdown...")

Почему буфер равен 1? Go отправляет сигналы в канал через select с default-веткой. Если канал без буфера и горутина ещё не слушает - сигнал будет потерян. Буфер 1 гарантирует, что первый сигнал не пропадёт.

signal.NotifyContext - современный подход (Go 1.16+)

С Go 1.16 появился signal.NotifyContext:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// инициализация...
<-ctx.Done()
stop() // важно! иначе второй Ctrl+C не завершит процесс принудительно

После ctx.Done() нужно вызвать stop() снова. Без этого повторный Ctrl+C не даст нового SIGKILL - только ещё один SIGINT, который уже обработан и проигнорирован.

Реализация: от сигнала до остановки сервера

http.Server.Shutdown(): как работает под капотом

ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()

if err := server.Shutdown(ctx); err != nil {
    log.Printf("Shutdown error: %v", err)
}

Метод Shutdown(ctx) возвращается в двух случаях:

  1. Все активные соединения закрыты, все обработчики завершились - OK.
  2. Контекст истёк раньше - сервер перестаёт ждать, но незавершённые обработчики продолжают работу.

Во втором случае обработчик может оставить транзакцию открытой или записать в БД частичные данные. Поэтому обработчики обязаны проверять ctx.Done() и уметь завершаться досрочно.

Когда ListenAndServe получает сигнал через Shutdown, он возвращает http.ErrServerClosed. Это нормальное поведение - не ошибка:

if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    log.Fatalf("Unexpected error: %v", err)
}

Передача shutdown-сигнала в обработчики через context

Обработчик по умолчанию не знает, что сервер завершается. server.Shutdown закрывает listeners, но активные goroutine продолжают выполняться.

Способ 1 - BaseContext:

ongoingCtx, cancelFn := context.WithCancel(context.Background())

server := &http.Server{
    Addr: ":8080",
    BaseContext: func(_ net.Listener) context.Context {
        return ongoingCtx
    },
}

// При shutdown:
cancelFn()

BaseContext передаёт единый корневой контекст всем входящим соединениям. При вызове cancelFn() контекст отменяется во всех обработчиках через ctx.Done().

Способ 2 - middleware:

func WithGracefulShutdown(next http.Handler, cancelCh <-chan struct{}) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithCancel(r.Context())
        defer cancel()

        go func() {
            select {
            case <-cancelCh:
                cancel()
            case <-ctx.Done():
            }
        }()

        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

Практический опыт: в продакшен-сервисах мы используем BaseContext - он проще и не создаёт лишних горутин на каждый запрос. Middleware полезен, когда нужна более тонкая логика отмены для разных групп роутов.

Middleware-подход и atomic.Bool для readiness

Есть важный момент специфичный для Kubernetes: даже после SIGTERM к поду ещё несколько секунд приходит трафик. kube-proxy быстро обновляет внутренний routing, но внешний load balancer работает по своим health check-ам.

Правильная последовательность:

var isShuttingDown atomic.Bool

// readiness endpoint
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    if isShuttingDown.Load() {
        http.Error(w, "shutting down", http.StatusServiceUnavailable)
        return
    }
    fmt.Fprintln(w, "OK")
})

При получении SIGTERM: сначала выставить флаг, подождать 5 с, потом вызвать Shutdown.

Таймауты и Kubernetes: не потерять запросы

terminationGracePeriodSeconds и бюджет времени

По умолчанию у вас 30 секунд между SIGTERM и SIGKILL. Резервируйте ~20% на запас - итого целевое время завершения: 25 секунд.

Это время должно включать:

  • Задержку readiness drain (~5 с)
  • Ожидание завершения активных запросов (server.Shutdown)
  • Закрытие БД, брокеров, кешей

Конкретный расчёт:

5 с (readiness drain) + 15 с (in-flight requests) + 3 с (hard stop) + 2 с (DB close) = 25 с

preStop hook: задержка перед SIGTERM

preStop hook запускается до SIGTERM. Он нужен, чтобы дать внешнему load balancer время убрать под из ротации:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

Время preStop входит в terminationGracePeriodSeconds. При sleep 5 у вас остаётся 25 секунд на сам shutdown.

Readiness probe: правильная последовательность

Порядок важен:

  1. Получить SIGTERM / поставить isShuttingDown = true
  2. Readiness endpoint начинает возвращать HTTP 503
  3. Подождать periodSeconds × 1–2 = ~5 с для пропагации в kube-proxy и LB
  4. Вызвать server.Shutdown(ctx) с таймаутом 15–20 с
  5. Вызвать cancelFn() для завершения обработчиков через context
  6. Закрыть DB, cache через defer (LIFO)

Конфигурация readiness probe:

readinessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5
  failureThreshold: 1

Освобождение ресурсов: БД, брокеры, воркеры

defer и LIFO: порядок закрытия компонентов

defer выполняет функции в обратном порядке (LIFO). Это точно соответствует логике shutdown: зависимые компоненты закрываются первыми.

db := connectDB()
defer db.Close() // закроется последним

cache := connectCache()
defer cache.Close() // закроется вторым (до db)

broker := connectBroker()
defer broker.Close() // закроется первым

Если broker зависит от db, не инвертируйте порядок - получите panic.

Транзакции и message broker - особые случаи

Незакрытые транзакции БД вынуждают сервер базы данных ждать таймаута соединения. Всегда оборачивайте транзакционный код в контекст:

func processOrder(ctx context.Context, db *sql.DB) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // откатит, если Commit не был вызван

    // ... работа с транзакцией ...

    return tx.Commit()
}

Если ctx отменится при shutdown - BeginTx или следующий SQL-вызов вернут ошибку, defer tx.Rollback() откатит незаконченную работу.

Для Kafka-консьюмера нечистое завершение провоцирует rebalancing: группа ожидает хартбита, не получает его и перераспределяет партиции. Вызывайте consumer.Close() явно перед завершением.

Утечки горутин и time.After

До Go 1.23 time.After(d) создавал внутренний таймер, который не освобождался до срабатывания - даже если select уже выбрал другую ветку. При высокой нагрузке это накапливалось в утечки памяти.

Безопасный аналог для старых версий:

t := time.NewTimer(duration)
defer t.Stop()

select {
case <-t.C:
    // таймаут
case <-ctx.Done():
    if !t.Stop() {
        <-t.C
    }
    return ctx.Err()
}

В Go 1.23+ time.After безопасен - GC корректно собирает таймер при ранней отмене.

Полный рабочий пример

Собираем всё вместе:

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "net/http"
    "os/signal"
    "sync/atomic"
    "syscall"
    "time"
)

const (
    shutdownPeriod    = 15 * time.Second
    shutdownHardStop  = 3 * time.Second
    readinessDrainDelay = 5 * time.Second
)

var isShuttingDown atomic.Bool

func main() {
    // 1. Контекст на сигналы
    rootCtx, stop := signal.NotifyContext(
        context.Background(),
        syscall.SIGINT, syscall.SIGTERM,
    )
    defer stop()

    // 2. Readiness endpoint
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        if isShuttingDown.Load() {
            http.Error(w, "shutting down", http.StatusServiceUnavailable)
            return
        }
        fmt.Fprintln(w, "OK")
    })

    // 3. Пример обработчика - контекст-осознанный
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        select {
        case <-time.After(2 * time.Second):
            fmt.Fprintln(w, "Hello!")
        case <-r.Context().Done():
            http.Error(w, "request cancelled", http.StatusRequestTimeout)
        }
    })

    // 4. Сервер с BaseContext для propagation shutdown
    ongoingCtx, cancelOngoing := context.WithCancel(context.Background())
    server := &http.Server{
        Addr: ":8080",
        BaseContext: func(_ net.Listener) context.Context {
            return ongoingCtx
        },
    }

    go func() {
        log.Println("Starting on :8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("ListenAndServe error: %v", err)
        }
    }()

    // 5. Ждём сигнал
    <-rootCtx.Done()
    stop()
    isShuttingDown.Store(true)
    log.Println("Signal received, starting graceful shutdown")

    // 6. Даём LB время убрать под из ротации
    time.Sleep(readinessDrainDelay)

    // 7. Останавливаем приём новых запросов, ждём текущие
    shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownPeriod)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Printf("Shutdown did not complete in time: %v", err)
        time.Sleep(shutdownHardStop)
    }

    // 8. Отменяем контекст для всех обработчиков
    cancelOngoing()

    log.Println("Server stopped gracefully")

    // 9. Здесь закрываем DB, cache через defer (см. выше)
}

Авторская ремарка: В реальных проектах я выношу shutdown-логику в отдельный ShutdownManager с поддержкой errgroup. Это позволяет параллельно останавливать несколько компонентов (HTTP, gRPC, Kafka consumer) с общим контекстом и коллекцией ошибок.

Альтернативный взгляд

Некоторые команды избегают сложного graceful shutdown и делают ставку на идемпотентность операций: каждый запрос можно безопасно повторить, поэтому потеря нескольких запросов при рестарте некритична. Это легитимный подход для Read-heavy API с кешированием. Но для платёжных систем, очередей и любых write-операций идемпотентности недостаточно - там graceful shutdown обязателен.

Нетривиальный факт

Go runtime сам регистрирует обработчики для SIGTERM, SIGQUIT, SIGILL, SIGTRAP ещё до вызова main(). При получении SIGTERM без пользовательского обработчика runtime временно блокирует свой обработчик и ретранслирует тот же SIGTERM обратно в процесс - на этот раз ОС обрабатывает его по умолчанию (kill). Именно поэтому достаточно одного вызова signal.Notify - вы буквально перехватываете управление у runtime.

FAQ

Почему нужен буфер 1 в канале для signal.Notify?

Go отправляет сигналы через select с default-веткой. Если канал без буфера и горутина не слушает в этот момент - сигнал потеряется. Буфер 1 страхует от пропуска первого сигнала при старте приложения.

Можно ли вызвать server.Close() после server.Shutdown()?

Можно, но осторожно. server.Close() немедленно обрывает все сетевые соединения. Обработчики, не взаимодействующие с сетью в момент вызова, продолжат работу в фоне. Надёжнее использовать cancellation через context.

Что такое terminationGracePeriodSeconds в Kubernetes?

Максимальное время (по умолчанию 30 секунд) между SIGTERM и принудительным SIGKILL. Весь shutdown - readiness drain, ожидание запросов, закрытие ресурсов - должен уложиться в этот период.

Зачем preStop hook, если shutdown реализован в коде?

preStop запускается до SIGTERM и даёт внешнему load balancer время убрать под из ротации. Без него трафик идёт к завершающемуся поду ещё несколько секунд после SIGTERM. Время preStop входит в terminationGracePeriodSeconds.

Как обработчики узнают о начале shutdown?

Через BaseContext на http.Server. При shutdown вызывается cancelFn(), контекст отменяется, все обработчики получают сигнал через ctx.Done(). Альтернатива - middleware, оборачивающее каждый запрос.

### time.After течёт в памяти в Go? До Go 1.23 - да: таймер не GC-лся до срабатывания, даже если select выбрал другую ветку. С Go 1.23 исправлено. В старых версиях используйте time.NewTimer + Stop().

Как тестировать graceful shutdown?

Вынесите логику из main() в отдельную функцию. В тесте запустите сервер, создайте медленный запрос, имитируйте сигнал через os.Process.Signal() и проверьте, что запрос завершился корректно без ошибки.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также