Вы настроили Spring graceful shutdown — приложение корректно завершает запросы при получении SIGTERM. Деплоите. Клиенты всё равно видят 502. Почему?
Потому что Spring и Kubernetes завершают pod независимо друг от друга, и без правильной конфигурации K8s клиенты продолжают слать запросы на pod, который уже не отвечает. Разберём, что именно настроить и почему.
Что происходит при завершении pod
Когда Kubernetes решает завершить pod (при деплое новой версии, масштабировании вниз или явном удалении), происходит следующее:
- Kubelet запускает
preStop-хук (если есть). - Одновременно K8s начинает убирать pod из списка endpoints — то есть исключает его из балансировки.
- После
preStopkubelet отправляет процессу SIGTERM. - Процесс выполняет graceful shutdown и завершается.
- Если процесс не завершился за
terminationGracePeriodSeconds— kubelet отправляет SIGKILL.
Проблема в шаге 2: kube-proxy на других узлах обновляет свои таблицы маршрутизации не мгновенно. Между моментом, когда K8s пометил pod как завершающийся, и моментом, когда новые запросы перестали на него приходить, проходит 5–15 секунд. Все эти запросы получат 502.
preStop: буфер против 502
preStop — это хук, который kubelet выполняет перед отправкой SIGTERM. Если положить в него sleep 10, приложение получит 10 секунд, пока kube-proxy распространит изменения по кластеру, и только потом начнётся завершение.
spec:
containers:
- name: app
image: order-service:1.4.2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
Без preStop SIGTERM отправляется немедленно. Spring начинает завершение и перестаёт принимать новые запросы. Но kube-proxy ещё не знает — и ещё 5–15 секунд присылает запросы, которые получают отказ.
На одном pod это несколько тысяч ошибок. На rolling deploy с десятком pod — это происходит при каждом из них.
terminationGracePeriodSeconds: почему 60, а не 30
terminationGracePeriodSeconds — суммарный бюджет времени от запуска preStop до принудительного SIGKILL. Дефолт K8s — 30 секунд.
Считаем:
preStop sleep 10— 10 секунд.- Spring graceful shutdown — нужно минимум 30 секунд (дефолт Spring).
- Итого: 10 + 30 = 40 секунд.
40 секунд не помещаются в бюджет 30. Pod получит SIGKILL в середине дрейна — активные запросы прервутся.
Правильная конфигурация:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: order-service:1.4.2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
60 секунд дают комфортный запас: 10 на kube-proxy, 30 на дрейн запросов, ещё 20 на непредвиденные задержки.
Полная последовательность событий:
T=0 Kubelet запускает preStop
T=0 K8s начинает убирать pod из endpoints
T=10s preStop завершён → kubelet отправляет SIGTERM
T=10..40s Spring drain: завершает текущие запросы
T=40s Процесс завершился
(Если нет — SIGKILL в T=60s)
readinessProbe и livenessProbe: разные цели
Два вида проб делают разные вещи при провале:
| Проба | Что проверяет | Что делает K8s при провале |
|---|---|---|
readinessProbe | Готов ли pod принимать трафик | Убирает из endpoints (без перезапуска) |
livenessProbe | Жив ли процесс | Перезапускает pod |
При завершении работает readiness:
- Spring публикует состояние «отказываюсь от трафика».
- Endpoint
/actuator/health/readinessначинает отвечать 503. - K8s через несколько секунд видит провал readiness — убирает pod из endpoints.
- Трафик перестаёт идти на этот pod.
Если livenessProbe настроена на тот же endpoint и тоже падает при завершении — K8s перезапускает pod вместо того, чтобы дать ему корректно завершиться. Это ломает весь graceful shutdown.
Правильная конфигурация:
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 3
initialDelaySeconds: 60
initialDelaySeconds: 60 на liveness — защита от перезапуска во время старта. JVM с прогревом и первыми запросами стартует 20–30 секунд; без задержки liveness-проба может убить pod, который просто ещё загружается.
Readiness без задержки — нормально: пока pod не готов, 503 просто означает «не шли сюда трафик».
maxSurge и maxUnavailable: zero-downtime deploy
Rolling deploy заменяет старые pod'ы новыми постепенно. Два параметра управляют тем, как именно:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
maxSurge: 1— K8s может создать на один pod больше заданного числа реплик. При 3 репликах в деплое появится 4 pod: новый запускается до того, как старый начинает завершаться.maxUnavailable: 0— ни один pod не должен быть недоступен одновременно.
Что происходит при деплое:
Начало: 3 pod (версия v1)
1. Создаётся pod v2, итого 4 pod
2. Pod v2 проходит readinessProbe → входит в endpoints
3. Один pod v1 начинает завершение:
preStop sleep → SIGTERM → drain → выход
Итого: 2 v1 + 1 v2 = 3 активных
4. Создаётся второй pod v2, итого 4 pod
...
Новый pod всегда входит в rotation до того, как старый начинает завершаться. Capacity не падает ниже запрошенного числа реплик.
Если задать maxUnavailable: 1 — K8s может убить старый pod до запуска нового. В моменте будет 2 активных pod вместо 3, что при пиковой нагрузке может привести к 503.
Частые ошибки
Нет preStop. Без хука SIGTERM отправляется немедленно, kube-proxy не успел обновиться — 5–15 секунд гарантированных 502 на каждый перезапускаемый pod.
terminationGracePeriodSeconds: 30 с preStop sleep 10. У Spring остаётся только 20 секунд вместо 30. Запросы, которые выполняются дольше, прерываются через SIGKILL.
Один endpoint /actuator/health для обеих проб. При завершении 503 на liveness вызывает перезапуск pod — graceful shutdown не завершается, начинается restart-цикл.
livenessProbe зависит от базы данных. Если БД недоступна, liveness падает и pod перезапускается. Правильно: liveness проверяет только то, что процесс жив, а не доступность зависимостей.
initialDelaySeconds: 0 на liveness. Pod убивается сразу после старта, пока JVM ещё прогревается. Минимум 30 секунд, безопаснее 60.
Коротко
- preStop sleep 10 — обязательный буфер: kube-proxy обновляет маршруты 5–15 секунд, без него запросы идут на завершающийся pod и получают 502.
terminationGracePeriodSeconds: 60— дефолтных 30 не хватает: preStop 10 + Spring graceful 30 = 40 секунд, pod убивается в середине дрейна.readinessProbeубирает pod из endpoints без перезапуска.livenessProbeперезапускает pod. На graceful shutdown нужна readiness=503, liveness при этом не должна падать.maxSurge: 1, maxUnavailable: 0— новый pod входит в rotation до того, как старый начинает завершаться. Capacity не проседает.initialDelaySeconds: 60на liveness — защита от перезапуска во время прогрева JVM.
Что почитать дальше
- HTTP drain и preStop — как Spring дренирует запросы в окне между SIGTERM и завершением.
- JVM и Spring конфигурация — как выставить Spring graceful timeout и что он означает.
- Бюджеты и observability — как разложить 60 секунд между фазами.