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

При выкатке новой версии сервиса Kubernetes останавливает старый pod и поднимает новый. Если сделать это неаккуратно — часть запросов упадёт с ошибкой 502. Пользователь видит сбой, мониторинг сигналит. Разберём, как этого избежать.

Разница между «включили graceful» и «выкатываемся без 502» видна на одной шкале: Spring дожимает то, что уже в работе, а окно, в котором на pod ещё приходит новое, закрывает пауза перед SIGTERM.

Остановка pod'а · бюджет terminationGracePeriodSeconds 60 с0 с5 с10 с40 с60 сt = 0: k8s удаляет podSIGKILL · конец бюджета Без preStop: SIGTERM в t = 0Tomcat закрыт для новых← дожимает активные ≤ 30 с 0–5 с: kube-proxy ещё шлёт сюда трафик → клиент ловит 502 С preStop sleep 10: SIGTERM отложен до t = 10 сsleep 10 эти 10 с приложение ещё отвечает — kube-proxy обновляет iptables дожимает активные ≤ 30 сt = 10 с: SIGTERM — Tomcat закрыт, новых уже нет exit 0 не позже 40 с — запас до SIGKILL 20 с Spring graceful дожимает активные, но окно 502 не закрываетЕго закрывает preStop sleep 10 — пауза перед SIGTERM, пока обновляется kube-proxy

Без preStop в окне 0–5 с kube-proxy ещё шлёт новые запросы на pod, который уже закрыл приём, — это и есть 502. Пауза sleep 10 перед SIGTERM отдаёт кластеру время обновить правила, а приложение эти 10 с продолжает отвечать: Spring потом дожимает только активные и выходит не позже 40-й секунды из бюджета 60.

Обязательно

Что происходит, когда pod останавливают

Допустим, сервис обрабатывает запрос пользователя. В этот момент Kubernetes решает остановить pod — например, при rolling update. Без специальной настройки процесс получает сигнал SIGTERM и завершается. Запрос прерывается на середине. Клиент видит 502 или обрыв соединения.

Spring Boot умеет этого избегать: дождаться ответов на всё, что уже в пути, — это и называется HTTP drain, — и только потом выйти. Режим называется graceful shutdown. Включается одной строкой:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

С этим включённым вот что происходит после SIGTERM:

МоментЧто происходит
T=0Spring получает SIGTERM
T=0+readiness переходит в REFUSING_TRAFFIC, на пробу уходит 503
T=0+Tomcat перестаёт принимать новые соединения, активные запросы продолжают обрабатываться
параллельноpod убрали из списка адресов Service ещё при удалении, а не по ответу 503; но kube-proxy на узлах узнаёт об этом не сразу
T=Nвсе активные запросы завершились — Spring завершает работу

Самое важное в этой таблице — строка «параллельно»: из списка адресов Service pod убирают по факту удаления, а не потому, что проба ответила 503. Проба здесь ни при чём. А вот kube-proxy на каждом узле обновляет правила с задержкой, и из этой задержки складывается окно, о котором следующий раздел.

Активный запрос, который уже обрабатывается, не прерывается. Spring ждёт его завершения — до значения timeout-per-shutdown-phase.

Почему Spring graceful недостаточен — нужен preStop

Здесь кроется неочевидная проблема. Kubernetes работает на многих узлах, и когда pod помечается для удаления, информация об этом расходится по кластеру не мгновенно.

Происходит вот что: Kubernetes одновременно отправляет SIGTERM в процесс и даёт команду убрать pod из списка endpoints Service. Но kube-proxy на других узлах обновляет свои правила iptables с задержкой — обычно 1–5 секунд, на больших кластерах (тысячи узлов) до 15. Всё это время новые запросы всё ещё маршрутизируются на pod, который уже начал завершение и не принимает соединения.

Результат: даже при правильном Spring graceful в этом окне будут 502.

Решение — preStop hook: заставить Kubernetes подождать перед отправкой SIGTERM.

spec:
  terminationGracePeriodSeconds: 60   # поле пода, не контейнера
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]

Обратите внимание на отступ у terminationGracePeriodSeconds: это поле самого пода, а не контейнера. Записанное на одном уровне с lifecycle оно просто не пройдёт проверку манифеста — ошибка неочевидная, потому что рядом стоящие поля выглядят похоже.

