Graceful shutdown в Go: завершаемся правильно

Как корректно завершать Go-сервис, не теряя запросы, останавливая фоновые задачи и закрывая ресурсы

При остановке сервиса во время деплоя, рестарта или масштабирования важно не обрывать текущие запросы и не оставлять после себя висящие соединения, фоновые воркеры и незавершённую очистку ресурсов. В Go это обычно решается через сигналы ОС, context и корректное завершение http.Server.

Если завершить процесс слишком грубо, пользователь получит оборванный запрос, а приложение может не успеть закрыть БД, отправить последние метрики или завершить фоновые goroutine.

Таймаут shutdown удобно выносить в переменные окружения — как это сделать, описано в статье Go: конфигурация через env без хаоса. А если сервис работает в Docker, полезно настроить HEALTHCHECK и STOPSIGNAL в Dockerfile — подробности в Production-сборка Docker-образов.

sequenceDiagram participant OS as ОС participant Process as Процесс participant HTTP as HTTP-сервер participant Resources as Ресурсы (БД, телеметрия) OS->>Process: SIGTERM / SIGINT Process->>HTTP: Shutdown(ctx) HTTP->>HTTP: Перестать принимать новые соединения HTTP->>HTTP: Дождаться завершения текущих запросов HTTP->>Process: Соединения закрыты Process->>Resources: Закрыть БД, flush телеметрии Resources->>Process: Cleanup завершён Process->>OS: exit 0

sequenceDiagram
    participant OS as ОС
    participant Process as Процесс
    participant HTTP as HTTP-сервер
    participant Resources as Ресурсы (БД, телеметрия)

    OS->>Process: SIGTERM / SIGINT
    Process->>HTTP: Shutdown(ctx)
    HTTP->>HTTP: Перестать принимать новые соединения
    HTTP->>HTTP: Дождаться завершения текущих запросов
    HTTP->>Process: Соединения закрыты
    Process->>Resources: Закрыть БД, flush телеметрии
    Resources->>Process: Cleanup завершён
    Process->>OS: exit 0
Последовательность graceful shutdown в Go-сервисе

Минимальный пример

package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	srv := &http.Server{Addr: ":8080"}
	// Буферизированный канал для ошибок сервера — не блокирует goroutine
	serverErr := make(chan error, 1)

	go func() {
		slog.Info("server starting", "addr", srv.Addr)
		// ErrServerClosed — штатная остановка, не ошибка
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			serverErr <- err
		}
	}()

	// Ждём сигнал завершения
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

	// Блокируемся до получения сигнала или ошибки сервера
	select {
	case sig := <-quit:
		slog.Info("shutdown signal received", "signal", sig.String())
	case err := <-serverErr:
		slog.Error("server error", "err", err)
		os.Exit(1)
	}

	// Даём серверу 30 секунд на завершение текущих запросов
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("shutdown error", "err", err)
	}

	slog.Info("server stopped")
}

Здесь важный момент: ошибка сервера не завершает процесс прямо из goroutine через os.Exit(1), а сначала попадает в основной поток. Иначе можно случайно обойти весь дальнейший graceful shutdown и cleanup.

Более современный вариант: signal.NotifyContext

Вместо отдельного канала сигналов можно использовать signal.NotifyContext, и тогда код получается чуть компактнее:

// Контекст автоматически отменяется при получении сигнала
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Блокируемся до отмены контекста (= получение сигнала)
<-ctx.Done()
slog.Info("shutdown signal received")

Это особенно удобно, если жизненный цикл приложения и так строится вокруг context.Context.

Что происходит при Shutdown(ctx)

  1. Сервер перестаёт принимать новые соединения
  2. Ждёт завершения текущих запросов
  3. Если контекст истёк, Shutdown возвращает ошибку, и дальше уже нужно решать, что делать с процессом и оставшимися соединениями

В этом и состоит ключевая идея graceful shutdown: сначала перестать принимать новую нагрузку, потом дать завершиться тому, что уже выполняется.

Shutdown() и Close() — не одно и то же

У http.Server есть два похожих метода, но ведут они себя по-разному:

  • Shutdown(ctx) останавливает приём новых соединений и ждёт завершения текущих запросов;
  • Close() просто закрывает слушатели и активные соединения без попытки дождаться нормального завершения.

Для production-сервисов почти всегда нужен именно Shutdown().
Close() — это скорее аварийный вариант, если graceful shutdown не уложился в отведённое время.

