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

При выкатке новой версии сервиса Kubernetes останавливает старый pod и поднимает новый. Если сделать это неаккуратно — часть запросов упадёт с ошибкой 502. Пользователь видит сбой, мониторинг сигналит. Разберём, как этого избежать.

Что происходит, когда pod останавливают

Допустим, сервис обрабатывает запрос пользователя. В этот момент Kubernetes решает остановить pod — например, при rolling update. Без специальной настройки процесс получает сигнал SIGTERM и завершается. Запрос прерывается на середине. Клиент видит 502 или обрыв соединения.

Spring Boot умеет этого избегать — режим называется graceful shutdown. Включается одной строкой:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

С этим включённым вот что происходит после SIGTERM:

T=0    Spring получает SIGTERM
T=0+   Статус readiness → OUT_OF_SERVICE (возвращает 503)
T=0+   Tomcat перестаёт принимать новые соединения
       Активные запросы продолжают обрабатываться
T=5s   Kubernetes видит 503, убирает pod из балансировки
T=N    Все активные запросы завершились
T=N    Spring завершает работу

Активный запрос, который уже обрабатывается, не прерывается. Spring ждёт его завершения — до значения timeout-per-shutdown-phase. Это и называется HTTP drain: «слить» в ответы всё, что уже в пути.

Почему Spring graceful недостаточен — нужен preStop

Здесь кроется неочевидная проблема. Kubernetes работает на многих узлах, и когда pod помечается для удаления, информация об этом расходится по кластеру не мгновенно.

Происходит вот что: Kubernetes одновременно отправляет SIGTERM в процесс и даёт команду убрать pod из списка endpoints Service. Но kube-proxy на других узлах обновляет свои правила iptables с задержкой — обычно 1–5 секунд, на больших кластерах (тысячи узлов) до 20 секунд. Всё это время новые запросы всё ещё маршрутизируются на pod, который уже начал завершение и не принимает соединения.

Результат: даже при правильном Spring graceful в этом окне будут 502.

Решение — preStop hook: заставить Kubernetes подождать перед отправкой SIGTERM.

spec:
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]
      terminationGracePeriodSeconds: 60

С preStop последовательность меняется:

T=0    Kubernetes решает удалить pod
T=0+   Запускается preStop hook — процесс спит 10 секунд
       Параллельно: pod убирается из endpoints Service
       Параллельно: kube-proxy на узлах обновляет iptables
T=10s  preStop завершился
T=10s  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–20 секунд.

Долгие синхронные операции

Предположим, есть 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 с правильными настройками

Если API должен быть синхронным по интерфейсу, тяжёлую работу можно вынести в пул потоков с настроенным graceful завершением:

@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 при остановке дождётся завершения задач в этом пуле.

Вариант 3: оптимизация

Иногда endpoint долгий не по природе операции, а из-за лишних запросов к базе или неоптимального кода. Стоит проверить: может быть, 30 секунд — это несколько N+1 запросов, а сама операция могла бы занять 2 секунды.

Частая ошибка: отключение graceful через кастомизацию Tomcat

Иногда разработчики добавляют кастомную настройку Tomcat и случайно ломают graceful:

// Это сломает graceful shutdown
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
    return factory -> factory.addConnectorCustomizers(connector -> {
        ((AbstractProtocol<?>) connector.getProtocolHandler()).setKeepAliveTimeout(0);
        connector.setAsyncTimeout(0);
    });
}

Установка awaitTermination(0, SECONDS) или обнуление таймаутов на уровне коннектора заставляет Tomcat завершаться немедленно при SIGTERM — Spring graceful перестаёт работать, запросы обрываются. Если нужно кастомизировать Tomcat, проверьте, что ни один из параметров не обнуляет время ожидания завершения.

Коротко

  • Spring graceful shutdown (server.shutdown: graceful) дожимает активные HTTP-запросы до ответа перед остановкой.
  • Одного Spring graceful мало: kube-proxy на других узлах обновляется 1–15 секунд после SIGTERM, в это окно новый трафик приходит на уже завершающийся pod.
  • preStop: sleep 10 в k8s-манифесте решает это: pod спит 10 секунд, kube-proxy успевает обновиться, только потом Kubernetes отправляет SIGTERM.
  • terminationGracePeriodSeconds должен покрывать preStop + Spring drain с запасом; значение 60s обычно достаточно.
  • Долгие синхронные операции (более 10 секунд) плохо сочетаются с drain — переводите их в 202 Accepted + polling или @Async с waitForTasksToCompleteOnShutdown.
  • Кастомизация Tomcat с обнулёнными таймаутами ломает graceful — проверяйте настройки.

Что почитать дальше

  • Graceful shutdown в Spring Boot — обзор — полный обзор всех фаз.
  • Kubernetes и terminationGracePeriodSeconds — детали конфигурации pod.
  • Scheduled-задачи и @Async при shutdown — как настроить пулы потоков.