Graceful shutdown в Go: как правильно останавливать сервисы

На код-ревью я периодически вижу сервисы, которые при получении SIGTERM просто умирают. Никакой обработки сигналов, никакого ожидания текущих запросов. ListenAndServe в main, Ctrl+C, процесс мёртв. Локально это работает — вы же всё равно перезапускаете руками. А в продакшене, где Kubernetes перекатывает поды несколько раз в день, начинаются проблемы: обрывы соединений, частично записанные данные в базе, потерянные сообщения из очередей.

Graceful shutdown — это когда сервис перестаёт принимать новые запросы, дожидается завершения текущих и только потом останавливается. В Go всё необходимое для этого есть в стандартной библиотеке.

Что идёт не так без graceful shutdown

Без корректного завершения сервис обрывается на полуслове. Клиент получает connection reset — retry-логика может сработать, а может и нет. Транзакция в базе не зафиксирована, но side-эффект (отправка события в очередь, вызов внешнего API) уже произошёл. Consumer вычитал сообщение из Kafka, но не успел закоммитить offset — после рестарта оно обработается повторно (и хорошо, если обработчик идемпотентен, а если нет — получите дубликат). Файловые дескрипторы не закрыты, блокировки в базе не сняты.

При разработке локально вы останавливаете сервис только после ручного теста, что происходит относительно редко. В продакшене деплои могут происходить несколько раз в день, Kubernetes по своим причинам перемещает поды между нодами или проводит рестарты. Некорректное завершение превращается в постоянный источник мелких и крупных багов, которые сложно воспроизвести и ещё сложнее отладить.

Двенадцать факторов и утилизируемость

Методология 12-factor app описывает принципы построения cloud-native приложений. Девятый фактор — Утилизируемость (ориг. Disposability) — про то, что процессы должны запускаться быстро, а завершаться корректно. При получении SIGTERM процесс прекращает приём новых задач, завершает текущие и выходит (на практике стоит обрабатывать и SIGINT — для корректной остановки при Ctrl+C в терминале). Для долгих задач (например: фоновые задачи или воркеры) рекомендуется возвращать задачу обратно в очередь, если процесс не успевает её доделать.

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

Сигналы в Unix и как Go с ними работает

Ctrl+C в терминале — это SIGINT. Kubernetes при остановке пода отправляет SIGTERM. kill -9 — это SIGKILL, который перехватить невозможно в принципе.

В современном Go удобнее всего перехватывать сигналы через signal.NotifyContext:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// ctx.Done() сработает при получении сигнала
<-ctx.Done()
log.Println("получен сигнал завершения")

signal.NotifyContext появился в Go 1.16 на замену более громоздкому паттерну с signal.Notify и каналом. Возвращает контекст, который отменяется при получении указанного сигнала. Этот контекст можно пробросить вниз по цепочке вызовов, и все операции, которые поддерживают context, завершатся сами.

Graceful shutdown HTTP-сервера

Стандартный http.Server поддерживает graceful shutdown из коробки через метод Shutdown:

package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Имитация обработки запроса
		time.Sleep(2 * time.Second)
		w.Write([]byte("OK"))
	})

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

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

	// Запускаем сервер в отдельной горутине
	go func() {
		log.Printf("сервер запущен на %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("ошибка сервера: %v", err)
		}
	}()

	// Ждём сигнала
	<-ctx.Done()
	log.Println("получен сигнал завершения, начинаем shutdown")

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

	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Printf("ошибка при shutdown: %v", err)
	}

	log.Println("сервер остановлен")
}

Что делает server.Shutdown:

  1. Закрывает все открытые listener-ы, новые соединения больше не принимаются
  2. Закрывает idle-соединения (keep-alive без активных запросов)
  3. Ждёт, пока все активные запросы завершатся
  4. Возвращает nil, если запросы завершились до истечения контекста, или ошибку контекста, если не успели

Таймаут — обязательная страховка. Если обработчик завис (ждёт ответа от базы, попал в бесконечный цикл), без таймаута процесс не завершится никогда. В Kubernetes после terminationGracePeriodSeconds (по умолчанию 30 секунд) под получит SIGKILL, и весь graceful shutdown окажется бессмысленным.

