Вы настроили Spring graceful shutdown — приложение корректно завершает запросы при получении SIGTERM. Деплоите. Клиенты всё равно видят 502. Почему?
Потому что Spring и Kubernetes завершают pod независимо друг от друга, и без правильной конфигурации K8s клиенты продолжают слать запросы на pod, который уже не отвечает. Разберём, что именно настроить и почему.
Вот обе ошибки и правильная конфигурация на одной шкале времени.
Kubernetes убирает pod из endpoints не мгновенно: до 15 с маршруты ещё ведут на него. Без preStop Tomcat закрывается в эти же секунды — отсюда 502. С preStop sleep 10 бюджет становится 10 + 30 = 40 с, и в дефолтные 30 он не помещается.
Что происходит при завершении pod
Когда Kubernetes решает завершить pod (при деплое новой версии, масштабировании вниз или явном удалении), происходит следующее:
- Kubelet запускает
preStop-хук (если есть). - Одновременно K8s начинает убирать pod из списка endpoints — то есть исключает его из балансировки.
- После
preStopkubelet отправляет процессу SIGTERM. - Процесс выполняет graceful shutdown и завершается.
- Если процесс не завершился за
terminationGracePeriodSeconds— kubelet отправляет SIGKILL.
Проблема в шаге 2: kube-proxy на других узлах обновляет свои таблицы маршрутизации не мгновенно. Между моментом, когда K8s пометил pod как завершающийся, и моментом, когда новые запросы перестали на него приходить, проходит от секунды до пятнадцати — чем больше кластер, тем ближе к верхней границе. Все эти запросы получат 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"]
У этой записи есть скрытое условие: в образе должен быть шелл. В distroless и scratch его нет, и тогда хук молча не сработает — паузы не будет, а манифест при этом выглядит правильным. Начиная с Kubernetes 1.30 для такой паузы есть встроенное действие, которому шелл не нужен:
lifecycle:
preStop:
sleep:
seconds: 10
Без preStop SIGTERM отправляется немедленно. Spring начинает завершение и перестаёт принимать новые запросы. Но kube-proxy ещё не знает — и до 15 секунд присылает запросы, которые получают отказ.
Сколько это ошибок — зависит от нагрузки: при 200 запросах в секунду и окне в 10 секунд счёт идёт на тысячи, при десяти запросах в секунду — на сотню. Считайте по своему rps. И помните, что на выкате с десятком 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; одновременно Kubernetes начинает убирать pod из endpoints |
| T=10 с | preStop завершён — kubelet отправляет SIGTERM |
| T=10…40 с | Spring дренирует: доводит уже начатые запросы до ответа |
| T=40 с | процесс завершился сам |
| T=60 с | если не завершился — SIGKILL |
Три состояния пода и что в каждом делает kubelet: смотреть стоит на верхний ряд, где до первого успеха startupProbe остальные пробы не выполняются вовсе.
readinessProbe и livenessProbe: разные цели
Два вида проб делают разные вещи при провале:
| Проба | Что проверяет | Что делает K8s при провале |
|---|---|---|
readinessProbe | Готов ли pod принимать трафик | Убирает из endpoints (без перезапуска) |
livenessProbe | Жив ли процесс | Перезапускает pod |
При завершении работает readiness:
- Spring публикует состояние «отказываюсь от трафика».
- Endpoint
/actuator/health/readinessначинает отвечать 503 — это делает Spring сам, см. ApplicationAvailability в настройке Spring Boot. - Все, кто спрашивает приложение о готовности напрямую — ingress-контроллер, внешний балансировщик, соседний сервис, — видят отказ и перестают слать.
Важная оговорка про сам Kubernetes: когда останавливают pod, из endpoints его убирают по факту удаления, ещё до всяких проб. Так что «сделать readiness почаще» окном 502 не поможет — его закрывает только preStop. Readiness тут нужна для второго случая: когда сервис уходит из ротации сам, не дожидаясь удаления pod.
С livenessProbe на том же эндпоинте связан отдельный миф: будто при завершении её 503 заставит K8s перезапустить pod. Pod, который уже удаляют, kubelet не перезапустит. А вот в обычной работе общий эндпоинт опасен по-настоящему: стоит отвалиться базе или соседнему сервису, /actuator/health краснеет, liveness падает — и Kubernetes убивает живой, исправный pod.
Правильная конфигурация:
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 3
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 30
Про старт отвечает startupProbe: пока она не прошла, liveness и readiness не проверяются вовсе. Шаг 5 секунд и 30 попыток дают приложению до двух с половиной минут на прогрев — JVM с первыми запросами стартует 20–30 секунд, запас нужен на холодные узлы и медленные зависимости. Как только startup прошла, liveness работает в полную силу.
Раньше вместо этого писали initialDelaySeconds: 60 на liveness, и это плохой обмен: мало поставишь — проба убьёт стартующий pod, много — отложишь обнаружение настоящего зависания на ту же минуту. startupProbe снимает выбор: щедрое окно на старте и короткая реакция потом.
Readiness без задержки — нормально: пока pod не готов, 503 просто означает «не шли сюда трафик».
startupProbe: ответ на «JVM стартует тридцать секунд»
У двух проб выше есть третья родственница, которая решает конкретную проблему запуска. Приложение на Spring Boot поднимается 15–40 секунд, и если проба живости начинает проверять раньше, чем оно ответило, кластер убивает стартующий под — и так по кругу, бесконечно.
Лечить это увеличением порогов у живости плохо: большой failureThreshold означает, что и зависшее приложение будут терпеть минуты. Правильный инструмент — отдельная проба запуска:
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 24 # 24 × 5 = до двух минут на запуск
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 20
failureThreshold: 3 # а вот здесь пороги строгие
timeoutSeconds: 5
Как это работает. Пока проба запуска не прошла ни разу, пробы живости и готовности не выполняются вовсе — кластер ждёт. Как только она прошла, она больше не запускается никогда, а в дело вступают обычные пробы со своими строгими порогами. Получается лучшее из двух: щедрый запас на старт и быстрая реакция на зависание в работе.
Что даёт failureThreshold умноженный на periodSeconds: это и есть предельное время старта. Не уложился — под будет перезапущен, и это правильно: приложение, которое не поднялось за две минуты, скорее всего не поднимется вовсе (не нашло базу, не прочитало настройки).
Два практических замечания. Путь у пробы запуска обычно тот же, что у живости, — она отвечает на вопрос «процесс поднялся», а не «готов принимать трафик». И если проба запуска настроена, начальную задержку (initialDelaySeconds) у остальных проб можно вообще не задавать: её роль эта проба и исполняет.
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 во время выката не должно опускаться ниже заказанного числа реплик. Это ограничение на строй целиком, а не обещание про конкретный pod.
Рядом с ними стоят два параметра, которые отвечают на вопросы «когда считать новый под живым» и «когда признать выкат неудачным», — и без них выкат бывает зелёным там, где он сломан.
spec:
minReadySeconds: 15
progressDeadlineSeconds: 300
revisionHistoryLimit: 10
minReadySeconds — сколько под должен проработать готовым, прежде чем считаться доступным и дать выкату идти дальше. По умолчанию ноль: под прошёл пробу готовности один раз — и следующий старый уже гасится. Беда в том, что приложение, которое падает на пятой секунде работы (не хватило памяти под нагрузкой, отвалилась зависимость при первом обращении), успевает сказать «я готов» — и выкат едет дальше, заменяя рабочие поды сломанными. Пятнадцать секунд обычно достаточно, чтобы такое проявилось, и при этом выкат остаётся быстрым.
progressDeadlineSeconds — сколько выкат может не продвигаться, прежде чем его признают неудачным. По умолчанию 600 секунд. После истечения в состоянии появляется ProgressDeadlineExceeded, и kubectl rollout status возвращает ошибку — то есть конвейер узнаёт о неудаче. Важно, что само по себе это ничего не откатывает: поды остаются как есть, и откат делает человек или автоматика вокруг. Значение ставят по времени старта с запасом: десять минут для приложения, которое поднимается тридцать секунд, означает восемь минут неведения.
revisionHistoryLimit — сколько прошлых наборов реплик хранится для откатов; по умолчанию десять, и уменьшать это обычно не нужно.
И проверка, которая связывает всё вместе: в конвейере после применения манифеста ждут результат выката и падают, если он не прошёл.
kubectl apply -f k8s/
kubectl rollout status deployment/orders --timeout=6m # вернёт ошибку по истечении срока
Без этой строки конвейер считает выкат успешным сразу после apply — и о сломанном выкате узнают от пользователей.
Что происходит при деплое:
Новый pod входит в endpoints раньше, чем старый начинает завершаться, — поэтому во время выката в строю всегда не меньше трёх. Плата за такой порядок: в этот момент кластеру нужна мощность на один pod больше, и если свободного места нет, выкат встанет.
Новый pod всегда входит в ротацию до того, как старый начинает завершаться. Число готовых pod не опускается ниже заказанного.
Если задать maxUnavailable: 1 — K8s может убить старый pod до запуска нового. В моменте будет 2 активных pod вместо 3, что при пиковой нагрузке может привести к 503.
Откат и миграции: чего не отменит rollout undo
Выкат разобран, а обратное движение — нет, и в нём прячется самая дорогая ошибка.
Сам откат прост. Кластер хранит прошлые наборы реплик, и возврат — это обычный плавный выкат в обратную сторону:
kubectl rollout history deployment/orders # какие ревизии есть
kubectl rollout undo deployment/orders # на предыдущую
kubectl rollout undo deployment/orders --to-revision=7
Работает он ровно так же, как выкат: с теми же maxSurge, maxUnavailable, паузой перед закрытием сокета и дренажом. То есть всё, что настроено для остановки, действует и при откате — и это хорошо.
А вот база назад не откатывается. Миграция, которую применила новая версия, уже применена. Если она добавила колонку — старый код будет работать, не замечая её. Если она переименовала или удалила колонку, старый код упадёт: он ищет то, чего больше нет. Получается неприятная картина: выкат откатили, а сервис по-прежнему не работает, и теперь надо срочно писать обратную миграцию в три часа ночи.
Отсюда единственное надёжное правило, и оно про то, как писать миграции, а не про то, как откатывать:
Схема совместима с предыдущей версией кода. Каждая миграция должна оставлять базу такой, чтобы прежняя версия приложения продолжала работать. Практически это означает разбивать изменение на шаги через несколько выкатов:
- добавить новую колонку рядом со старой, писать в обе, читать из старой;
- выкатить версию, которая читает из новой;
- убедиться, что старая версия больше не работает нигде;
- только потом удалить старую колонку отдельной миграцией.
Три выката вместо одного — цена возможности откатиться в любой момент. Подробный разбор с примерами — в статье про базу и остановку и в статье про доставку.
И то, что стоит проверить заранее. Спросите себя про последнюю миграцию: если сейчас откатить сервис на предыдущую версию, она заработает? Если ответ «нет», у вас нет возможности откатиться, и это надо знать до выката, а не после.
Вывод узла: PodDisruptionBudget
Всё выше — про выкат, когда поды меняете вы. Есть второй путь, по которому под может уйти, и он не ваш: обслуживание узла. Администратор обновляет ядро, меняет машину, уменьшает кластер — и выселяет поды командой kubectl drain.
Выселение уважает остановку: подам посылается сигнал, отведённое время соблюдается, пауза перед закрытием сокета работает. Чего оно не делает само — не заботится о том, сколько ваших реплик уйдёт одновременно. Все три реплики на одном узле означают, что сервис исчезнет целиком, хотя ничего не сломалось.
Правило задаёт отдельный объект:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: orders
spec:
minAvailable: 2 # или maxUnavailable: 1
selector:
matchLabels:
app: orders
Теперь выселение идёт по одному поду: кластер гасит один, ждёт, пока замена станет готовой на другом узле, и берётся за следующий. Если правило выполнить нельзя, выселение блокируется — администратор увидит отказ и подождёт, и это лучше, чем лежащий сервис.
Четыре вещи, которые важно знать.
Это работает только для добровольных выселений. Падение узла, нехватка памяти, удаление пода вручную — не выселение, и бюджет тут не участвует. Он про обслуживание, а не про аварии.
Значение согласуют с числом реплик. Три реплики и minAvailable: 2 — разумно. Три и minAvailable: 3 означают «выселять нельзя никогда», и обслуживание кластера встанет на вашем сервисе. Для одной реплики бюджет либо не заводят, либо пишут maxUnavailable: 1 и соглашаются на перерыв.
Замена должна успевать становиться готовой. Выселение ждёт готовности новых подов, поэтому медленный старт растягивает обслуживание узла на десятки минут. Здесь и пригождается проба запуска: без неё «готов» наступает то слишком рано, то слишком поздно.
Размещение реплик по узлам — отдельная настройка. Бюджет не заставляет реплики стоять на разных узлах; это делают правила размещения (topologySpreadConstraints или анти-совместимость подов). Без них три реплики могут оказаться на одном узле, и падение этого узла (уже не выселение) погасит сервис целиком.
Практический минимум: у каждого сервиса с несколькими репликами есть такой объект, и его проверяют не чтением манифеста, а прогоном: kubectl drain на тестовом узле под нагрузкой — и ноль ошибок у клиента.
Долгоживущие соединения при выкате
Отдельный случай, который выкат ломает тише всего: соединения, которые живут дольше одного запроса. Пауза перед закрытием сокета и дренаж рассчитаны на запрос длительностью секунды; соединение, открытое на час, ни одна из этих мер не спасает.
Что именно страдает. Соединение WebSocket с браузером, поток событий (SSE), долгий поток gRPC, подписка на обновления. При остановке пода такое соединение закрывается, и клиент видит обрыв. На выкате с тремя репликами это означает, что все клиенты по очереди получат обрыв — по одному на каждую замену пода.
Чего делать не надо. Увеличивать отведённое время до часа, чтобы «дождаться, пока клиенты сами уйдут». Выкат превратится в многочасовую процедуру, а kubectl drain заблокируется на всё это время.
Что делают вместо.
Клиент обязан переподключаться. Для WebSocket это не пожелание, а часть клиента: обрыв, пауза со случайным разбросом, повторное подключение, восстановление состояния. Для потока событий в браузере это работает само — встроенный механизм переподключается и присылает идентификатор последнего полученного события, если сервер его выставлял. Проверять это надо не на выкате, а раньше: закройте соединение вручную и посмотрите, что делает клиент.
Сервер закрывает соединения сам, порциями. Получив сигнал остановки, приложение не ждёт, а рассылает по своим соединениям сообщение «сеанс завершается, переподключитесь» и закрывает их не все сразу, а группами с паузой. Тогда клиенты расходятся по живым подам постепенно, а не бьют в один в тот же миллисекунд. Это несколько десятков строк в обработчике остановки, и они того стоят.
Состояние — не в поде. Всё, что клиент потеряет при обрыве (подписки, позиция в потоке, накопленный контекст), должно восстанавливаться после переподключения — то есть лежать в общем хранилище или приходить от клиента. Иначе переподключение технически проходит, а работа пользователя всё равно теряется.
Срок жизни соединения ограничивают заранее. Если соединение всё равно живёт не дольше пятнадцати минут (сервер сам закрывает его и клиент переподключается), то выкат перестаёт быть событием: клиенты и так переподключаются постоянно, и никто не заметит разницы. Для gRPC это настройка максимального возраста соединения на стороне сервера, для WebSocket — своя логика в приложении.
Разбор того, как это выглядит в коде Spring, — в статье про дренаж HTTP.
Частые ошибки
Нет preStop. Без хука SIGTERM отправляется немедленно, kube-proxy не успел обновиться — до 15 секунд гарантированных 502 на каждый перезапускаемый pod.
terminationGracePeriodSeconds: 30 с preStop sleep 10. У Spring остаётся только 20 секунд вместо 30. Запросы, которые выполняются дольше, прерываются через SIGKILL.
Один endpoint /actuator/health для обеих проб. Он краснеет от любой недоступной зависимости, а значит, liveness будет падать в обычной работе — и Kubernetes перезапустит исправный pod.
livenessProbe зависит от базы данных. Если БД недоступна, liveness падает и pod перезапускается — хотя перезапуск базу не чинит. Правильно: liveness проверяет только то, что процесс жив.
Нет startupProbe. Пока JVM прогревается, liveness успевает трижды получить отказ и убить pod. Ставить вместо этого большой initialDelaySeconds — значит на ту же минуту ослепнуть к настоящим зависаниям.
Глубже: sidecar умирает раньше приложениярасширенное
Схема остановки выше нарисована для пода с одним контейнером. В проде рядом с приложением обычно стоит прокси сервисной сетки или агент секретов, и SIGTERM приходит всем контейнерам пода одновременно. Прокси завершается за секунды, а приложение в это время ещё дренирует запросы и делает исходящие вызовы через тот самый прокси: платёж в середине саги упирается в закрытый localhost:15001, и graceful ломается там, где его меньше всего ждали.
Обходные пути, которые работали до сих пор: задержка в preStop у sidecar, чтобы он жил дольше, чем окно дренажа приложения, или настройка прокси «не выходить, пока есть активные соединения» с пределом ожидания (у Istio это terminationDrainDuration и режим ожидания нулевых соединений). Оба хрупкие: числа приходится согласовывать с бюджетом остановки руками, и при увеличении spring.lifecycle.timeout-per-shutdown-phase кто-то должен вспомнить про прокси.
Штатный ответ появился в Kubernetes 1.29: контейнер объявляют в initContainers с restartPolicy: Always, и он становится настоящим sidecar с правильным порядком: стартует до приложения и готов раньше него, живёт вместе с ним, а при остановке получает SIGTERM после того, как основные контейнеры завершились. Сервисные сетки поддерживают этот режим отдельной настройкой, и с ним прокси гарантированно переживает дренаж приложения без единого числа в preStop. На старте это решает и обратную проблему, когда приложение поднимается раньше прокси и первые исходящие вызовы падают.
Что проверить у себя: kubectl get pod -o yaml покажет, где объявлен прокси; если в containers, порядок остановки не гарантирован, и нужен либо переезд в initContainers, либо задержка у прокси не меньше окна дренажа приложения. Как устроены многоконтейнерные поды вообще, разбирает статья про основы Kubernetes.
Коротко
- preStop sleep 10 — обязательный буфер: kube-proxy обновляет маршруты до 15 секунд, без него запросы идут на завершающийся pod и получают 502.
terminationGracePeriodSeconds: 60— дефолтных 30 не хватает: preStop 10 + Spring graceful 30 = 40 секунд, pod убивается в середине дрейна.readinessProbeотвечает на вопрос «слать ли трафик»,livenessProbe— «жив ли процесс». Вешать их на один эндпоинт нельзя: он краснеет от любой недоступной зависимости и приводит к перезапуску исправного pod.maxSurge: 1, maxUnavailable: 0— новый pod входит в ротацию до того, как старый начинает завершаться. Число готовых pod не опускается ниже заказанного.startupProbe— штатная защита медленного старта;initialDelaySecondsна liveness её не заменяет.startupProbeдаёт щедрый запас на старт JVM (failureThreshold×periodSeconds) и не мешает держать строгие пороги у живости: пока она не прошла, остальные пробы не выполняются.- Sidecar получает
SIGTERMвместе с приложением и умирает раньше, обрывая исходящие вызовы; штатный порядок дают sidecar черезinitContainersсrestartPolicy: Always(Kubernetes 1.29), иначе задержка у прокси не меньше окна дренажа. minReadySecondsне даёт выкату ехать дальше на подах, которые падают через пять секунд;progressDeadlineSecondsопределяет, когда выкат признают неудачным, аkubectl rollout statusприносит это в конвейер.rollout undoвозвращает код, но не схему базы: миграции пишут совместимыми с предыдущей версией и в три выката, иначе откатиться нельзя.PodDisruptionBudgetзащищает от выселения при обслуживании узла (но не от аварий), согласуется с числом реплик, а разносит реплики по узлам отдельная настройка размещения.- Долгоживущие соединения дренаж не спасает: клиент обязан переподключаться, сервер закрывает их порциями с сообщением о завершении, состояние живёт вне пода, а срок жизни соединения ограничивают заранее.
Что почитать дальше
- HTTP drain и preStop — как Spring дренирует запросы в окне между SIGTERM и завершением.
- JVM и Spring конфигурация — как выставить Spring graceful timeout и что он означает.
- Бюджеты и observability — как разложить 60 секунд между фазами.