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

Когда Kubernetes останавливает pod, он посылает процессу сигнал SIGTERM. По умолчанию Spring Boot обрабатывает его жёстко: немедленно останавливает сервер, обрывая все активные HTTP-запросы. Клиенты получают 502 Bad Gateway или Connection reset. Это и есть проблема, которую решает graceful shutdown.

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

Разница между «правильно» и «почти правильно» здесь видна на одной временной шкале: свой флаг завершения и ApplicationAvailability переключаются в один и тот же момент, но трафик останавливает только второй.

SIGTERM · бюджет terminationGracePeriodSeconds 60 с0 с30 с45 с60 сSIGTERMSIGKILL Ошибка: свой AtomicBoolean shuttingDownhealth-эндпоинты о нём не знаютreadiness UP · снаружи выглядит готовым балансировщик всё это время шлёт трафик Tomcat новых соединений не принимаетклиент получает 502 Верно: ApplicationAvailability → REFUSING_TRAFFICreadiness 503 · честный ответpod убран из Service ещё при удалении несколько секунд хвост kube-proxy, дальше новых нет — дожимаются активные t ≤ 30 с — exit 0до SIGKILL остаётся запас 30 с потолок таймаутаtimeout-per-shutdown-phase > 45 с — JVM может не успеть до SIGKILL Свой флаг меняет только код — снаружи его никто не видитО завершении говорит readiness: её 503 читают и пробы, и балансировщики

Оба варианта переключают внутреннее состояние в момент 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.

liveness /actuator/health/liveness 503 перезапуск контейнера readiness /actuator/health/readiness 503 снят с трафика

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