Целостность данных при остановке

Целостность это то ключевое, ради чего graceful shutdown вообще нужен. Возьмём обработчик, который создаёт заказ:

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	tx, err := h.db.BeginTx(r.Context(), nil)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	defer tx.Rollback()

	// Создаём заказ
	orderID, err := h.repo.CreateOrder(r.Context(), tx, req)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	// Списываем средства
	if err := h.repo.DeductBalance(r.Context(), tx, req.UserID, req.Amount); err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	if err := tx.Commit(); err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]int{"order_id": orderID})
}

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

Graceful shutdown гарантирует, что обработчик отработает предсказуемо и до конца. Транзакция зафиксируется, внешние вызовы завершатся, клиент получит ответ.

Важный момент: обратите внимание на r.Context() в запросах к базе. server.Shutdown не отменяет контексты активных запросов — он ждёт их завершения. Но если вы передаёте в SQL-запросы контекст от signal.NotifyContext, то при получении сигнала все запросы к базе отменятся разом. И получится ровно та ситуация, от которой вы пытались защититься. Я встречал этот баг в продакшене — на первый взгляд всё сделано правильно, graceful shutdown есть, но контекст прокинут не тот, и при деплое транзакции всё равно обрываются. Используйте r.Context() в обработчиках, а не глобальный контекст приложения.

Завершение фоновых горутин

HTTP-сервер — только часть приложения. Обычно рядом крутятся воркеры для очередей, периодические джобы, подписки на события. Всё это тоже нужно останавливать.

errgroup хорошо подходит для координации:

package main

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

	"golang.org/x/sync/errgroup"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	g, gCtx := errgroup.WithContext(ctx)

	server := &http.Server{Addr: ":8080", Handler: newRouter()}

	// HTTP-сервер
	g.Go(func() error {
		log.Println("HTTP-сервер запущен")
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			return err
		}
		return nil
	})

	// Graceful shutdown HTTP-сервера
	g.Go(func() error {
		<-gCtx.Done()
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		return server.Shutdown(shutdownCtx)
	})

	// Воркер обработки очереди
	g.Go(func() error {
		return runQueueWorker(gCtx)
	})

	// Периодическая задача
	g.Go(func() error {
		return runPeriodicCleanup(gCtx, 5*time.Minute)
	})

	if err := g.Wait(); err != nil {
		log.Printf("завершение с ошибкой: %v", err)
	}
	log.Println("все компоненты остановлены")
}

func runQueueWorker(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			log.Println("воркер: получен сигнал завершения")
			return nil
		default:
			// Обрабатываем следующее сообщение из очереди
			// Используем ctx, чтобы прерваться при shutdown
			if err := processNextMessage(ctx); err != nil {
				log.Printf("воркер: ошибка обработки: %v", err)
			}
		}
	}
}

func runPeriodicCleanup(ctx context.Context, interval time.Duration) error {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			log.Println("cleanup: остановлен")
			return nil
		case <-ticker.C:
			if err := cleanup(ctx); err != nil {
				log.Printf("cleanup: ошибка: %v", err)
			}
		}
	}
}

Если любой компонент падает с ошибкой, контекст группы отменяется и остальные тоже начинают останавливаться. g.Wait() дожидается завершения всех горутин — ни одна не останется висеть. Горутина shutdown в этом примере блокирует завершение группы, пока сервер не остановится или не истечёт таймаут.

Обратите внимание на разницу в поведении при shutdown. HTTP-сервер через server.Shutdown даёт текущим запросам доработать до конца (drain). А вот воркер при отмене контекста прервёт текущую операцию — processNextMessage получит отменённый ctx и должен корректно откатиться. Если очередь поддерживает Nack, воркер может вернуть недообработанное сообщение, чтобы его подхватил другой инстанс. Это разные стратегии завершения, и обе правильные — просто нужно понимать, какая где применяется.

Порядок завершения

Порядок, в котором компоненты останавливаются, имеет значение. Если закрыть пул соединений к базе раньше, чем HTTP-сервер завершит обработку запросов — текущие запросы получат ошибку при обращении к БД.

