Когда Kubernetes останавливает pod, он посылает процессу сигнал SIGTERM. По умолчанию Spring Boot обрабатывает его жёстко: немедленно останавливает сервер, обрывая все активные HTTP-запросы. Клиенты получают 502 Bad Gateway или Connection reset. Это и есть проблема, которую решает graceful shutdown.
Правильная остановка — не одна настройка, а последовательность: сначала сказать Kubernetes «мне больше не слать запросы», потом дать балансировщику время это заметить, потом дообработать то, что уже пришло, закрыть соединения с базой и брокером — и только тогда выйти. В этой статье разберём минимальный набор Spring-настроек, которые это обеспечивают.
Разница между «правильно» и «почти правильно» здесь видна на одной временной шкале: свой флаг завершения и ApplicationAvailability переключаются в один и тот же момент, но трафик останавливает только второй.
Оба варианта переключают внутреннее состояние в момент SIGTERM, но снаружи спрашивают readiness: с UP на закрывающийся Tomcat все 30 с идут новые запросы и клиент ловит 502, с 503 ответ честный. В Kubernetes pod убирают из Service ещё при удалении, но первые секунды на него идёт хвост запросов, пока обновляются маршруты на узлах, дальше приходят только уже принятые, и процесс завершается сам, с запасом в 30 с до SIGKILL.
server.shutdown=graceful — основа всего
Без этой настройки при получении SIGTERM Spring вызывает Tomcat.stop() немедленно. Все обрабатывающиеся запросы прерываются с IOException, клиенты видят 502.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
С server.shutdown: graceful поведение меняется:
- Tomcat перестаёт принимать новые соединения.
- Уже активные HTTP-запросы продолжают обрабатываться.
- После того как все запросы завершатся (или истечёт таймаут) — Tomcat полностью закрывается.
Это и есть «мягкая» остановка.
И сразу оговорка, о которой узнают по жалобам пользователей: дренаж действует не на всё. Он ждёт завершения обычных запросов, у которых есть конец. Долгоживущие соединения конца не имеют, и с ними происходит другое:
- WebSocket — соединение закрывается при остановке контейнера, и клиент получает обрыв. Ждать его Spring не будет: с точки зрения счётчика активных запросов такое соединение либо не учитывается, либо не завершится никогда.
- Поток событий (SSE) и потоковые ответы — то же самое: клиент читает ответ, который прервётся на середине.
- Асинхронные ответы (
DeferredResult,CompletableFuture, реактивные потоки) — дренаж дожидается их завершения, если они укладываются в таймаут, но долгий поток данных так же обрывается.
Что с этим делают: клиента учат переподключаться (для WebSocket это обязательная часть клиента, а не пожелание), сервер перед закрытием отправляет явное сообщение о завершении сеанса, а для потоков событий используют идентификатор последнего события, чтобы клиент продолжил с того места. Подробный разбор — в статье про дренаж HTTP.
Кто вообще получает SIGTERM
Самая частая жалоба по этой теме звучит так: «graceful включили, а он не работает — под всё равно умирает через тридцать секунд с кодом 137». Почти всегда причина не в настройках Spring, а в том, что сигнал до JVM не доходит. Разберём цепочку целиком.
Kubernetes посылает сигнал процессу с номером 1 внутри контейнера. Не «приложению», а именно первому процессу.
Первым процессом должна быть JVM. Если ENTRYPOINT в Dockerfile записан строкой (ENTRYPOINT java -jar app.jar), Docker запускает это через /bin/sh -c, первым процессом становится shell, а JVM — его дочерним. Shell сигнал получает и дальше не передаёт: он просто ждёт. JVM о просьбе завершиться не узнаёт, обработчики Spring не выполняются, и через отведённое время всё убивает SIGKILL. Узнаваемый признак: остановка занимает ровно столько, сколько отведено, и заканчивается кодом 137.
ENTRYPOINT ["java", "-jar", "/app.jar"] # так сигнал доходит
# ENTRYPOINT java -jar /app.jar # так нет
Если строковая форма нужна (подстановка переменных), процесс запускают через exec: ENTRYPOINT exec java -jar /app.jar — тогда shell заменяет себя JVM, и номер 1 достаётся ей. То же касается скриптов-обёрток: последняя строка должна быть exec java ..., а не java ....
JVM должна иметь обработчик. Его ставит сам Spring Boot: spring.main.register-shutdown-hook включён по умолчанию (true), и именно он запускает корректное закрытие контекста. Выключают его только в особых случаях (например, когда жизненным циклом управляет внешний контейнер), и тогда закрытие контекста нужно вызывать самому. Проверить, что хук на месте, проще всего по логу: при docker stop в журнале должны появиться строки о закрытии контекста, а не обрыв на полуслове.
И отдельный случай — свой обработчик сигналов. Runtime.getRuntime().addShutdownHook(...), добавленный вручную, выполняется параллельно со спринговым и без всякого порядка. Если он ждёт чего-то долгого, он съест бюджет остановки у всех остальных. Поэтому свою уборку оформляют бином с @PreDestroy или SmartLifecycle — там порядок и таймауты управляемы, о чём говорит раздел про фазы ниже.
Проверка одной командой, без кластера:
docker run -d --name app my-app:1.0
time docker stop app # должно уложиться заметно быстрее 10 секунд
docker inspect app --format '{{.State.ExitCode}}' # 0 или 143, но не 137
Код 143 означает «завершился по SIGTERM» и это нормальный исход; 137 — «убит принудительно», и вот тогда надо возвращаться к началу этого раздела.
Как выбрать таймаут
spring.lifecycle.timeout-per-shutdown-phase задаёт, сколько секунд Spring ждёт завершения каждой фазы остановки. Значение по умолчанию — 30 секунд. Его лучше прописать явно, чтобы оно было видно в конфигурации и не менялось между версиями Spring Boot.
Число зависит от одного: сколько длится самый долгий запрос, которого вы готовы дождаться. Spring не «сливает потоки»: он просто ждёт, пока в Tomcat не останется ни одного запроса в работе — опрашивает счётчик активных запросов, пока тот не станет нулём. Значит, ориентир — не круглое число, а p99 длительности ваших запросов плюс небольшой запас. Сверху и снизу число ограничено так:
- Больше 45 секунд — рискованно в Kubernetes: если общий бюджет
terminationGracePeriodSecondsравен 60 секундам, JVM может получить SIGKILL до завершения. И это ещё оптимистичная оценка: лимит даётся каждой группе отдельно, а групп в приложении несколько — контейнеры Kafka, исполнители задач, веб-сервер, — и гасятся они одна за другой. - 30 секунд — разумный баланс для большинства REST API, где 99% запросов укладываются в несколько секунд.
Если у вас есть запросы, которые выполняются дольше 30 секунд по своей природе, решение — декомпозировать их на более короткие операции, а не увеличивать таймаут.
Как именно декомпозировать: долгая операция превращается в приём задачи и ответ 202 Accepted со ссылкой на статус, а результат клиент узнаёт опросом или уведомлением. Тогда остановка пода не обрывает работу пользователя — задача продолжится в другом поде. Разбор с кодом — в статье про дренаж HTTP, а надёжность самой задачи (что будет, если под умер посреди неё) — в статье про идемпотентность.
ApplicationAvailability — как k8s узнаёт о завершении
Kubernetes решает, отправлять ли трафик на pod, по результату readiness probe — HTTP-запроса к /actuator/health/readiness. Пока там возвращается UP, новые запросы продолжают идти на pod, даже если он уже завершается.
В Spring Boot это поведение по умолчанию начиная с версии 2.3: ApplicationAvailability — механизм, который связывает состояние жизненного цикла приложения с ответом health-эндпоинтов. При получении сигнала остановки Spring автоматически переключает readiness в REFUSING_TRAFFIC, и /actuator/health/readiness начинает возвращать 503.
Теперь все, кто спрашивает приложение о готовности, получают честный ответ: ingress-контроллер, внешний балансировщик, соседний сервис с собственной проверкой. После этого Spring дожидается завершения уже активных запросов и останавливается.
Отдельная оговорка про Kubernetes: когда останавливают сам pod, из списка адресов Service его убирают по факту удаления, а не по ответу пробы, — так что 503 тут не причина, а честное сообщение о состоянии. И снятие из списка не мгновенное: правила маршрутизации на узлах кластера обновляются ещё несколько секунд, и в это окно новые запросы продолжают приходить на завершающийся pod. Чем закрыть это окно — разобрано в HTTP drain.
Чтобы иметь явный лог момента переключения, можно добавить listener:
@Component
@Slf4j
public class ShutdownAvailabilityListener {
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
log.info("Graceful shutdown started, readiness is already REFUSING_TRAFFIC");
}
}
Обратите внимание: listener только пишет в лог. Переключать состояние ему нечего — Spring публикует REFUSING_TRAFFIC ещё до того, как разошлёт ContextClosedEvent, поэтому любой такой listener срабатывает вторым и повторил бы уже сделанное. Вся его польза — строка в логе с точным временем начала остановки, по которой потом считают, сколько она заняла.
Отдельные пробы для liveness и readiness
По умолчанию Spring Boot отдаёт одну /actuator/health точку для всего. Для Kubernetes нужны отдельные эндпоинты:
management:
endpoint:
health:
probes:
enabled: true
show-details: when-authorized
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
После этого появляются два отдельных эндпоинта:
/actuator/health/liveness— процесс жив (JVM не завис, нет deadlock)./actuator/health/readiness— готов принимать трафик (зависит отApplicationAvailability).
Это различие важно при остановке. Отвечать 503 должна readiness: это значит «не шлите сюда новое». Liveness при этом обязана оставаться зелёной — её 503 читается как «процесс сломан, перезапусти».
Оговорка про момент остановки: pod, который уже удаляют, kubelet не перезапустит, даже если liveness начнёт падать. Опасна другая привычка — повесить обе пробы на общий /actuator/health. Тогда в обычной работе, стоит отвалиться базе или соседнему сервису, liveness падает вместе с ней, и Kubernetes убивает живой pod.
Два эндпоинта и два разных вывода kubelet из одного и того же кода 503: по liveness он перезапускает контейнер, по readiness только убирает под из ротации.
Проверка зависимостей — только в readiness
Отдельно стоит сказать, как добавить в готовность проверку зависимостей, потому что делают это не кодом, а настройкой групп.
management:
endpoint:
health:
group:
readiness:
include: readinessState,db,rabbit
liveness:
include: livenessState
Здесь группа готовности собирается из состояния приложения плюс проверок базы и брокера, а группа живости — только из состояния приложения. Имена проверок — это имена индикаторов Spring Boot (db, rabbit, redis, diskSpace и другие, они видны в /actuator/health с подробностями).
Почему зависимости нельзя включать в живость. Живость отвечает на вопрос «перезапустить ли процесс». База недоступна — перезапуск процесса её не вернёт, а вот потерять все реплики сразу он поможет: упала база, все пробы живости покраснели, кластер перезапустил все поды, после старта они снова не нашли базу и снова покраснели. Это каскад, запущенный собственной системой обнаружения, и он превращает «база недоступна пять минут» в «сервис лежит полчаса».
Почему в готовность их включать полезно и опасно одновременно. Полезно: под без соединения с базой не должен получать трафик. Опасно: короткий сбой базы выведет из ротации все реплики сразу, и сервис перестанет отвечать вовсе, хотя часть запросов он мог бы обслужить (кэш, чтение из реплики, ответ с деградацией). Поэтому правило такое: в готовность включают только то, без чего сервис бесполезен, а всё остальное — не включают и обрабатывают в коде. Внешний платёжный провайдер в готовности — почти всегда ошибка; собственная база у сервиса, который без неё не отвечает ни на что, — обычно оправданно.
Отсюда и практическая проверка настройки: выключите зависимость на стенде и посмотрите, что стало с пробами. Если покраснела живость — настройка неверная, и это надо чинить до прода.
Когда часть этого не нужна
Набор выше описан для HTTP-сервиса, и для других видов приложений половина его лишена смысла. Стоит проговорить, что остаётся.
Сервис без HTTP (только потребитель очереди или задачи по расписанию). server.shutdown: graceful не делает ничего — веб-сервера нет. Проб готовности тоже нет смысла определять через HTTP, а трафик никто не балансирует. Остаётся главное: корректно закрыть потребителя и дождаться текущих сообщений (об этом статья про Kafka), закрыть пул соединений последним и уложиться в бюджет. Пробу живости при этом всё равно делают — но её обычно вешают на отдельный простой эндпоинт Actuator, включённый специально для этого.
Задача, которая выполняется и завершается (Job в кластере, разовый прогон). Ей не нужны ни пробы, ни дренаж: важно только, чтобы работа была идемпотентной и повторяемой, потому что задачу могут прервать и запустить заново.
Сервис, у которого все запросы короче секунды. Настройки остаются, но выбор таймаута перестаёт быть интересным: хватит и пяти секунд. Зато на первый план выходит задержка обновления правил на узлах — та самая, из-за которой нужна пауза перед закрытием сокета; она не зависит от длительности запросов вовсе.
Общее правило: graceful про HTTP, всё остальное — про то, чтобы закрыть ресурсы в правильном порядке и не потерять работу.
Частая ошибка: собственный флаг завершения
Иногда разработчики добавляют собственный AtomicBoolean shuttingDown или статическое поле volatile boolean isShuttingDown и переключают его при остановке:
// Так делать не надо
@Component
public class CustomShutdownState {
private static final AtomicBoolean SHUTTING_DOWN = new AtomicBoolean(false);
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
SHUTTING_DOWN.set(true);
}
public static boolean isShuttingDown() {
return SHUTTING_DOWN.get();
}
}
Проблема в том, что health-эндпоинты об этом флаге ничего не знают. /actuator/health/readiness продолжает возвращать UP, Kubernetes не убирает pod из ротации, и трафик продолжает поступать всё время остановки.
Правильный подход — использовать ApplicationAvailability напрямую:
@Component
@RequiredArgsConstructor
@Slf4j
public class SomeService {
private final ApplicationAvailability availability;
public void doWork() {
if (availability.getReadinessState() == ReadinessState.REFUSING_TRAFFIC) {
log.info("Refusing new work, shutdown in progress");
return;
}
// работа
}
}
Так состояние завершения согласовано через один механизм, который виден и health-эндпоинтам, и Kubernetes.
Коротко
server.shutdown: gracefulобязателен — без него Tomcat прерывает активные запросы сразу при SIGTERM.timeout-per-shutdown-phase: 30s— хороший баланс для типичных REST API; помните, что лимит действует на каждую группу бинов отдельно, а группы гасятся одна за другой — в бюджет надо уложить их все.- Spring Boot сам переключает readiness в
REFUSING_TRAFFICпри остановке, причём раньшеContextClosedEvent; свой listener имеет смысл только как отметка в логе. - Отдельные пробы
livenessиreadinessнужны: readiness=503 означает «не шлите трафик», liveness=503 — «процесс сломан, перезапусти». Вешать обе на один эндпоинт нельзя. - Собственный
AtomicBoolean shuttingDown— типичная ошибка: о нём не знают ни health-эндпоинты, ни балансировщики. - SIGTERM приходит процессу с номером 1: строковая форма
ENTRYPOINTоставляет там shell, и сигнал до JVM не доходит — признак «остановка ровно по таймауту и код 137». - Обработчик ставит сам Spring (
spring.main.register-shutdown-hook), а свою уборку оформляют@PreDestroyилиSmartLifecycle, а не собственным хуком: он выполняется без порядка и съедает бюджет. - Зависимости включают только в группу готовности (
management.endpoint.health.group.readiness.include), и только те, без которых сервис бесполезен; в живости они дают каскад перезапусков. - Дренаж не касается WebSocket, потоков событий и длинных потоковых ответов — их обрывает закрытие контейнера, и клиент обязан уметь переподключаться.
- Для сервиса без HTTP половина набора не нужна:
gracefulни на что не влияет, остаётся корректное закрытие потребителя и порядок закрытия ресурсов.
Что почитать дальше
- HTTP drain — что происходит с активными HTTP-запросами во время остановки.
- Kafka shutdown — как корректно остановить Kafka listener container.
- Kubernetes — preStop hook и terminationGracePeriodSeconds.
- Бюджеты и наблюдаемость — как распределить 60-секундный бюджет завершения.