← назад к разделу

Вы настроили Spring graceful shutdown — приложение корректно завершает запросы при получении SIGTERM. Деплоите. Клиенты всё равно видят 502. Почему?

Потому что Spring и Kubernetes завершают pod независимо друг от друга, и без правильной конфигурации K8s клиенты продолжают слать запросы на pod, который уже не отвечает. Разберём, что именно настроить и почему.

Что происходит при завершении pod

Когда Kubernetes решает завершить pod (при деплое новой версии, масштабировании вниз или явном удалении), происходит следующее:

  1. Kubelet запускает preStop-хук (если есть).
  2. Одновременно K8s начинает убирать pod из списка endpoints — то есть исключает его из балансировки.
  3. После preStop kubelet отправляет процессу SIGTERM.
  4. Процесс выполняет graceful shutdown и завершается.
  5. Если процесс не завершился за 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:

  1. Spring публикует состояние «отказываюсь от трафика».
  2. Endpoint /actuator/health/readiness начинает отвечать 503.
  3. K8s через несколько секунд видит провал readiness — убирает pod из endpoints.
  4. Трафик перестаёт идти на этот 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 секунд между фазами.