У варианта с sh -c есть слабое место: в образе может не быть шелла. Distroless и scratch собирают как раз без него, и тогда хук молча не сработает — паузы не будет, а манифест выглядит правильным. Начиная с Kubernetes 1.30 для этого есть встроенная пауза, которой шелл не нужен:

      lifecycle:
        preStop:
          sleep:
            seconds: 10

С preStop последовательность меняется:

МоментЧто происходит
T=0Kubernetes решает удалить pod
T=0+запускается preStop hook — команда sleep 10; приложение при этом живо и продолжает отвечать на запросы
параллельноpod убирается из списка адресов Service — по факту удаления, а не потому, что проба готовности начала отвечать отказом: это разные пути
параллельноkube-proxy на узлах обновляет iptables
T=10spreStop завершился, Kubernetes отправляет SIGTERM
T=10s+Spring graceful drain: ждёт завершения активных запросов

За 10 секунд сна kube-proxy успевает обновиться. К моменту, когда Spring получает SIGTERM и перестаёт принимать соединения, новый трафик уже не маршрутизируется на этот pod. Spring drain занимается только теми запросами, которые уже были в обработке.

Важный момент: terminationGracePeriodSeconds — это полный бюджет (preStop + Spring drain + всё остальное). Значение 60 секунд обычно достаточно: 10s preStop + до 30s Spring drain + запас.

Десять секунд — хорошее значение для большинства кластеров. На очень больших (тысячи узлов) можно увеличить до 15.

Откуда взять своё число

«Десять секунд» — разумная отправная точка, а не результат измерения. Измерить своё можно, и делается это за полчаса.

Суть измерения: узнать, сколько времени проходит от удаления пода до момента, когда на него перестают приходить запросы. Порядок такой.

  1. Поднять сервис с двумя-тремя репликами и постоянной нагрузкой (об инструментах — ниже). Приложение должно писать в журнал каждый принятый запрос с отметкой времени.
  2. Удалить один под и зафиксировать время удаления: kubectl delete pod <имя> --wait=false плюс метка времени; событие удаления видно и в kubectl get events.
  3. Посмотреть в журнале удаляемого пода отметку последнего принятого запроса. Разница между этим временем и временем удаления и есть задержка обновления правил на узлах вашего кластера.
  4. Повторить пять-десять раз (значение плавает) и взять худшее, добавив запас.

Практически у небольших кластеров получается 1–3 секунды, у крупных — 5–15. Если приложение логирует не каждый запрос, тот же ответ даёт метрика с числом запросов по поду: смотрят, через сколько после удаления она обнулилась.

Что ещё влияет на это число, кроме размера кластера: контроллер входящего трафика (он узнаёт об изменении отдельно и держит свои соединения), внешний балансировщик облака (обновляет список машин медленнее, чем кластер обновляет правила — там бывают и десятки секунд), и сервисная сетка (у неё свой путь доставки изменений). Поэтому итоговую паузу берут по самому медленному участнику, а не по kube-proxy.

Как проверить, что 502 больше нет

Ни одна настройка остановки не считается сделанной, пока не проверена под нагрузкой. Проверка простая, и её стоит прогонять после каждого изменения бюджета.

Подаём постоянную нагрузку и выкатываем новую версию:

# постоянная нагрузка: 50 запросов в секунду на две минуты
hey -z 2m -q 50 -c 10 https://orders.example.com/api/orders/1

# в это же время в другом окне
kubectl rollout restart deployment/orders
kubectl rollout status deployment/orders

В отчёте смотрят на распределение кодов ответа: должны быть только 200 (и осмысленные 4xx, если они бывают в норме). Любой 502, 503 или connection reset означает, что окно осталось: где-то не хватает паузы перед закрытием сокета, или дренаж не успевает, или соединение держит контроллер входящего трафика.

Из инструментов подходят любые: hey (проще всего), vegeta (даёт точную частоту и гистограмму), k6 (сценарии на JavaScript, удобно для сложных проверок). Важно одно свойство: инструмент должен держать постоянную частоту, а не «столько, сколько выдержит сервис», иначе провал соединений будет незаметен.

Чуть более строгая версия той же проверки — прогонять её не на выкате, а на выводе узла из работы (kubectl drain): там к остановке добавляется бюджет выселения и правила PodDisruptionBudget, и это другой путь, который тоже стоит пройти до прода.

