Когда Kubernetes останавливает pod, он посылает процессу сигнал SIGTERM. По умолчанию Spring Boot обрабатывает его жёстко: немедленно останавливает сервер, обрывая все активные HTTP-запросы. Клиенты получают 502 Bad Gateway или Connection reset. Это и есть проблема, которую решает graceful shutdown.
Правильная остановка — не одна настройка, а скоординированная последовательность: переключить readiness → дать балансировщику время убрать pod из ротации → дожать активные запросы → закрыть соединения → завершиться. В этой статье разберём минимальный набор Spring-настроек, которые это обеспечивают.
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 полностью закрывается.
Это и есть «мягкая» остановка.
Как выбрать таймаут
spring.lifecycle.timeout-per-shutdown-phase задаёт, сколько секунд Spring ждёт завершения каждой фазы остановки. Значение по умолчанию — 30 секунд. Его лучше прописать явно, чтобы оно было видно в конфигурации и не менялось между версиями Spring Boot.
Как выбрать значение:
- Меньше 20 секунд — может не хватить для дрейна потоков Tomcat под нагрузкой. Долгие запросы прерываются.
- Больше 45 секунд — рискованно в Kubernetes: если общий бюджет
terminationGracePeriodSecondsравен 60 секундам, JVM может получить SIGKILL до завершения. - 30 секунд — разумный баланс для большинства REST API, где 99% запросов укладываются в несколько секунд.
Если у вас есть запросы, которые выполняются дольше 30 секунд по своей природе, решение — декомпозировать их на более короткие операции, а не увеличивать таймаут.
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.
Kubernetes видит 503 от readiness probe и убирает pod из Service routing — новый трафик перестаёт поступать. После этого Spring дожидается завершения уже активных запросов и останавливается.
Чтобы иметь явный лог момента переключения, можно добавить listener:
@Component
@RequiredArgsConstructor
@Slf4j
public class ShutdownAvailabilityListener {
private final ApplicationEventPublisher events;
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
log.info("Graceful shutdown started, switching readiness to REFUSING_TRAFFIC");
events.publishEvent(new AvailabilityChangeEvent<>(this, ReadinessState.REFUSING_TRAFFIC));
}
}
Spring Boot делает это автоматически, но явный 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).
Это различие важно при остановке. Нужно именно readiness вернуть 503 — тогда Kubernetes уберёт pod из ротации. Если вернуть 503 от liveness — Kubernetes решит, что процесс завис, и перезапустит его, а не подождёт корректного завершения.
Частая ошибка: собственный флаг завершения
Иногда разработчики добавляют собственный 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
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; не увеличивайте выше 45s в Kubernetes с 60-секундным бюджетом.- Spring Boot 2.3+ автоматически переключает readiness в
REFUSING_TRAFFICпри остановке; явный listener полезен для видимости в логах. - Отдельные пробы
livenessиreadinessнужны: readiness=503 убирает pod из ротации, liveness=503 перезапускает его. - Собственный
AtomicBoolean shuttingDown— типичная ошибка: не интегрируется с health-эндпоинтами, Kubernetes не видит завершение.
Что почитать дальше
- HTTP drain — что происходит с активными HTTP-запросами во время остановки.
- Kafka shutdown — как корректно остановить Kafka listener container.
- Kubernetes — preStop hook и terminationGracePeriodSeconds.
- Бюджеты и наблюдаемость — как распределить 60-секундный бюджет завершения.