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

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

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

Вот обе ошибки и правильная конфигурация на одной шкале времени.

T=0 — kubelet начинает завершение pod · шкала 0…60 с0 с10 с15 с30 с40 с60 с K8s убирает pod из endpointskube-proxy обновляет маршрутыдлится до 15 с запросы всё ещё идут на этот pod Tomcat закрыт для новыхОшибка: без preStop — SIGTERM сразу 0…15 с — клиент видит 502 Верно: preStop sleep 10 + tGPS 60preStopSpring drain 30 сзапас 20 с exit 0 в T=40SIGKILL T=60 граница дефолта: tGPS 30 — SIGKILL здесь Без preStop 502 идут от kube-proxy, а не от Spring10 на kube-proxy + 30 на дрейн = 40 — в бюджет 30 не влезает

Kubernetes убирает pod из endpoints не мгновенно: до 15 с маршруты ещё ведут на него. Без preStop Tomcat закрывается в эти же секунды — отсюда 502. С preStop sleep 10 бюджет становится 10 + 30 = 40 с, и в дефолтные 30 он не помещается.

Обязательно

Что происходит при завершении 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 как завершающийся, и моментом, когда новые запросы перестали на него приходить, проходит от секунды до пятнадцати — чем больше кластер, тем ближе к верхней границе. Все эти запросы получат 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=0kubelet запускает preStop; одновременно Kubernetes начинает убирать pod из endpoints
T=10 сpreStop завершён — kubelet отправляет SIGTERM
T=10…40 сSpring дренирует: доводит уже начатые запросы до ответа
T=40 спроцесс завершился сам
T=60 сесли не завершился — SIGKILL
стартует startupProbe liveness ждёт трафика нет готов liveness 10 с readiness 5 с pod в endpoints завершается preStop 10 с SIGTERM вне endpoints

Три состояния пода и что в каждом делает kubelet: смотреть стоит на верхний ряд, где до первого успеха startupProbe остальные пробы не выполняются вовсе.

readinessProbe и livenessProbe: разные цели

Два вида проб делают разные вещи при провале:

ПробаЧто проверяетЧто делает K8s при провале
readinessProbeГотов ли pod принимать трафикУбирает из endpoints (без перезапуска)
livenessProbeЖив ли процессПерезапускает pod

При завершении работает readiness:

  1. Spring публикует состояние «отказываюсь от трафика».
  2. Endpoint /actuator/health/readiness начинает отвечать 503 — это делает Spring сам, см. ApplicationAvailability в настройке Spring Boot.
  3. Все, кто спрашивает приложение о готовности напрямую — 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 — и о сломанном выкате узнают от пользователей.

Что происходит при деплое:

начало 3 pod версии v1 шаг 1 создан pod v2 — всего 4 pod шаг 2 pod v2 прошёл readinessProbe и вошёл в endpoints шаг 3 один pod v1 завершается: preStop, SIGTERM, drain, выход шаг 4 в строю 2 v1 и 1 v2 — снова 3 активных дальше создан второй pod v2, и так до конца выката

Новый 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 защищает от выселения при обслуживании узла (но не от аварий), согласуется с числом реплик, а разносит реплики по узлам отдельная настройка размещения.
  • Долгоживущие соединения дренаж не спасает: клиент обязан переподключаться, сервер закрывает их порциями с сообщением о завершении, состояние живёт вне пода, а срок жизни соединения ограничивают заранее.

Что почитать дальше