Ещё одна проверка, которая ловит самую обидную ошибку: остановить контейнер локально и посмотреть код выхода — docker stop должен уложиться быстрее отведённого времени и дать код 143, а не 137. Если 137, сигнал не доходит до приложения, и никакие паузы в кластере не помогут; разбор — в статье про настройку JVM и Spring.

Долгие синхронные операции

Предположим, есть endpoint, который генерирует отчёт — синхронно, за 30 секунд. При остановке pod пять таких запросов в обработке. Spring будет ждать их завершения, но timeout-per-shutdown-phase: 30s не позволит ждать вечно. Часть запросов прервётся.

Проблема не в Spring и не в Kubernetes — проблема в дизайне endpoint'а. Долгая синхронная операция через HTTP плохо сочетается с любым вариантом остановки сервиса.

Вариант 1: 202 Accepted + polling

Вместо того чтобы держать соединение открытым, endpoint сразу возвращает 202 и идентификатор задачи. Клиент периодически опрашивает статус.

@RestController
@RequiredArgsConstructor
public class ReportController {

    private final ReportService reportService;

    @PostMapping("/reports")
    public ResponseEntity<ReportStarted> start(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody @Valid ReportRequest req
    ) {
        var reportId = reportService.enqueue(key, req);
        return ResponseEntity.accepted()
            .header("Location", "/reports/" + reportId)
            .body(new ReportStarted(reportId, "QUEUED"));
    }

    @GetMapping("/reports/{id}")
    public ReportStatus get(@PathVariable Long id) {
        return reportService.getStatus(id);
    }
}

POST возвращает ответ за миллисекунды. Фактическая генерация идёт в фоновом потоке. При остановке pod короткий POST не блокирует drain, а фоновый процесс может завершить работу в рамках своего бюджета.

Вариант 2: @Async с настроенным пулом

Контракт снаружи тот же, что и в первом варианте: клиент сразу получает 202. Разница в том, где ждёт своей очереди незаконченная работа — не в таблице заданий, а в пуле потоков внутри процесса:

@PostMapping("/process")
public ResponseEntity<ProcessStarted> process(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody @Valid ProcessRequest req
) {
    asyncProcessor.startAsync(key, req);
    return ResponseEntity.accepted().body(new ProcessStarted(key, "STARTED"));
}

asyncProcessor использует ThreadPoolTaskExecutor, и одного waitForTasksToCompleteOnShutdown = true тут мало: этот флаг только запрещает прерывать уже запущенные задачи, но ждать их Spring всё равно не станет. Ждать он начинает, когда задан срок ожидания — setAwaitTerminationSeconds(20). Нужны обе настройки, иначе процесс уйдёт дальше, не дожидаясь ни одной задачи.

И плата за такой вариант: всё, что не успело выполниться, лежит в памяти пула. При остановке этот список никуда не записывается, и следующий запуск о нём не узнает. Первый вариант такого не теряет — там задание уже лежит строкой в базе.

Вариант 3: оптимизация

Иногда endpoint долгий не по природе операции, а из-за лишних запросов к базе или неоптимального кода. Стоит проверить: может быть, 30 секунд — это несколько N+1 запросов, а сама операция могла бы занять 2 секунды.

А если под умер, не дождавшись задачи

Перевод долгой операции в приём задачи и ответ 202 решает вопрос обрыва соединения, но создаёт следующий, который часто оставляют без ответа: задача принята, ответ клиенту отдан, а под умер раньше, чем работа закончилась. Клиент опрашивает статус и видит «в работе» — вечно.

Правильная схема состоит из трёх частей, и все три обязательны.

Задача — это запись в базе, а не объект в памяти. Приём задачи — это транзакция, которая создаёт строку со статусом PENDING, и только после её фиксации клиенту отвечают 202. Если задача живёт в памяти исполнителя или в очереди внутри процесса, смерть пода уносит её вместе с собой, и никакой опрос статуса уже не поможет.

Исполнение подхватывает тот, кто жив. Работу берут из той же таблицы — выборкой под блокировку (SELECT ... FOR UPDATE SKIP LOCKED), с отметкой «взято в работу» и временем. Тогда задача незавершённого пода вернётся в обработку: другой под увидит запись, которая слишком долго висит «в работе», и возьмёт её снова. Механику разбирает статья про шаблоны обмена сообщениями.

