Go-сервис может аккуратно завершать обработку запросов при остановке — но этого недостаточно. Без правильной настройки Kubernetes клиенты всё равно получат ошибки 502: kube-proxy не успевает убрать под из списка активных до того, как процесс перестаёт отвечать. Три параметра в манифесте Deployment закрывают этот пробел.
Почему 502 при перезапуске — это проблема конфигурации
Когда Kubernetes останавливает под, он делает два действия одновременно: убирает под из Service endpoints (чтобы новый трафик не шёл туда) и отправляет процессу сигнал SIGTERM. Проблема в том, что обновление таблиц маршрутизации через kube-proxy занимает несколько секунд. В этом окне балансировщик ещё направляет запросы на под, который уже начал завершаться.
Решение — дать kube-proxy время синхронизироваться до того, как SIGTERM дойдёт до процесса. Для этого используется preStop hook.
terminationGracePeriodSeconds и preStop
terminationGracePeriodSeconds — общий бюджет времени, который Kubernetes отводит на всю последовательность завершения пода. Если процесс не завершился за это время, Kubernetes отправляет SIGKILL.
Значение по умолчанию — 30 секунд. Этого часто недостаточно: preStop занимает 10 секунд, а Go-процессу остаётся только 20 секунд на завершение всех горутин, HTTP-соединений и закрытие пула БД.
Правильная конфигурация:
# 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"]
Как работает последовательность завершения:
T=0s Kubernetes начинает останавливать под:
- запускает preStop hook
- убирает под из Service endpoints
T=10s preStop завершён → Kubernetes отправляет SIGTERM
T=10s Go-процесс получает SIGTERM:
- помечает себя как не готовый (readiness → 503)
- завершает фоновые горутины
- дожидается in-flight HTTP-запросов
- закрывает пул соединений с БД
T=35s процесс завершился (25 секунд на shutdown после preStop)
T=60s если процесс всё ещё жив → SIGKILL
Важно понимать: terminationGracePeriodSeconds отсчитывается с T=0, то есть с самого начала. preStop sleep 10 секунд — это часть этого бюджета, а не отдельное время сверх него.
Без preStop: Kubernetes отправит SIGTERM немедленно, kube-proxy ещё направляет трафик на под, процесс уже не отвечает — клиенты получают 502 в течение 5-15 секунд.
Два отдельных health-эндпоинта
Типичная ошибка — один эндпоинт /health для обеих проверок. Kubernetes использует два разных типа проверок с разным поведением при сбое:
- readinessProbe: если под не готов, Kubernetes убирает его из endpoints. Pod продолжает работать.
- livenessProbe: если под не отвечает, Kubernetes его перезапускает.
При graceful shutdown нужно разное поведение: readiness должна вернуть 503 (чтобы Kubernetes перестал слать трафик), liveness должна остаться 200 (чтобы Kubernetes не перезапустил под посередине завершения).
Состояние готовности через атомарный флаг:
// 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. Kubernetes проверяет readiness каждые 5 секунд, и при двух последовательных 503 убирает под из endpoints. Новый трафик перестаёт поступать; запросы, уже принятые сервером, дожимаются через srv.Shutdown.
atomic.Bool здесь важен: флаг читается и пишется из разных горутин (HTTP-обработчики и shutdown-горутина), поэтому нужна атомарная операция — обычный bool без синхронизации даст неопределённое поведение.
Настройка probes в Deployment
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
Go-сервисы стартуют быстро — обычно менее 5 секунд. Поэтому initialDelaySeconds: 15 для liveness достаточно. После старта Kubernetes начнёт проверять readiness через 5 секунд: если сервис поднялся, под сразу добавится в endpoints.
SIGTERM-handler и порядок завершения
// internal/server/server.go
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() // readiness → 503 первым делом
cancelConsumerCtx() // сигнал фоновым горутинам остановиться
<-consumerDone // ждём завершения текущего batch
<-schedulerDone // ждём текущей итерации scheduler
shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
slog.ErrorContext(ctx, "http shutdown error", "error", err)
}
closePool() // пул соединений — последним
return nil
}
Несколько важных деталей:
srv.Shutdown(ctx), а не srv.Close() — Shutdown ждёт завершения всех активных запросов, Close обрывает соединения немедленно.
context.WithTimeout(context.Background(), ...) — не родительский ctx: к моменту shutdown родительский контекст уже отменён, а нам нужно дать HTTP-серверу время завершить запросы.
Пул соединений (pgxpool) закрывается последним: горутины-потребители Kafka и scheduler ещё могут обращаться к БД во время своего завершения.
Rolling update без потери трафика
При деплое новой версии Kubernetes по умолчанию может остановить старый под раньше, чем новый успеет принять трафик. Чтобы этого не происходило:
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
maxUnavailable: 0 — Kubernetes не выключает старые поды, пока не поднял новые. Количество активных подов никогда не падает ниже заявленного.
maxSurge: 1 — разрешает временно иметь один лишний под. Новый под запускается, проходит readinessProbe, добавляется в endpoints — и только после этого начинается остановка одного старого.
Последовательность деплоя с тремя репликами:
Старт: 3 пода v2.1.0
Kubernetes создаёт pod v2.2.0 (итого 4)
v2.2.0 прошёл readinessProbe → добавлен в endpoints
pod v2.1.0 #1: preStop sleep → SIGTERM → завершение
(активных: 2×v2.1.0 + 1×v2.2.0)
Kubernetes создаёт pod v2.2.0 #2...
Частые ошибки
Нет preStop — SIGTERM приходит до того, как kube-proxy убрал под из endpoints. Клиенты получают 502 в течение нескольких секунд после начала остановки.
terminationGracePeriodSeconds: 30 (значение по умолчанию) при preStop 10 секунд — Go-процессу остаётся только 20 секунд на завершение. Если shutdown занимает больше, Kubernetes убивает процесс принудительно.
Один /health для обеих probes — при graceful shutdown liveness возвращает 503, Kubernetes решает, что под завис, и перезапускает его посередине завершения.
srv.Close() вместо srv.Shutdown(ctx) — Close обрывает соединения немедленно, клиенты получают ошибки.
Закрыть пул БД до завершения горутин — если pgxpool закрыт, а горутины ещё пытаются выполнять запросы, они получают ошибки вместо нормального завершения.
maxUnavailable: 1 на production — при деплое количество активных подов временно сокращается, нагрузка на оставшиеся возрастает, SLO может нарушиться.
Liveness зависит от доступности БД — если БД недоступна, liveness возвращает 503, Kubernetes перезапускает поды. Но перезапуск не починит БД, и поды начнут рестартовать в цикле. Liveness должна возвращать 200 всегда; проверку доступности БД кладут в readiness.
Коротко
preStop: sleep 10даёт kube-proxy время убрать под из endpoints до того, как SIGTERM дойдёт до процесса. Без этого гарантированы 502 при каждом деплое.terminationGracePeriodSeconds: 60— явно в Deployment. Значение по умолчанию (30) не оставляет достаточно времени при preStop 10 секунд./health/liveвсегда возвращает 200;/health/readyвозвращает 503 с первого же момента shutdown — это сигнал Kubernetes убрать под из endpoints.atomic.Boolдля флага готовности — флаг читается из HTTP-горутин и пишется из shutdown-горутины одновременно.- Порядок завершения: readiness → 503, фоновые горутины,
srv.Shutdown, пул БД последним. maxSurge: 1, maxUnavailable: 0— новый под добавляется в rotation до того, как старый начинает останавливаться.
Что почитать дальше
- HTTP drain и in-flight запросы — как
srv.Shutdownдожидается активных соединений. - Фоновые горутины и outbox —
context.Contextиsync.WaitGroupдля фоновых задач. - БД и persistence — порядок закрытия pgxpool и транзакции при завершении.