При остановке сервиса во время деплоя, рестарта или масштабирования важно не обрывать текущие запросы и не оставлять после себя висящие соединения, фоновые воркеры и незавершённую очистку ресурсов. В 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
Минимальный пример
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)
- Сервер перестаёт принимать новые соединения
- Ждёт завершения текущих запросов
- Если контекст истёк,
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, то:
- новые запросы больше не принимаются;
- текущий
/slowможет спокойно завершиться; - после завершения сервер корректно остановится.
Если вместо этого завершить процесс резко, долгий запрос просто оборвётся посередине.
Добавляем очистку ресурсов и фоновых задач
В реальном приложении 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.
Типичные ошибки
- Сразу вызывать
os.Exit(1)после сигнала и не даватьShutdown()отработать. - Не обрабатывать
http.ErrServerClosedи считать нормальную остановку ошибкой. - Остановить HTTP-сервер, но забыть про фоновые goroutine и воркеры.
- Выбрать слишком маленький timeout, в который не помещается завершение текущих запросов.
- Не привязать cleanup-операции к
contextи получить зависание на остановке. - Путать
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 для реального сервиса.
Комментарии