При выкатке новой версии сервиса Kubernetes останавливает старый pod и поднимает новый. Если сделать это неаккуратно — часть запросов упадёт с ошибкой 502. Пользователь видит сбой, мониторинг сигналит. Разберём, как этого избежать.
Разница между «включили graceful» и «выкатываемся без 502» видна на одной шкале: Spring дожимает то, что уже в работе, а окно, в котором на pod ещё приходит новое, закрывает пауза перед SIGTERM.
Без 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=0 | Spring получает 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=0 | Kubernetes решает удалить pod |
| T=0+ | запускается preStop hook — команда sleep 10; приложение при этом живо и продолжает отвечать на запросы |
| параллельно | pod убирается из списка адресов Service — по факту удаления, а не потому, что проба готовности начала отвечать отказом: это разные пути |
| параллельно | kube-proxy на узлах обновляет iptables |
| T=10s | preStop завершился, 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.
Откуда взять своё число
«Десять секунд» — разумная отправная точка, а не результат измерения. Измерить своё можно, и делается это за полчаса.
Суть измерения: узнать, сколько времени проходит от удаления пода до момента, когда на него перестают приходить запросы. Порядок такой.
- Поднять сервис с двумя-тремя репликами и постоянной нагрузкой (об инструментах — ниже). Приложение должно писать в журнал каждый принятый запрос с отметкой времени.
- Удалить один под и зафиксировать время удаления:
kubectl delete pod <имя> --wait=falseплюс метка времени; событие удаления видно и вkubectl get events. - Посмотреть в журнале удаляемого пода отметку последнего принятого запроса. Разница между этим временем и временем удаления и есть задержка обновления правил на узлах вашего кластера.
- Повторить пять-десять раз (значение плавает) и взять худшее, добавив запас.
Практически у небольших кластеров получается 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 не трогает уже открытые соединения: 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спасает от обрыва, только если задача лежит в базе, подхватывается живым подом из очереди с блокировкой и исполняется идемпотентно.
Что почитать дальше
- Настройка JVM и Spring: базовая статья фазы, откуда берутся
server.shutdown, выбор таймаута и раздельные пробы, на которых держится весь дренаж. - Graceful shutdown в Spring Boot — обзор — полный обзор всех фаз.
- Kubernetes и terminationGracePeriodSeconds — детали конфигурации pod.
- Scheduled-задачи и @Async при shutdown — как настроить пулы потоков.