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