Опирается на правила:
R-SHUT-K8S-1…R-SHUT-K8S-3иR-SHUT-K8S-X1…R-SHUT-K8S-X2из Graceful Shutdown → раздел 6. Kubernetes.
Важно знать
terminationGracePeriodSeconds: 60— явно в Deployment, не k8s default 30.preStop sleep 10— отдельный бюджет; SIGTERM приходит после preStop, не вместе с ним.atomic.Boolвhealth.State— единственный источник readiness;SetNotReady()вызывается доsrv.Shutdown./health/readyотвечает 503 при!appState.IsReady()— k8s убирает pod из endpoints;/health/liveвсегда 200 на shutdown.srv.Shutdown(ctx), неsrv.Close()— Shutdown дожидается in-flight запросов, Close рвёт соединения немедленно.maxSurge: 1, maxUnavailable: 0— новый pod принимает трафик до того как старый начал shutdown.- Без
preStop— гарантированные 502 в окне 5-15 секунд после SIGTERM.
K8s — контекст, в котором живёт graceful shutdown. Go-сервис на net/http+chi может корректно завершать обработчики, но без правильной k8s-конфигурации клиенты увидят 502: kube-proxy не успевает убрать pod из endpoints до того, как SIGTERM добирается до процесса. Три k8s-параметра замыкают цепочку.
terminationGracePeriodSeconds: 60
R-SHUT-K8S-1: total budget для всей shutdown-последовательности.
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-service
image: order-service:2.1.0
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
Sequence завершения pod:
T=0s kubelet начинает shutdown:
- запускает preStop hook параллельно с убиранием pod из Service endpoints
T=10s preStop sleep завершён → kubelet отправляет SIGTERM
T=10s os.Signal-канал в Run() получает SIGTERM:
- appState.SetNotReady() // /health/ready → 503
- cancelConsumerCtx() // kafka consumer завершает batch
- consumerWg.Wait()
- schedulerWg.Wait() // outbox relay дожимает итерацию
- srv.Shutdown(shutCtx) // дожидается in-flight HTTP
- pool.Close() // pgxpool последним
T=35s процесс завершается (25s на shutdown после preStop)
T=70s если процесс жив → SIGKILL
terminationGracePeriodSeconds: 30 (k8s default) + preStop 10s → Go-процессу остаётся 20s, но ShutdownTimeout: 25s уже не помещается. Pod уходит в SIGKILL посередине дрейна HTTP.
Явная запись 60 обязательна — k8s не читает значение из кода приложения.
Readiness-флаг и health endpoints
R-SHUT-CFG-3, R-SHUT-CFG-4, R-SHUT-K8S-2: на shutdown нужен readiness=503, liveness должна оставаться 200.
// 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() }
// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, state *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 !state.IsReady() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
}
На SIGTERM SetNotReady() вызывается первым — ещё до srv.Shutdown. k8s через periodSeconds: 5 × failureThreshold: 2 = 10s видит 503 и убирает pod из endpoints. Новый трафик не поступает; in-flight запросы, принятые до переключения, дожимаются через srv.Shutdown.
Liveness возвращает 200 всегда — падение liveness вызывает restart pod, что разрушает graceful.
Probes в Deployment
R-SHUT-K8S-2:
containers:
- name: order-service
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
| Probe | Endpoint | Действие k8s при fail |
|---|---|---|
| readinessProbe | /health/ready | Убрать из endpoints (без restart) |
| livenessProbe | /health/live | Перезапустить pod |
Go-сервисы стартуют быстро (< 5s в среднем), поэтому initialDelaySeconds: 15 на liveness — достаточно. В отличие от JVM-сервисов, где прогрев JIT занимает 30-60s, Go-бинарь готов почти сразу после запуска.
readinessProbe начинает работать через 5s — если сервис поднялся, kube-proxy сразу добавит pod в endpoints.
SIGTERM-handler и порядок shutdown
R-SHUT-CFG-2, R-SHUT-CFG-3:
// internal/server/server.go
package server
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/example/order-service/internal/health"
)
type Config struct {
ShutdownTimeout time.Duration // 25s
}
func Run(
ctx context.Context,
srv *http.Server,
cfg Config,
appState *health.State,
cancelConsumerCtx context.CancelFunc,
consumerDone <-chan struct{},
schedulerDone <-chan struct{},
closePool func(),
) 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 первым
cancelConsumerCtx() // kafka consumer: не начинать новый batch
<-consumerDone // дождаться завершения текущего batch
<-schedulerDone // outbox relay: текущая итерация
shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil { // R-SHUT-CFG-1: дожать HTTP
slog.ErrorContext(ctx, "http shutdown error", "error", err)
}
closePool() // R-SHUT-DB-1: pgxpool последним
return nil
}
context.WithTimeout(context.Background(), ...) — не родительский ctx: на момент shutdown родительский контекст уже может быть отменён, а нам нужно завершить in-flight запросы корректно.
maxSurge / maxUnavailable
R-SHUT-K8S-3: zero-downtime rolling deploy.
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
Sequence deploy order-service v2.1.0 → v2.2.0:
Начало: 3 пода v2.1.0
T=0 kubelet создаёт pod v2.2.0 (итого 4 пода)
T=Ns v2.2.0 проходит readinessProbe → добавлен в endpoints
T=Ns pod v2.1.0 #1: preStop sleep 10 → SIGTERM → SetNotReady() → Shutdown → exit
(3 активных пода: 2×v2.1.0 + 1×v2.2.0)
T=Ns kubelet создаёт pod v2.2.0 #2 (итого 4)
...
Без maxUnavailable: 0 k8s мог бы выключить старый pod до запуска нового — capacity падает, SLO нарушается.
maxSurge: 1 — минимум для небольших сервисов (3-10 replicas). Для ProductService с 50+ replicas — maxSurge: 25%.
Cumulative budget
R-SHUT-OBS-1: все фазы должны уложиться в 60s.
preStop sleep 10s (k8s, до SIGTERM)
────────────────────────────────────────── ← SIGTERM
SetNotReady ~0s
consumerWg.Wait ~15s (kafka: текущий batch)
schedulerWg.Wait ~10s (outbox relay: итерация)
srv.Shutdown ~25s (HTTP: in-flight)
pool.Close ~1s
──────────────────────────────────────────
Итого 61s > 60s → нужно сократить
Если budget не помещается — сократить batch (100→20 событий в outbox), не увеличивать terminationGracePeriodSeconds. Большой budget скрывает медленные операции; правильное решение — ускорить их.
Структурный лог и метрика (R-SHUT-OBS-2, R-SHUT-OBS-3):
// обёртка в Run(), до shutdown-последовательности:
start := time.Now()
slog.InfoContext(ctx, "graceful shutdown начат")
defer func() {
slog.InfoContext(ctx, "graceful shutdown завершён",
"duration_s", time.Since(start).Seconds(),
)
shutdownDuration.Set(time.Since(start).Seconds()) // promauto Gauge
}()
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Нет preStop в lifecycle | R-SHUT-K8S-X1 | exec: ["/bin/sh", "-c", "sleep 10"] |
terminationGracePeriodSeconds: 30 (default) | R-SHUT-K8S-X2 | 60 явно |
Один /health для обеих probes | R-SHUT-K8S-2 | отдельные /health/live и /health/ready |
srv.Close() вместо srv.Shutdown(ctx) | R-SHUT-CFG-1 | http.Server.Shutdown с явным таймаутом |
var shuttingDown bool без atomic.Bool | R-SHUT-CFG-X1 | health.State с atomic.Bool |
pool.Close() до consumerWg.Wait() | R-SHUT-DB-X1 | pgxpool — последним в shutdown-последовательности |
| Liveness зависит от состояния БД | R-SHUT-K8S-2 | liveness всегда 200; БД-зависимость — в readiness |
maxUnavailable: 1 на production | R-SHUT-K8S-3 | maxUnavailable: 0 |
Куда дальше
- Бюджеты и observability — cumulative budget,
app_shutdown_duration_seconds, структурный лог. - HTTP drain —
srv.Shutdownvssrv.Close,preStopдетали, долгие эндпоинты. - БД и persistence — порядок закрытия
pgxpool, транзакции на SIGTERM. - Kafka shutdown —
kafka-goconsumer черезcontext.Context,CommitMessages,writer.Close. - Фоновые задачи и outbox —
sync.WaitGroup, outbox relay, критичная секция сcontext.Background(). - Идемпотентность in-flight операций —
Idempotency-Key,ON CONFLICT DO NOTHINGпоevent_id.