Модуль
1HTTP-сервер на Go2JSON API3Graceful Shutdown← вы здесь4Финальный проект
Урок 3~12 минут

Graceful Shutdown

Graceful Shutdown

Когда Kubernetes деплоит новую версию твоего сервиса, он убивает старый под. Но не сразу — сначала посылает SIGTERM и ждёт до 30 секунд. Если ты не обрабатываешь этот сигнал, сервер умирает мгновенно, обрывая все активные HTTP-запросы. Пользователи получают ошибки.

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

Сигналы ОС

Операционная система общается с процессами через сигналы:

СигналЗначениеКто посылает
SIGINTCtrl+C в терминалеПользователь
SIGTERMЗапрос на завершениеKubernetes, systemd, kill
SIGHUPПерезагрузить конфигТрадиционно: демоны
SIGKILLНемедленное убийствоНельзя перехватить

SIGKILL перехватить невозможно — это аварийный выключатель ОС. Поэтому server.Shutdown должен уложиться в таймаут до того, как Kubernetes пошлёт SIGKILL.

Подписка на сигналы

go
quit := make(chan os.Signal, 1) // буфер обязателен!
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

Буфер на 1 — не случайность. signal.Notify отправляет сигнал в канал неблокирующим образом. Если канал полон или нет читателя в этот момент, сигнал потеряется. Буфер гарантирует, что сигнал будет сохранён до тех пор, пока горутина его не прочитает.

Базовый паттерн

go
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/api/", apiHandler)
 
    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }
 
    // Запускаем сервер в горутине — main не должна блокироваться
    go func() {
        log.Println("starting server on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()
 
    // Ждём сигнала
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    sig := <-quit
    log.Printf("received signal: %v", sig)
 
    // Даём 30 секунд на завершение текущих запросов
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown error: %v", err)
    }
 
    log.Println("server stopped gracefully")
}

Когда ListenAndServe возвращает http.ErrServerClosed — это нормально, это сигнал что Shutdown был вызван. Все остальные ошибки — настоящие проблемы.

Что делает server.Shutdown()

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

Если в течение 30 секунд все запросы завершились — Shutdown вернёт nil. Если таймаут вышел, а запросы ещё идут — вернёт context.DeadlineExceeded, и сервер будет закрыт принудительно.

Cleanup после shutdown

Обычно после остановки сервера нужно закрыть другие ресурсы:

go
sig := <-quit
log.Printf("received signal: %v, shutting down...", sig)
 
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
 
// Graceful shutdown HTTP
if err := server.Shutdown(ctx); err != nil {
    log.Printf("http shutdown error: %v", err)
}
 
// Закрываем соединения с базой данных
if err := db.Close(); err != nil {
    log.Printf("db close error: %v", err)
}
 
// Сбрасываем буферизованные логи
logger.Sync()
 
log.Println("shutdown complete")

Порядок важен: сначала HTTP (чтобы не принимать новые запросы), затем база данных (запросы уже завершены), затем логи (всё записано).

Уведомление готовности

Продвинутый паттерн — уведомить Kubernetes что shutdown завершён:

go
// Канал для сигнала "сервер готов к shutdown"
done := make(chan struct{})
 
go func() {
    defer close(done)
 
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
 
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    server.Shutdown(ctx)
}()
 
// Ждём завершения shutdown перед выходом из main
<-done
log.Println("done")

close(done) — это идиоматичный способ уведомить несколько горутин об одном событии. Получение из закрытого канала возвращает сразу.

Timeout для Kubernetes

Kubernetes ждёт SIGTERM → завершение = terminationGracePeriodSeconds (по умолчанию 30). Типичная конфигурация:

yaml
spec:
  terminationGracePeriodSeconds: 60  # дать 60 сек
 
  containers:
  - name: app
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sleep", "5"]  # дать load balancer убрать pod из rotation

preStop sleep даёт load balancer время убрать под из rotation до того, как начнётся shutdown. Без этого новые запросы продолжают приходить в момент начала shutdown.

Отмена in-flight запросов через Context

Если запрос выполняет долгую операцию (запрос к БД, внешнему сервису), ей нужно знать о shutdown:

go
func apiHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // этот контекст отменяется при Shutdown
 
    result, err := db.QueryContext(ctx, "SELECT ...")
    if err != nil {
        if ctx.Err() != nil {
            // Сервер завершается — запрос отменён
            return
        }
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
    // ...
}

r.Context() отменяется когда соединение закрыто или сервер начинает shutdown. Передавай его во все операции с I/O — база данных, HTTP-клиенты, другие сервисы.

Полная картина

[Kubernetes] SIGTERM
      |
      v
[main goroutine] <-quit (разблокируется)
      |
      v
server.Shutdown(ctx) — перестаём принимать новые запросы
      |
      |--- wait for active handlers...
      |
      v  (все запросы завершились или timeout)
db.Close() — закрываем ресурсы
      |
      v
log.Sync() — сбрасываем логи
      |
      v
main() returns — process exits с кодом 0

Код 0 при выходе означает "успешное завершение". Kubernetes видит это и не перезапускает под экстренно. Код не-0 — аварийный выход, может триггернуть алерты.

Graceful shutdown — это разница между 'сервер упал' и 'сервис задеплоился'. Kubernetes посылает SIGTERM перед удалением пода — без обработки сигнала ты роняешь активные запросы пользователей.

ОС отправляет сигналы процессам. Go программа может перехватить их через пакет os/signal и корректно завершить работу.

Terminal / OS
idle
Go программа
running...
Отправить сигнал:
quit := make(chan os.Signal, 1)  // буферизованный канал!
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

fmt.Println("Server started, waiting for signal...")
sig := <-quit  // блокируемся до получения сигнала
fmt.Printf("Received signal: %v\n", sig)
// начинаем graceful shutdown...
Почему буферизованный канал (capacity 1)?
ПЛОХО
make(chan os.Signal)
// небуферизованный!
Если программа не готова принять сигнал в момент его отправки — сигнал теряется. ОС не ждёт.
ХОРОШО
make(chan os.Signal, 1)
// буфер на 1 сигнал
Сигнал сохраняется в буфере. Программа прочитает его, когда освободится. Ни один сигнал не потеряется.
🎯
Миссия 1 из 4
Почему канал для os.Signal должен быть буферизованным (capacity 1)?