Правильный порядок — обратный порядку запуска. Сначала перестаём принимать новые запросы (закрываем listener). Потом дожидаемся завершения текущих. Останавливаем фоновые воркеры. Закрываем соединения к внешним сервисам — БД, Redis, Kafka. В последнюю очередь сбрасываем метрики и логи.

log.Println("1. останавливаем HTTP-сервер")
server.Shutdown(shutdownCtx)

log.Println("2. останавливаем воркеры")
cancelWorkers()
workersWg.Wait()

log.Println("3. закрываем соединение с БД")
db.Close()

log.Println("4. закрываем соединение с Redis")
redisClient.Close()

log.Println("5. сбрасываем метрики")
metricsExporter.Flush()

Логика простая: компоненты верхнего уровня (HTTP-сервер, воркеры) зависят от компонентов нижнего уровня (БД, кэш). Закрыли базу раньше сервера — обработчик, который обращается к базе, получит ошибку.

Readiness probe и Kubernetes

В Kubernetes graceful shutdown связан с probes. Когда под получает SIGTERM, он должен перестать отвечать на readiness probe. Это сигнал для Service — перестать слать на этот под трафик.

var isReady atomic.Bool

func init() {
	isReady.Store(true)
}

func readinessHandler(w http.ResponseWriter, r *http.Request) {
	if !isReady.Load() {
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
}

При получении сигнала выставляем isReady в false:

<-ctx.Done()
isReady.Store(false)

// Даём Kubernetes время заметить, что под не ready
time.Sleep(5 * time.Second)

// Теперь начинаем shutdown
server.Shutdown(shutdownCtx)

Пауза в 5 секунд нужна, потому что Kubernetes не мгновенно обновляет endpoints. Между моментом, когда под стал не-ready, и моментом, когда kube-proxy обновит iptables, проходит несколько секунд. Без этой паузы под может получить новые запросы уже после начала shutdown. Мы на это наступили, когда у нас при деплое проскакивали 502 ошибки на балансировщике — добавили sleep, ошибки ушли.

Но time.Sleep в коде приложения — не единственный вариант. Логика ожидания обновления endpoints относится к инфраструктуре, а не к бизнес-логике. В Kubernetes для этого есть lifecycle.preStop hook:

lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]

Kubernetes выполнит preStop перед отправкой SIGTERM. Приложение при этом ничего не знает про задержку — оно получает сигнал и сразу начинает shutdown. Учтите, что таймер terminationGracePeriodSeconds начинает тикать до выполнения preStop, а не после — время preStop и время shutdown приложения вместе должны уложиться в grace period. Зато это чище в плане разделения ответственности: кластер управляет таймингами остановки трафика, приложение занимается только своими ресурсами. Если в одном кластере endpoints обновляются за 2 секунды, а в другом за 10 — меняется только манифест, код трогать не нужно.

Таймауты и SIGKILL

Таймауты должны складываться в правильную цепочку:

ПараметрЗначениеЧто делает
Пауза перед shutdown5 сОжидание обновления endpoints в Kubernetes
Таймаут server.Shutdown10 сВремя на завершение текущих запросов
terminationGracePeriodSeconds30 сВремя до SIGKILL от Kubernetes

Сумма паузы и таймаута shutdown должна быть меньше terminationGracePeriodSeconds. Иначе Kubernetes убьёт процесс SIGKILL раньше, чем он завершится сам, и graceful shutdown не отработает.

Если у вас есть запросы, которые могут работать дольше 10 секунд (загрузка файлов, тяжёлые отчёты) — увеличивайте оба значения соответственно.

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

Самая частая — вообще не обрабатывать сигналы. Go-рантайм при получении SIGTERM завершает процесс. Deferred-функции не выполняются. Все ваши defer db.Close() и defer tx.Rollback() просто не сработают.

Shutdown без таймаута — тоже классика. Зависший обработчик заблокирует завершение навсегда. В Kubernetes это закончится SIGKILL, в systemd — зависшим сервисом, который придётся убивать руками.

Ещё встречается — закрывать зависимости раньше обработчиков. Закрыли пул БД, а HTTP-сервер ещё обрабатывает запросы. Текущие обработчики получат ошибку при обращении к базе.

