← назад к разделу

KUBERNETES И GRACEFUL SHUTDOWN БД и persistence

Обязательно

БД и persistence при graceful shutdown Go

pgxpool · Transactions · WaitGroup

Активные транзакции завершаются до закрытия пула, а pool.Close идёт последним после ожидания workers.

1. Транзакция завершается до pool.Close

1 / 5

Закрытие пула посреди транзакции обрывает commit или rollback. Код сначала прекращает новые операции и ждёт активные.

  • Новые DB-вызовы прекращаются.
  • Активные транзакции получают дедлайн.
  • Пул закрывается последним.
Stop new DB workFinish transactionspool.CloseСначала commit или rollback

2. Короткая транзакция получает свой дедлайн

2 / 5

Отдельный shutdown context ограничивает commit и rollback. Долгая операция возвращает ошибку и не держит соединение вечно.

  • Дедлайн меньше общего бюджета.
  • Неиспользуемый context.Background для finish.
  • Ошибка фиксируется метрикой и логом.
UPDATECOMMITDeadlineПосле него rollback и освобождение

3. WaitGroup ждёт все DB-workers

3 / 5

Каждый worker добавляет себя в WaitGroup, main ждёт их завершения и только потом вызывает pool.Close.

  • Новые workers не запускаются.
  • Текущие получают cancel.
  • WaitGroup отделяет работу от ресурса.
Worker AWorker Bwg.Waitpool.Close

4. Миграции выполняются при старте

4 / 5

golang-migrate запускается до открытия трафика. При остановке новая миграция не начинается, текущая завершается по правилам БД.

  • Readiness ждёт миграции.
  • Миграция не дублируется на каждом поде.
  • Пул не закрывается перед ею.
Migrate startupReadyTrafficShutdown относится к рабочим вызовам

5. Проверка доказывает порядок ресурсов

5 / 5

Тест имитирует активную транзакцию и SIGTERM. pool.Close вызывается только после commit и wg.Wait.

  • Активный commit успевает.
  • Новые вызовы отклоняются.
  • Ошибка не маскируется.
SIGTERM → stop new workactive transaction → COMMITwg.Wait → pool.Close

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

Почему нельзя закрыть пул сразу

Если вызвать pool.Close() раньше, чем завершились фоновые горутины, получится проблема. Close в pgxpool не рвёт занятые соединения: он закрывает свободные, запрещает новые Acquire и блокируется, пока занятые не вернут в пул. Начатую транзакцию он не оборвёт, но scheduler или outbox-relay на следующем Begin получат ошибку closed pool посреди бизнес-операции, а горутина, которая соединение не отпускает, повесит остановку до SIGKILL.

Правило простое: пул соединений закрывается последним — после того как все горутины отработали до конца.

В main это выглядит как явная последовательность шагов:

func run(ctx context.Context, cfg Config) error {
    pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
    if err != nil {
        return fmt.Errorf("pgxpool.New: %w", err)
    }

    appState := health.NewState()

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

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

    queries := db.New(pool)
    consumer := consumer.NewOrderConsumer(queries)
    relay := scheduler.NewOutboxRelay(queries, pool)

    consumerWg.Add(1)
    go func() { defer consumerWg.Done(); consumer.Run(consumerCtx) }()

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

    srv := buildServer(cfg, appState, queries)
    go srv.ListenAndServe()

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

    <-sigC
    slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown")

    // 1. readiness → 503: k8s убирает pod из endpoints
    appState.SetNotReady()

    // 2. consumer: сигнал остановки + ожидание текущего сообщения
    cancelConsumer()
    consumerWg.Wait()

    // 3. scheduler: сигнал остановки + ожидание текущей итерации
    cancelScheduler()
    schedulerWg.Wait()

    // 4. HTTP: дожать in-flight запросы
    shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutCtx); err != nil {
        slog.WarnContext(ctx, "http shutdown", "error", err)
    }

    // 5. пул БД — последним
    pool.Close()
    slog.InfoContext(ctx, "pgxpool закрыт")

    return nil
}

Consumer и scheduler держат соединения из пула. Шаги 2 и 3 гарантируют, что они закончили работу до шага 5. Обратите внимание на запуск relay: его Run сам делает defer wg.Done(), поэтому оборачивать вызов во вторую горутину с ещё одним Done нельзя — счётчик уйдёт в минус, а это уже настоящая panic sync: negative WaitGroup counter.

Как дожимать активные транзакции

Механизм зависит от того, кто держит транзакцию.

HTTP-handler

