Когда приложение получает 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 — структурный лог и метрики завершения.