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

Когда 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-секундный бюджет завершения.