sqlc-запросы в handler выполняются в рамках пула; srv.Shutdown даёт им завершиться. Запрос либо успевает выполнить операцию и вернуть ответ, либо укладывается в shutdown-таймаут — в обоих случаях состояние базы остаётся согласованным:

func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("decode request"))
        return
    }

    order, err := h.queries.CreateOrder(r.Context(), db.CreateOrderParams{
        CustomerID: req.CustomerID,
        Amount:     req.Amount,
    })
    if err != nil {
        httperr.Write(w, r, fmt.Errorf("create order: %w", err))
        return
    }

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(order)
}

Фоновая горутина

Здесь есть важный момент. Если контекст уже отменён (пришёл ctx.Done()), но транзакция уже начата — прерывать её нельзя. Поэтому для транзакций вне HTTP используют context.Background():

func (s *PaymentSettler) settle(ctx context.Context, orderID uuid.UUID) error {
    // ctx может быть отменён — открываем транзакцию на Background,
    // чтобы SIGTERM не прервал начатую запись
    tx, err := s.pool.Begin(context.Background())
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(context.Background())

    q := db.New(tx)

    order, err := q.LockOrderForUpdate(context.Background(), orderID)
    if err != nil {
        return fmt.Errorf("lock order %s: %w", orderID, err)
    }

    if err := q.UpdateOrderStatus(context.Background(), db.UpdateOrderStatusParams{
        ID:     order.ID,
        Status: db.OrderStatusPaid,
    }); err != nil {
        return fmt.Errorf("update order status %s: %w", orderID, err)
    }

    if err := tx.Commit(context.Background()); err != nil {
        return fmt.Errorf("commit payment settle %s: %w", orderID, err)
    }

    return nil
}

Сигнал через ctx.Done() означает «не начинай следующую итерацию», а не «прервись прямо сейчас». Горутина проверяет это перед новым витком цикла:

func (s *PaymentSettler) Run(ctx context.Context, wg *sync.WaitGroup) {
    defer wg.Done()
    ticker := time.NewTicker(s.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            slog.InfoContext(ctx, "payment settler: завершаем работу")
            return
        case <-ticker.C:
            if err := s.processNextOrder(ctx); err != nil {
                slog.WarnContext(ctx, "payment settler", "error", err)
            }
        }
    }
}

Outbox-relay

Relay захватывает пачку событий атомарно через FOR UPDATE SKIP LOCKED. Начатую пачку нужно довести до конца:

func (r *OutboxRelay) processOneBatch(ctx context.Context) error {
    tx, err := r.pool.Begin(context.Background())
    if err != nil {
        return fmt.Errorf("begin outbox tx: %w", err)
    }
    defer tx.Rollback(context.Background())

    q := db.New(tx)

    events, err := q.LockOutboxBatch(context.Background(), batchSize)
    if err != nil {
        return fmt.Errorf("lock outbox batch: %w", err)
    }

    for _, e := range events {
        if err := r.producer.Publish(context.Background(), e); err != nil {
            return fmt.Errorf("publish event %s: %w", e.ID, err)
        }
        if err := q.MarkDispatched(context.Background(), e.ID); err != nil {
            return fmt.Errorf("mark dispatched %s: %w", e.ID, err)
        }
    }

    if err := tx.Commit(context.Background()); err != nil {
        return fmt.Errorf("commit outbox batch: %w", err)
    }

    return nil
}

Миграции запускаются только при старте

Распространённое заблуждение: «нужно что-то почистить в базе при остановке». Это почти никогда не нужно. Схема базы данных не откатывается при остановке сервиса.

golang-migrate запускается один раз — при старте, до создания пула:

func applyMigrations(ctx context.Context, databaseURL string) error {
    m, err := migrate.New("file://migrations", databaseURL)
    if err != nil {
        return fmt.Errorf("migrate.New: %w", err)
    }
    defer m.Close()

    if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return fmt.Errorf("migrate up: %w", err)
    }

    slog.InfoContext(ctx, "миграции применены")
    return nil
}

На shutdown делать с миграциями ничего не нужно — m.Close() уже вызван через defer при старте.

Долгие транзакции мешают остановке и соседям

Транзакция, которая держит блокировку, при остановке становится проблемой дважды: она задерживает завершение пода и блокирует соседей, пока не закончится. Опаснее всего сочетание «долгая транзакция плюс блокировка строк»:

func (p *Notifier) processBatch(ctx context.Context) error {
    return pgx.BeginFunc(ctx, p.pool, func(tx pgx.Tx) error {
        orders, err := p.repo.LockPendingForUpdate(ctx, tx, 1000) // SELECT ... FOR UPDATE
        if err != nil {
            return err
        }
        for _, order := range orders {
            if err := p.external.Notify(ctx, order); err != nil { // сетевой вызов внутри транзакции
                return err
            }
            if err := p.repo.MarkNotified(ctx, tx, order.ID); err != nil {
                return err
            }
        }
        return nil
    })
}

Здесь тысяча строк заблокирована на всё время обхода, а внутри ещё и сетевой вызов, длительность которого вам не принадлежит. При остановке main ждёт эту горутину, съедая бюджет; если не дождётся, транзакция оборвётся на середине.

Что делают, чтобы этого не было: пачки по сто строк и транзакция на пачку, а не на весь прогон; никаких внешних вызовов внутри транзакции, уведомление уходит после фиксации; проверка ctx.Err() между пачками, чтобы цикл остановился сам, зафиксировав сделанное; FOR UPDATE SKIP LOCKED вместо FOR UPDATE, чтобы остановка одного пода не тормозила остальных. Проверить, есть ли у вас такие транзакции, можно одним запросом под нагрузкой: транзакции длительностью больше нескольких секунд видны в pg_stat_activity по xact_start.

Если база недоступна в момент остановки

Под останавливается, а база в это время недоступна: упала, потеряна сеть, кончились соединения. Любой шаг остановки, который пытается получить соединение (отметить задачу, записать последнее состояние), уходит в ожидание на весь таймаут пула, и один такой шаг съедает бюджет остальных.

Правило: в завершении к базе не обращаются, если без этого можно обойтись, а если обращение нужно, у него свой короткий таймаут, и отказ не мешает остановке продолжаться:

func markStopped(ctx context.Context, pool *pgxpool.Pool, nodeID string) {
    ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
    defer cancel()
    if _, err := pool.Exec(ctx, "UPDATE nodes SET stopped_at = now() WHERE id = $1", nodeID); err != nil {
        slog.Warn("не удалось отметить остановку узла, продолжаем", "err", err)
    }
}

Проверяется на стенде за минуту: остановите базу и сразу за ней под, посмотрите, за какое время завершился процесс. Если вместо «остановился за три секунды» получилось принудительное завершение через шестьдесят, в цепочке завершения есть шаг, который ждёт соединения.

Частые ошибки

pool.Close() до WaitGroup.Wait() — горутины нарвутся на closed pool при следующем Begin, а Close повиснет на соединении, которое горутина ещё держит. Сначала дожидайтесь завершения всех горутин, потом закрывайте пул.

// так не надо: пул закрыт, а горутины ещё в транзакциях
cancel()
pool.Close()
wg.Wait()

pool.Close() в отдельной горутине без синхронизации — порядок завершения становится недетерминированным. Последовательные shutdown-шаги в main — единственный надёжный способ.

tx.Begin(r.Context()) в критичной секции фоновой задачи — если контекст уже отменён, транзакция упадёт на старте. Для фоновых задач вне HTTP используйте context.Background().

slog.Error при нормальном pool.Close() — нормальное закрытие пула не требует уровня Error. Используйте slog.Info.

Дополнительно: при первом чтении можно пропустить

Глубже: WithoutCancel вместо голого Backgroundрасширенное

context.Background() в транзакции фоновой горутины решает задачу «не прервать начатое», но теряет всё, что лежало в контексте: trace-span, идентификатор запуска, поля логгера. С Go 1.21 есть context.WithoutCancel(ctx): он возвращает контекст с теми же значениями, но без отмены родителя. А срок жизни транзакции задают отдельно — иначе зависшая база держит остановку до SIGKILL:

txCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
tx, err := s.pool.Begin(txCtx)

Тот же приём годится для всего, что должно доработать после отмены общего контекста: коммит offset в Kafka, отправка пачки outbox, финальная запись в лог.

Коротко

  • Пул соединений закрывается последним — после WaitGroup.Wait() для всех горутин.
  • Отмена контекста означает «не начинай следующий шаг», а не «прервись прямо сейчас».
  • Транзакции в фоновых горутинах открываются на context.Background() — чтобы SIGTERM не прервал уже начатую запись.
  • HTTP-транзакции дожимаются через srv.Shutdown с таймаутом.
  • golang-migrate — только при старте; на shutdown с базой ничего делать не нужно.
  • Нормальный pool.Close() логируется на уровне Info, не Error.
  • context.WithoutCancel(ctx) с собственным таймаутом лучше голого Background: значения контекста остаются, отмена родителя снимается, зависшая база не держит остановку до SIGKILL.

Что почитать дальше