Повторное исполнение безопасно. Раз задачу могут взять дважды (первый под успел сделать половину), сама работа должна быть идемпотентной: с ключом операции, с проверкой «уже сделано» или с естественным уникальным индексом. Это ровно то, о чём статья про идемпотентность, и без этой части схема с 202 даёт двойные списания вместо обрывов — обмен одной беды на другую.

Отсюда и практическая проверка: остановите под в момент, когда задача в работе, и посмотрите, чем она закончилась. Правильный результат — задача выполнена один раз и статус в итоге стал DONE; неправильные — вечное «в работе» или выполнение дважды.

Частая ошибка: сигнал не доходит до приложения

Дрейном занимается не Tomcat сам по себе, а Spring: он ставит коннекторы на паузу и дальше просто опрашивает счётчик запросов в работе, пока тот не станет нулём. Поэтому крутить таймауты коннектора бессмысленно — в дрейне они не участвуют. Ломают его в других местах.

Shell-форма ENTRYPOINT в Dockerfile. Строка ENTRYPOINT java -jar app.jar разворачивается в /bin/sh -c "java -jar app.jar". Первым процессом становится шелл, SIGTERM приходит ему, а до JVM не доходит вовсе — приложение спокойно работает до самого SIGKILL, и все активные запросы обрываются разом. Пишите exec-форму: ENTRYPOINT ["java", "-jar", "app.jar"].

spring.main.register-shutdown-hook: false. Этот хук — единственное, что связывает сигнал с закрытием контекста. Без него JVM на SIGTERM просто умирает: ни дрейна, ни закрытия пулов.

kill -9, docker kill, истёкший terminationGracePeriodSeconds. SIGKILL перехватить нельзя — процесс останавливают на том месте, где он был. Это не ошибка настройки, а напоминание: бюджет должен покрывать всё, что вы просите приложение доделать.

Дополнительно: при первом чтении можно пропустить

Глубже: долгоживущие соединения: WebSocket, SSE и gRPC-стримы при остановкерасширенное

server.shutdown: graceful ждёт завершения запросов в работе. WebSocket и SSE это запросы, которые не завершаются никогда: соединение открыто часами, и для Tomcat каждое из них «в работе». Остановка ждёт весь таймаут, потом обрывает всё разом, и двадцать тысяч клиентов получают обрыв в одну секунду и переподключаются в одну секунду к оставшимся репликам, которые от этого ложатся.

Дренировать такие соединения приложение обязано само, до того, как веб-сервер начнёт ждать. Место для этого есть: обработчик ContextClosedEvent или бин SmartLifecycle с фазой выше, чем у веб-сервера, срабатывает в начале остановки, когда readiness уже снята. В нём соединения закрывают по правилам протокола: WebSocket получает кадр закрытия с кодом 1001 («сервер уходит»), по которому клиентская библиотека знает, что нужно переподключиться, а не показывать ошибку; SSE получает событие вида event: reconnect с полем retry, после чего поток закрывают, и EventSource в браузере переподключится сам через указанный интервал. Закрывают не разом, а порциями с разбросом в несколько секунд, чтобы переподключения размазались по времени и по репликам; как это укладывается в выкат, разбирает статья про масштабирование WebSocket.

gRPC-стримы устроены иначе: сервер посылает по соединению HTTP/2 кадр GOAWAY, клиент доделывает текущие вызовы и открывает новое соединение к другой реплике. У grpc-java это server.shutdown() (перестать принимать, дождаться текущих) и awaitTermination с пределом, а стримы, которые не завершаются сами, обрывают shutdownNow в конце окна; клиентам с включёнными повторами этого достаточно.

Что остаётся неизбежным: клиент, который переподключается без разброса и без ограничения попыток, устроит шторм при любой аккуратности сервера. Поэтому правила переподключения (случайная задержка, растущая пауза, предел) это часть контракта с фронтендом, а не деталь сервера. И бюджет: дренаж двадцати тысяч соединений порциями занимает секунды, и они входят в те же terminationGracePeriodSeconds, что и всё остальное.

Глубже: keep-alive от ingress: endpoints обновились, соединение живорасширенное

