Опирается на правила: R-SHUT-CFG-1R-SHUT-CFG-4 и R-SHUT-CFG-X1 из Graceful Shutdown Style Guide → раздел 1. Runtime/конфигурация.

Важно знать

  • http.Server.Shutdown(ctx) — единственный правильный способ остановки; Close() немедленно рвёт активные соединения и даёт 502.
  • context.WithTimeout(context.Background(), 25s) явно — не дефолтный бесконечный контекст; бюджет: preStop 10s + Shutdown ≤25s, остаток для Kafka/БД.
  • appState.SetNotReady() первым, до srv.Shutdown — k8s должен убрать pod из endpoints прежде, чем начнётся дрейн.
  • atomic.Bool — единственный источник readiness-состояния; /health/ready читает только его, ничего параллельного.
  • Раздельные /health/live и /health/ready — readiness-падение убирает pod из трафика, liveness-падение перезапускает pod (чего при shutdown не нужно).
  • os.Signal-канал с буфером 1 — сигнал не теряется, если горутина не успела прочитать мгновенно.
  • var shuttingDown bool без atomic и без связи с health-эндпоинтом — k8s ничего не знает о состоянии pod'а.

Graceful shutdown в Go — не одна функция, а явная последовательность шагов в main: поймать SIGTERM, переключить readiness, дождаться завершения in-flight, потом закрыть БД-пул и выйти. Всё это оркестрирует сам разработчик через os.Signal-канал, context.Context и sync.WaitGroup. Ниже — минимальный правильный набор.

http.Server.Shutdown vs Close

R-SHUT-CFG-1: разница принципиальная.

srv.Shutdown(ctx):

  • ждёт завершения всех активных in-flight запросов;
  • новые соединения не принимает с момента вызова;
  • возвращает nil когда все запросы завершились или ctx истёк.

srv.Close():

  • немедленно закрывает все соединения (включая активные);
  • in-flight запросы получают разрыв → клиент видит 502 Bad Gateway или Connection reset.

В правильной реализации Close() никогда не используется в shutdown-пути:

// internal/server/server.go
func Run(ctx context.Context, srv *http.Server, appState *health.State, cfg Config) error {
    sigC := make(chan os.Signal, 1)
    signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
    defer signal.Stop(sigC)

    errC := make(chan error, 1)
    go func() { errC <- srv.ListenAndServe() }()

    select {
    case sig := <-sigC:
        slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown",
            "signal", sig.String())
    case err := <-errC:
        return err
    }

    appState.SetNotReady() // R-SHUT-CFG-3: readiness → 503 первым

    shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer cancel()
    return srv.Shutdown(shutCtx) // R-SHUT-CFG-1: ждёт in-flight
}

Явный таймаут shutdown

R-SHUT-CFG-2: cfg.ShutdownTimeout — 20–25 секунд; не бесконечность.

// internal/server/config.go
type Config struct {
    Addr            string
    ShutdownTimeout time.Duration // 25s
}
// cmd/order-service/main.go
cfg := server.Config{
    Addr:            ":8080",
    ShutdownTimeout: 25 * time.Second,
}

Бюджет внутри terminationGracePeriodSeconds: 60:

ФазаБюджет
preStop sleep10s
http.Server.Shutdown≤ 25s
kafka consumer drain≤ 15s
pgxpool.Pool.Closeмгновенно (после WaitGroup)

Если ShutdownTimeout задан меньше 20s — p99-запросы под нагрузкой обрываются. Больше 45s — SIGKILL от k8s прилетит посреди дрейна. Долгие синхронные эндпоинты (>10s) — не повод растягивать таймаут; правильный ответ — 202 Accepted + polling (см. HTTP drain, R-SHUT-HTTP-3).

Readiness-состояние — atomic.Bool

R-SHUT-CFG-3: единственный источник истины о готовности принимать трафик.

// internal/health/state.go
package health

import "sync/atomic"

type State struct {
    ready atomic.Bool
}

func NewState() *State {
    s := &State{}
    s.ready.Store(true)
    return s
}

func (s *State) SetNotReady() { s.ready.Store(false) }
func (s *State) IsReady() bool  { return s.ready.Load() }

appState.SetNotReady() вызывается первым на SIGTERM — до srv.Shutdown. Это даёт k8s время (за счёт preStop sleep 10s) обновить endpoints и прекратить слать новый трафик на умирающий pod.

Компоненты, которым нужно знать о состоянии shutdown (например, outbox-relay), читают appState.IsReady():

