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

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

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

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

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

В 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 func() { defer schedulerWg.Done(); 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.

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

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

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 при старте.

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

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

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

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

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

Коротко

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

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

  • HTTP drain — srv.Shutdown, in-flight запросы, долгие эндпоинты.
  • Фоновые задачи и outbox — sync.WaitGroup, ctx.Done(), outbox-relay цикл.
  • Kafka shutdown — consumer, CommitMessages, writer.Close().
  • Бюджеты и observability — структурный лог и метрики завершения.