Ещё одна — os.Exit в обработчиках или в горутинах. os.Exit завершает процесс немедленно, без выполнения deferred-функций. Я видел код, где при критической ошибке вызывался os.Exit(1) прямо из обработчика, и при этом рядом был аккуратно реализован graceful shutdown. Они друг друга исключают.

И про логгер: если используете async-логгер (zap с buffered writer, например), сбрасывайте буфер в самом конце, после остановки всех компонентов. Иначе последние строки логов, которые как раз описывают процесс завершения, просто потеряются. Для zap это logger.Sync():

// В самом конце main, после остановки всех компонентов
logger.Sync()

Заключение

В Go стандартная библиотека покрывает всё, что нужно для graceful shutdown: signal.NotifyContext перехватывает сигналы, http.Server.Shutdown корректно останавливает сервер, context пробрасывается по всей цепочке вызовов.

На практике чаще всего забывают про три вещи: правильный контекст в SQL-запросах (нужен r.Context(), а не глобальный), порядок завершения компонентов (обратный порядку запуска) и паузу перед shutdown в Kubernetes (чтобы endpoints успели обновиться).

Если вы работаете по 12-factor app — disposability уже в чеклисте. Если нет — graceful shutdown стоит добавить первым. Это небольшое изменение, но оно убирает целый класс багов, которые проявляются только при деплое и которые сложно воспроизвести локально.

Связанные статьи:

FAQ

Что произойдёт, если не обрабатывать SIGTERM?

Go-рантайм при получении SIGTERM завершает процесс. Deferred-функции не выполняются, финализаторы не вызываются. Процесс просто останавливается. Всё, что вы настроили в defer, не сработает.

SIGKILL перехватить нельзя ни в Go, ни в любом другом языке. Обработка SIGTERM — единственный шанс завершиться корректно.

Как тестировать graceful shutdown?

Интеграционный тест: запускаем сервер, отправляем долгий запрос, вызываем Shutdown и проверяем, что запрос завершился без ошибки:

func TestGracefulShutdown(t *testing.T) {
	server := &http.Server{
		Addr: ":0",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(2 * time.Second)
			w.Write([]byte("OK"))
		}),
	}

	ln, _ := net.Listen("tcp", ":0")
	go server.Serve(ln)

	// Отправляем запрос, который будет обрабатываться 2 секунды
	done := make(chan struct{})
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String())
		if err != nil {
			t.Errorf("запрос завершился с ошибкой: %v", err)
		} else {
			resp.Body.Close()
		}
		close(done)
	}()

	// Даём запросу начать обработку
	time.Sleep(100 * time.Millisecond)

	// Инициируем shutdown
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	server.Shutdown(ctx)

	// Запрос должен был завершиться успешно
	<-done
}

В продакшене полезно логировать время shutdown и количество запросов в момент остановки. Если таймаут регулярно превышается — значит, какие-то обработчики работают слишком долго, и стоит разобраться с ними отдельно.

Нужен ли graceful shutdown для CLI-утилит?

Зависит от того, что утилита делает. Парсинг файла или генерация отчёта — скорее нет. Запись в файл, базу или вызов внешних API — да.

Пример: CLI-утилита, которая мигрирует данные между базами. Без graceful shutdown прерывание на середине оставит данные в неконсистентном состоянии. С ним — утилита завершит текущий batch и выведет, на каком месте остановилась, чтобы можно было продолжить.

Как работает graceful shutdown в Go-фреймворках?

Большинство фреймворков оборачивают http.Server.Shutdown:

Gin:

srv := &http.Server{Addr: ":8080", Handler: router}
go srv.ListenAndServe()
// ... обработка сигнала ...
srv.Shutdown(ctx) // Тот же http.Server.Shutdown

Echo:

go e.Start(":8080")
// ... обработка сигнала ...
e.Shutdown(ctx) // Внутри вызывает http.Server.Shutdown

Fiber (построен на fasthttp, не на net/http):

go app.Listen(":8080")
// ... обработка сигнала ...
app.ShutdownWithTimeout(10 * time.Second)

Fiber — исключение, потому что использует fasthttp вместо net/http. Остальные фреймворки делегируют shutdown стандартному http.Server, так что паттерны из этой статьи применимы напрямую.


Теги: