Graceful Shutdown
Graceful Shutdown
Когда Kubernetes деплоит новую версию твоего сервиса, он убивает старый под. Но не сразу — сначала посылает SIGTERM и ждёт до 30 секунд. Если ты не обрабатываешь этот сигнал, сервер умирает мгновенно, обрывая все активные HTTP-запросы. Пользователи получают ошибки.
Graceful shutdown — это ответ на SIGTERM: перестать принимать новые запросы, дождаться завершения текущих, закрыть соединения с базой данных и только потом выйти.
Сигналы ОС
Операционная система общается с процессами через сигналы:
| Сигнал | Значение | Кто посылает |
|---|---|---|
SIGINT | Ctrl+C в терминале | Пользователь |
SIGTERM | Запрос на завершение | Kubernetes, systemd, kill |
SIGHUP | Перезагрузить конфиг | Традиционно: демоны |
SIGKILL | Немедленное убийство | Нельзя перехватить |
SIGKILL перехватить невозможно — это аварийный выключатель ОС. Поэтому server.Shutdown должен уложиться в таймаут до того, как Kubernetes пошлёт SIGKILL.
Подписка на сигналы
quit := make(chan os.Signal, 1) // буфер обязателен!
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)Буфер на 1 — не случайность. signal.Notify отправляет сигнал в канал неблокирующим образом. Если канал полон или нет читателя в этот момент, сигнал потеряется. Буфер гарантирует, что сигнал будет сохранён до тех пор, пока горутина его не прочитает.
Базовый паттерн
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
Обычно после остановки сервера нужно закрыть другие ресурсы:
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 завершён:
// Канал для сигнала "сервер готов к 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). Типичная конфигурация:
spec:
terminationGracePeriodSeconds: 60 # дать 60 сек
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "5"] # дать load balancer убрать pod из rotationpreStop sleep даёт load balancer время убрать под из rotation до того, как начнётся shutdown. Без этого новые запросы продолжают приходить в момент начала shutdown.
Отмена in-flight запросов через Context
Если запрос выполняет долгую операцию (запрос к БД, внешнему сервису), ей нужно знать о shutdown:
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 — аварийный выход, может триггернуть алерты.
ОС отправляет сигналы процессам. Go программа может перехватить их через пакет os/signal и корректно завершить работу.
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...make(chan os.Signal) // небуферизованный!
make(chan os.Signal, 1) // буфер на 1 сигнал