// internal/scheduler/outbox_relay.go — фрагмент
func (r *OutboxRelay) Run(ctx context.Context, wg *sync.WaitGroup) {
    defer wg.Done()
    ticker := time.NewTicker(r.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := r.processOneBatch(ctx); err != nil {
                slog.WarnContext(ctx, "outbox relay batch",
                    "order_relay", true, "error", err)
            }
        }
    }
}

Отмена контекста (ctx.Done()) — сигнал «не начинать новую итерацию», не «прервать текущую». Текущий batch доводится до конца.

Раздельные /health/live и /health/ready

R-SHUT-CFG-4: два chi-маршрута с разной логикой.

// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, appState *health.State) {
    r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
        if !appState.IsReady() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })
}

Разница критична при shutdown:

  • /health/ready → 503 — k8s убирает pod из Service endpoints, новый трафик перестаёт приходить.
  • /health/live → 503 — k8s считает pod сломанным и перезапускает его. При корректном shutdown liveness должна оставаться 200 OK до последнего.

Если liveness и readiness объединены в одном эндпоинте — на shutdown pod уйдёт в рестарт вместо корректного завершения.

Полная последовательность main

Все элементы собираются в явную shutdown-последовательность:

// cmd/product-service/main.go
func main() {
    ctx := context.Background()

    pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
    if err != nil {
        slog.ErrorContext(ctx, "pgxpool init", "error", err)
        os.Exit(1)
    }

    appState := health.NewState()

    consumerCtx, cancelConsumer := context.WithCancel(ctx)
    var consumerWg sync.WaitGroup

    consumerWg.Add(1)
    go func() {
        defer consumerWg.Done()
        if err := productConsumer.Run(consumerCtx); err != nil {
            slog.ErrorContext(ctx, "product consumer", "error", err)
        }
    }()

    var schedulerWg sync.WaitGroup
    schedulerCtx, cancelScheduler := context.WithCancel(ctx)

    schedulerWg.Add(1)
    go outboxRelay.Run(schedulerCtx, &schedulerWg)

    r := chi.NewRouter()
    health.RegisterHealthRoutes(r, appState)
    // ... остальные маршруты

    srv := &http.Server{Addr: cfg.Addr, Handler: r}

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

    errC := make(chan error, 1)
    go func() { errC <- srv.ListenAndServe() }()

    select {
    case sig := <-sigC:
        slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown",
            "signal", sig.String())
    case err := <-errC:
        slog.ErrorContext(ctx, "server error", "error", err)
        os.Exit(1)
    }

    start := time.Now()

    appState.SetNotReady()                                        // R-SHUT-CFG-3
    cancelConsumer()                                              // R-SHUT-KFK-1
    consumerWg.Wait()
    cancelScheduler()                                             // R-SHUT-SCHED-1
    schedulerWg.Wait()

    shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutCtx); err != nil {                // R-SHUT-CFG-1
        slog.ErrorContext(ctx, "server shutdown", "error", err)
    }

    pool.Close()                                                  // R-SHUT-DB-1: последним

    slog.InfoContext(ctx, "graceful shutdown завершён",
        "duration_s", time.Since(start).Seconds())
}

Что запрещено

АнтипаттернПравилоЧто взамен
srv.Close() вместо srv.Shutdown(ctx)R-SHUT-CFG-1srv.Shutdown(ctx) с таймаутом
context.WithTimeout на context.Background() без явного значенияR-SHUT-CFG-220–25s явно в конфиге
var shuttingDown bool без atomic и без связи с /health/readyR-SHUT-CFG-X1atomic.Bool в health.State
SetNotReady() после srv.Shutdown, а не доR-SHUT-CFG-3readiness переключается первым
Единый /health для live и readyR-SHUT-CFG-4раздельные /health/live и /health/ready
pool.Close() до WaitGroup.Wait() по задачамR-SHUT-DB-X1pgxpool закрывается последним

Куда дальше

  • Бюджеты и observability — app_shutdown_duration_seconds, лог SIGTERM/конца shutdown.
  • БД и persistence — порядок pgxpool.Pool.Close и транзакции на shutdown.
  • HTTP drain — preStop sleep 10, долгие эндпоинты через 202 Accepted.
  • Идемпотентность in-flight — Idempotency-Key и дедупликация.
  • Kafka shutdown — kafka-go consumer context + CommitMessages.
  • Kubernetes — terminationGracePeriodSeconds, probes, maxUnavailable: 0.
  • Scheduled / async / outbox — sync.WaitGroup + ctx.Done() в relay.