Сделано всё по статье, а 502 при выкате остались, редкие, по несколько на выкат. Причина в соединениях, которые уже открыты. Ingress (nginx и большинство прокси) держит к каждому поду пул keep-alive соединений и переиспользует их. Под снят из endpoints, nginx новых соединений к нему не откроет, но старое живое соединение он продолжит использовать, пока оно не закроется.

Tomcat в режиме graceful перестаёт принимать новые соединения и закрывает простаивающие keep-alive, и вот гонка: nginx отправляет запрос в соединение, которое Tomcat закрывает в ту же миллисекунду; nginx видит upstream prematurely closed connection и отвечает 502. GET nginx повторит на другой под сам, POST нет.

Лечат с двух сторон. Со стороны сервера: пауза preStop из раздела выше даёт nginx время перестать отправлять запросы в этот под до того, как Tomcat начнёт закрывать соединения; для этого пауза должна быть длиннее, чем интервал обновления endpoints у ingress, обычно пять секунд достаточно, десять надёжнее. Со стороны таймаутов: правило «сервер держит keep-alive дольше, чем клиент». У nginx-ingress простаивающее соединение к поду живёт upstream-keepalive-timeout (60 секунд), у Tomcat server.tomcat.keep-alive-timeout по умолчанию равен таймауту соединения, те же 60 секунд, и при равных значениях кто закроет первым, решает случай. Tomcat ставят больше, например 75 секунд: тогда простаивающее соединение всегда закрывает nginx, и он никогда не отправит запрос в соединение, которое сервер закрыл секунду назад. То же правило действует между вашими сервисами: keep-alive у HTTP-клиента короче, чем у сервера, к которому он ходит.

И контроль: считать 502 на ingress в окне выката (nginx_ingress_controller_requests с кодом по сервису), а не в приложении, которое этих ответов не видит. Ноль за выкат это критерий, о котором говорит статья про бюджеты.

под удалён endpoints обновились nginx новых соединений не открывает старое keep-alive живо, в него идёт запрос та же миллисекунда Tomcat закрывает его клиент 502 upstream closed чем лечат preStop + keep-alive 75 с

Обновление endpoints не трогает уже открытые соединения: 502 рождается в ту миллисекунду, когда nginx пишет запрос в соединение, которое Tomcat закрывает.

Коротко

  • Spring graceful shutdown (server.shutdown: graceful) дожимает активные HTTP-запросы до ответа перед остановкой.
  • Одного Spring graceful мало: kube-proxy на других узлах обновляет правила 1–5 секунд после удаления pod, на больших — до 15, и в это окно новый трафик приходит на уже завершающийся pod.
  • preStop: sleep 10 в k8s-манифесте решает это: 10 секунд приложение живо и продолжает отвечать, kube-proxy успевает обновиться, и только потом Kubernetes отправляет SIGTERM. Своё значение паузы измеряют: удалить под, посмотреть в журнале время последнего принятого запроса, повторить несколько раз и взять худшее с запасом; по самому медленному участнику — контроллеру входящего трафика или балансировщику облака, а не по kube-proxy.
  • terminationGracePeriodSeconds должен покрывать preStop + Spring drain с запасом; значение 60s обычно достаточно.
  • Долгие синхронные операции (более 10 секунд) плохо сочетаются с drain — переводите их в 202 Accepted + polling или в пул потоков, где заданы и waitForTasksToCompleteOnShutdown, и awaitTerminationSeconds.
  • Чаще всего graceful ломает не настройка Tomcat, а то, что SIGTERM не доходит до JVM: shell-форма ENTRYPOINT или выключенный register-shutdown-hook.
  • WebSocket и SSE graceful не дренирует: закрывать самим в начале остановки порциями с разбросом, WebSocket кодом 1001, SSE событием с retry, gRPC через GOAWAY; правила переподключения это часть контракта с клиентом.
  • Остаточные 502 дают живые keep-alive соединения от ingress: preStop длиннее обновления endpoints, keep-alive у Tomcat дольше, чем у nginx, чтобы закрывал клиент; считать 502 на ingress в окне выката.
  • Настройка считается сделанной только после проверки: постоянная нагрузка (hey, vegeta, k6) плюс rollout restart, и в отчёте ноль 502 и 503; отдельно — та же проверка на kubectl drain.
  • Ответ 202 спасает от обрыва, только если задача лежит в базе, подхватывается живым подом из очереди с блокировкой и исполняется идемпотентно.

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