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"]
Как работает последовательность завершения:
Бюджет terminationGracePeriodSeconds считается с самого начала, и preStop съедает его первые 10 секунд: процессу остаётся не 60, а 50. Зато за эти 10 секунд под успевает пропасть из endpoints, и клиенты не ловят 502.
Важно понимать: 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 (чтобы балансировщики, которые опрашивают ручку сами, перестали слать трафик), liveness должна остаться 200 (чтобы kubelet не перезапустил контейнер, который сам сливает трафик и ничем не болен).
Состояние готовности через атомарный флаг:
// 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. Из EndpointSlice удаляемый под Kubernetes убирает сам, в момент удаления и без всяких проб: к приходу SIGTERM новый трафик через Service туда уже не идёт, а запросы, уже принятые сервером, дожимаются через srv.Shutdown. Пробу с периодом 5 секунд и failureThreshold: 2 kubelet заметил бы только через 10 секунд, и на удаление это не влияет. Флаг готовности работает на других: на ingress-контроллер и внешний балансировщик, которые опрашивают /health/ready сами, и на запуск вне кластера, где SIGTERM приходит, а из списка адресов сервис никто не убирает.
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 — и только после этого начинается остановка одного старого.
Последовательность деплоя с тремя репликами:
Сначала поднять новый под, потом гасить старый — так число готовых подов на всём выкате не падает ниже заявленных трёх. Платим временем выката и одним лишним подом, который на пике занимает память и соединения к базе.
Частые ошибки
Нет preStop — SIGTERM приходит до того, как kube-proxy убрал под из endpoints. Клиенты получают 502 в течение нескольких секунд после начала остановки.
terminationGracePeriodSeconds: 30 (значение по умолчанию) при preStop 10 секунд — Go-процессу остаётся только 20 секунд на завершение. Если shutdown занимает больше, Kubernetes убивает процесс принудительно.
Один /health для обеих probes — любое 503 бьёт сразу по обеим. Под, который Kubernetes уже удаляет, liveness не проверяют, но стоит сервису самому уйти в режим слива по команде дежурного или проверке базы внутри /health не пройти — kubelet перезапустит контейнер, который ничем не болен.
srv.Close() вместо srv.Shutdown(ctx) — Close обрывает соединения немедленно, клиенты получают ошибки.
Закрыть пул БД до завершения горутин — если pgxpool закрыт, а горутины ещё пытаются выполнять запросы, они получают ошибки вместо нормального завершения.
maxUnavailable: 1 на production — при деплое количество активных подов временно сокращается, нагрузка на оставшиеся возрастает, SLO может нарушиться.
Liveness зависит от доступности БД — если БД недоступна, liveness возвращает 503, Kubernetes перезапускает поды. Но перезапуск не починит БД, и поды начнут рестартовать в цикле. Liveness должна возвращать 200 всегда; проверку доступности БД кладут в readiness.
Глубже: округление стратегии, хук не по бюджету и откуда 502расширенное
Округление в стратегии по умолчанию. Без явных maxSurge и maxUnavailable Kubernetes берёт 25 % и 25 %: лишние поды округляет вверх, недоступные — вниз. Для трёх реплик это уже maxSurge: 1, maxUnavailable: 0, а начиная с четырёх недоступным становится один под, и выкат идёт с просадкой. Явные значения в манифесте нужны, чтобы поведение не менялось от числа реплик.
Хук, который не уложился. Если preStop сам не завершился за бюджет, kubelet обрывает хук, шлёт SIGTERM и оставляет процессу минимальные две секунды до SIGKILL — на остановку с дрейном и пачками этого не хватит. Поэтому паузу в хуке держат заведомо короче бюджета и не делают в нём ничего, что может зависнуть.
Откуда 502 без preStop. Под выпадает из EndpointSlice в момент удаления, но kube-proxy на каждой из сотен нод перестраивает правила сам и с задержкой, а ingress-контроллеры и внешние балансировщики обновляют список адресов ещё позже. Пауза в 10 секунд закрывает эту рассинхронизацию, и никакая проба её не заменит.
Коротко
preStop: sleep 10даёт kube-proxy время убрать под из endpoints до того, как SIGTERM дойдёт до процесса. Без этого гарантированы 502 при каждом деплое.terminationGracePeriodSeconds: 60— явно в Deployment. Значение по умолчанию (30) не оставляет достаточно времени при preStop 10 секунд./health/liveвсегда возвращает 200;/health/readyвозвращает 503 с первого же момента shutdown. Из EndpointSlice под убирает само удаление, флаг страхует балансировщики, которые опрашивают ручку сами.atomic.Boolдля флага готовности — флаг читается из HTTP-горутин и пишется из shutdown-горутины одновременно.- Порядок завершения: readiness → 503, фоновые горутины,
srv.Shutdown, пул БД последним. maxSurge: 1, maxUnavailable: 0— новый под добавляется в rotation до того, как старый начинает останавливаться.- Стратегия по умолчанию 25 %/25 % для трёх реплик уже даёт
maxSurge: 1, maxUnavailable: 0, с четырёх реплик недоступным становится один — значения задают явно.
Что почитать дальше
- HTTP drain и in-flight запросы — как
srv.Shutdownдожидается активных соединений. - Фоновые горутины и outbox —
context.Contextиsync.WaitGroupдля фоновых задач. - БД и persistence — порядок закрытия pgxpool и транзакции при завершении.