Что это даёт на практике

Представим, что один запрос выполняется долго:

mux := http.NewServeMux()
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
	select {
	case <-time.After(10 * time.Second):
		w.Write([]byte("done"))
	case <-r.Context().Done():
		// Контекст запроса отменяется при Shutdown — обработчик завершается корректно
		slog.Warn("request cancelled")
	}
})

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}

Если в этот момент процесс получит SIGTERM и вызовет Shutdown(ctx) с таймаутом, например, в 30s, то:

  1. новые запросы больше не принимаются;
  2. текущий /slow может спокойно завершиться;
  3. после завершения сервер корректно остановится.

Если вместо этого завершить процесс резко, долгий запрос просто оборвётся посередине.

Добавляем очистку ресурсов и фоновых задач

В реальном приложении graceful shutdown не заканчивается на srv.Shutdown(ctx). Обычно нужно:

  • закрыть пул или клиент БД;
  • остановить фоновые воркеры;
  • завершить consumer loops;
  • сделать flush логов и телеметрии;
  • дождаться завершения внутренних goroutine.

Минимально это может выглядеть так:

// Общий таймаут на весь процесс завершения
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// 1. Остановить HTTP-сервер: завершить текущие запросы
if err := srv.Shutdown(shutdownCtx); err != nil {
	slog.Error("http shutdown error", "err", err)
}

// 2. Остановить фоновые воркеры через отмену их контекста
workerCancel()

// 3. Закрыть пул соединений к БД
if err := dbPool.Close(); err != nil {
	slog.Error("db pool close error", "err", err)
}

// 4. Отправить оставшуюся телеметрию
if err := otelShutdown(shutdownCtx); err != nil {
	slog.Error("otel shutdown error", "err", err)
}

slog.Info("cleanup complete")

Если у приложения есть свои goroutine, им тоже нужен сигнал на завершение. Обычно для этого используют общий context.Context, sync.WaitGroup или отдельный cancel().

Контекст Docker и Kubernetes

Тема graceful shutdown особенно важна в контейнерной среде:

  • docker stop сначала отправляет процессу SIGTERM;
  • только потом, если процесс не завершился вовремя, контейнер получает принудительное завершение;
  • в Kubernetes есть тот же общий принцип с terminationGracePeriodSeconds.

То есть graceful shutdown — это не “приятное улучшение”, а нормальный способ пережить деплой без потери запросов.

Если приложение завершает работу дольше, чем ему отвели Docker или Kubernetes, оно всё равно будет убито. Поэтому timeout в коде и timeout инфраструктуры должны быть согласованы между собой.

Если сервис стоит за балансировщиком или работает в Kubernetes, полезно сначала убрать инстанс из приёма нового трафика через readiness-механику, а уже потом завершать текущие запросы и cleanup.

Типичные ошибки

  1. Сразу вызывать os.Exit(1) после сигнала и не давать Shutdown() отработать.
  2. Не обрабатывать http.ErrServerClosed и считать нормальную остановку ошибкой.
  3. Остановить HTTP-сервер, но забыть про фоновые goroutine и воркеры.
  4. Выбрать слишком маленький timeout, в который не помещается завершение текущих запросов.
  5. Не привязать cleanup-операции к context и получить зависание на остановке.
  6. Путать Shutdown() и Close().

Небольшой production-checklist

  • сервер ловит SIGINT и SIGTERM;
  • используется Shutdown(ctx), а не только Close();
  • у shutdown есть понятный timeout;
  • фоновые задачи умеют завершаться;
  • БД, telemetry и внешние ресурсы закрываются явно;
  • таймаут в приложении согласован с Docker или Kubernetes.

Правило простое: graceful shutdown — это обязательная часть любого production-сервиса, а не косметическая доработка “на потом”.

Документация и первоисточники

Если хочется проверить детали по стандартной библиотеке Go и поведению HTTP-сервера, полезно смотреть в официальные материалы:

  • os/signal — работа с сигналами ОС;
  • signal.NotifyContext — более современный способ встроить сигналы в context;
  • context — базовый механизм таймаутов и отмены;
  • net/http Server.Shutdown — корректная остановка HTTP-сервера;
  • net/http Server.Close — аварийное закрытие без graceful-ожидания.

Статья при этом остаётся не справкой по API, а практическим разбором: как собрать вокруг этих примитивов нормальный shutdown для реального сервиса.

Обсуждение в Telegram

Присоединиться →

Комментарии