Опирается на правила:
R-SHUT-CFG-1…R-SHUT-CFG-4иR-SHUT-CFG-X1из Graceful Shutdown Style Guide → раздел 1. Runtime/конфигурация.
Важно знать
http.Server.Shutdown(ctx)— единственный правильный способ остановки;Close()немедленно рвёт активные соединения и даёт 502.context.WithTimeout(context.Background(), 25s)явно — не дефолтный бесконечный контекст; бюджет: preStop 10s + Shutdown ≤25s, остаток для Kafka/БД.appState.SetNotReady()первым, доsrv.Shutdown— k8s должен убрать pod из endpoints прежде, чем начнётся дрейн.atomic.Bool— единственный источник readiness-состояния;/health/readyчитает только его, ничего параллельного.- Раздельные
/health/liveи/health/ready— readiness-падение убирает pod из трафика, liveness-падение перезапускает pod (чего при shutdown не нужно).os.Signal-канал с буфером 1 — сигнал не теряется, если горутина не успела прочитать мгновенно.var shuttingDown boolбезatomicи без связи с health-эндпоинтом — k8s ничего не знает о состоянии pod'а.
Graceful shutdown в Go — не одна функция, а явная последовательность шагов в main: поймать SIGTERM, переключить readiness, дождаться завершения in-flight, потом закрыть БД-пул и выйти. Всё это оркестрирует сам разработчик через os.Signal-канал, context.Context и sync.WaitGroup. Ниже — минимальный правильный набор.
http.Server.Shutdown vs Close
R-SHUT-CFG-1: разница принципиальная.
srv.Shutdown(ctx):
- ждёт завершения всех активных in-flight запросов;
- новые соединения не принимает с момента вызова;
- возвращает
nilкогда все запросы завершились илиctxистёк.
srv.Close():
- немедленно закрывает все соединения (включая активные);
- in-flight запросы получают разрыв → клиент видит
502 Bad GatewayилиConnection reset.
В правильной реализации Close() никогда не используется в shutdown-пути:
// internal/server/server.go
func Run(ctx context.Context, srv *http.Server, appState *health.State, cfg Config) error {
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigC)
errC := make(chan error, 1)
go func() { errC <- srv.ListenAndServe() }()
select {
case sig := <-sigC:
slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown",
"signal", sig.String())
case err := <-errC:
return err
}
appState.SetNotReady() // R-SHUT-CFG-3: readiness → 503 первым
shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return srv.Shutdown(shutCtx) // R-SHUT-CFG-1: ждёт in-flight
}
Явный таймаут shutdown
R-SHUT-CFG-2: cfg.ShutdownTimeout — 20–25 секунд; не бесконечность.
// internal/server/config.go
type Config struct {
Addr string
ShutdownTimeout time.Duration // 25s
}
// cmd/order-service/main.go
cfg := server.Config{
Addr: ":8080",
ShutdownTimeout: 25 * time.Second,
}
Бюджет внутри terminationGracePeriodSeconds: 60:
| Фаза | Бюджет |
|---|---|
| preStop sleep | 10s |
http.Server.Shutdown | ≤ 25s |
| kafka consumer drain | ≤ 15s |
pgxpool.Pool.Close | мгновенно (после WaitGroup) |
Если ShutdownTimeout задан меньше 20s — p99-запросы под нагрузкой обрываются. Больше 45s — SIGKILL от k8s прилетит посреди дрейна. Долгие синхронные эндпоинты (>10s) — не повод растягивать таймаут; правильный ответ — 202 Accepted + polling (см. HTTP drain, R-SHUT-HTTP-3).
Readiness-состояние — atomic.Bool
R-SHUT-CFG-3: единственный источник истины о готовности принимать трафик.
// internal/health/state.go
package health
import "sync/atomic"
type State struct {
ready atomic.Bool
}
func NewState() *State {
s := &State{}
s.ready.Store(true)
return s
}
func (s *State) SetNotReady() { s.ready.Store(false) }
func (s *State) IsReady() bool { return s.ready.Load() }
appState.SetNotReady() вызывается первым на SIGTERM — до srv.Shutdown. Это даёт k8s время (за счёт preStop sleep 10s) обновить endpoints и прекратить слать новый трафик на умирающий pod.
Компоненты, которым нужно знать о состоянии shutdown (например, outbox-relay), читают appState.IsReady():
// internal/scheduler/outbox_relay.go — фрагмент
func (r *OutboxRelay) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.processOneBatch(ctx); err != nil {
slog.WarnContext(ctx, "outbox relay batch",
"order_relay", true, "error", err)
}
}
}
}
Отмена контекста (ctx.Done()) — сигнал «не начинать новую итерацию», не «прервать текущую». Текущий batch доводится до конца.
Раздельные /health/live и /health/ready
R-SHUT-CFG-4: два chi-маршрута с разной логикой.
// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, appState *health.State) {
r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
if !appState.IsReady() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
}
Разница критична при shutdown:
/health/ready→ 503 — k8s убирает pod из Service endpoints, новый трафик перестаёт приходить./health/live→ 503 — k8s считает pod сломанным и перезапускает его. При корректном shutdown liveness должна оставаться200 OKдо последнего.
Если liveness и readiness объединены в одном эндпоинте — на shutdown pod уйдёт в рестарт вместо корректного завершения.
Полная последовательность main
Все элементы собираются в явную shutdown-последовательность:
// cmd/product-service/main.go
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.ErrorContext(ctx, "pgxpool init", "error", err)
os.Exit(1)
}
appState := health.NewState()
consumerCtx, cancelConsumer := context.WithCancel(ctx)
var consumerWg sync.WaitGroup
consumerWg.Add(1)
go func() {
defer consumerWg.Done()
if err := productConsumer.Run(consumerCtx); err != nil {
slog.ErrorContext(ctx, "product consumer", "error", err)
}
}()
var schedulerWg sync.WaitGroup
schedulerCtx, cancelScheduler := context.WithCancel(ctx)
schedulerWg.Add(1)
go outboxRelay.Run(schedulerCtx, &schedulerWg)
r := chi.NewRouter()
health.RegisterHealthRoutes(r, appState)
// ... остальные маршруты
srv := &http.Server{Addr: cfg.Addr, Handler: r}
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigC)
errC := make(chan error, 1)
go func() { errC <- srv.ListenAndServe() }()
select {
case sig := <-sigC:
slog.InfoContext(ctx, "получили SIGTERM, начинаем graceful shutdown",
"signal", sig.String())
case err := <-errC:
slog.ErrorContext(ctx, "server error", "error", err)
os.Exit(1)
}
start := time.Now()
appState.SetNotReady() // R-SHUT-CFG-3
cancelConsumer() // R-SHUT-KFK-1
consumerWg.Wait()
cancelScheduler() // R-SHUT-SCHED-1
schedulerWg.Wait()
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil { // R-SHUT-CFG-1
slog.ErrorContext(ctx, "server shutdown", "error", err)
}
pool.Close() // R-SHUT-DB-1: последним
slog.InfoContext(ctx, "graceful shutdown завершён",
"duration_s", time.Since(start).Seconds())
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
srv.Close() вместо srv.Shutdown(ctx) | R-SHUT-CFG-1 | srv.Shutdown(ctx) с таймаутом |
context.WithTimeout на context.Background() без явного значения | R-SHUT-CFG-2 | 20–25s явно в конфиге |
var shuttingDown bool без atomic и без связи с /health/ready | R-SHUT-CFG-X1 | atomic.Bool в health.State |
SetNotReady() после srv.Shutdown, а не до | R-SHUT-CFG-3 | readiness переключается первым |
Единый /health для live и ready | R-SHUT-CFG-4 | раздельные /health/live и /health/ready |
pool.Close() до WaitGroup.Wait() по задачам | R-SHUT-DB-X1 | pgxpool закрывается последним |
Куда дальше
- Бюджеты и observability —
app_shutdown_duration_seconds, лог SIGTERM/конца shutdown. - БД и persistence — порядок
pgxpool.Pool.Closeи транзакции на shutdown. - HTTP drain —
preStop sleep 10, долгие эндпоинты через202 Accepted. - Идемпотентность in-flight —
Idempotency-Keyи дедупликация. - Kafka shutdown —
kafka-goconsumer context +CommitMessages. - Kubernetes —
terminationGracePeriodSeconds, probes,maxUnavailable: 0. - Scheduled / async / outbox —
sync.WaitGroup+ctx.Done()в relay.