Опирается на правила: R-SHUT-HTTP-1R-SHUT-HTTP-3 и R-SHUT-HTTP-X1 из Graceful Shutdown Style Guide → раздел 2. HTTP drain.

Важно знать

  • In-flight requests дожимаются до response, новые принимаются до switch readiness в OUT_OF_SERVICE.
  • preStop hook со sleep 10 в k8s обязателен даже при правильном Spring graceful.
  • kube-proxy distribution на других нодах — 5-15 секунд после SIGTERM, в это время traffic ещё приходит.
  • Долгие синхронные endpoints (>10s) — @Async + Idempotency-Key или 202 Accepted + polling.
  • Custom WebServerCustomizer с awaitTermination(0, SECONDS) — аннулирует graceful.
  • Без preStop — даже идеальный Spring graceful даёт гарантированные 502 в окне 5-15s.
  • 10 секунд — типичное значение; 1000+ nodes в кластере — до 20s.

HTTP drain — самая видимая часть graceful shutdown. Любая 502 на клиенте при rolling deploy = инцидент в UX и metrics. UCP формулирует два слоя защиты: Spring graceful + k8s preStop. Без обоих в проде стабильно 1-2% запросов теряются на каждом deploy.

In-flight requests дожимаются

R-SHUT-HTTP-1: что делает Spring Boot graceful (server.shutdown: graceful).

T=0    SIGTERM получен
T=0+   Spring publishes ContextClosedEvent
T=0+   ApplicationAvailability → REFUSING_TRAFFIC
T=0+   /actuator/health/readiness → 503
T=0+   Tomcat connector: новые connections не принимает
       Активные threads продолжают обрабатывать requests
T=5s   k8s readiness probe видит 503, убирает из endpoints
       Но на других нодах ещё может прилетать traffic (см. preStop)
T=N    Активные requests завершились
T=N    Spring complete shutdown

Активный HTTP request (например, processing 8 секунд) продолжает работать. Tomcat ждёт его завершения до timeout-per-shutdown-phase (см. JVM/Spring config).

Это работает только если Spring graceful включен. Без — Tomcat обрывает thread, request получает IOException.

preStop sleep — обязательно

R-SHUT-HTTP-2: даже при идеальном Spring.

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

Почему preStop нужен

K8s shutdown sequence:

T=0    kubelet получил команду delete pod
T=0+   kubelet вызывает preStop hook (sleep 10 в нашем случае)
       Параллельно: endpoints-controller убирает pod из Service
       Параллельно: kube-proxy на других нодах обновляет iptables
T=10s  preStop завершился
T=10s  kubelet отправляет SIGTERM в процесс
T=10s+ Spring graceful начинается

Без preStop:

T=0    SIGTERM отправлен сразу
T=0+   Spring graceful → readiness=503
T=0+   k8s readiness probe → ещё не успела опросить (period 5s)
T=0+   Pod ещё в endpoints на других нодах (kube-proxy не обновился)
T=0..15s  Новый трафик продолжает идти на наш pod
          Spring уже не принимает → 502 / connection refused

10 секунд — типичное время:

  • kube-proxy iptables update — 1-5s.
  • Load balancer cache flush — 1-3s.
  • DNS TTL (если используется) — secondss.

На больших кластерах (1000+ nodes) — до 20s. Для UCP-сервисов 10s — достаточно в 95% случаев.

Что делает sleep 10

Просто ничего. Процесс приложения продолжает обрабатывать запросы (SIGTERM ещё не отправлен). За 10 секунд:

  • K8s успевает убрать pod из endpoints.
  • kube-proxy на других нодах обновляет iptables.
  • Балансер уже не маршрутизирует новый трафик в этот pod.

После 10s — SIGTERM, Spring graceful занимается уже только in-flight requests, новых не приходит.

Долгие endpoints

R-SHUT-HTTP-3: synchronous > 10s.

Сценарий поломки:

  • POST /reports/generate — синхронный, занимает 30 секунд.
  • В момент SIGTERM 5 таких запросов в обработке.
  • timeout-per-shutdown-phase: 30s — ждём максимум 30 секунд.
  • Часть из 5 запросов не успевает, прерываются.
  • Клиент видит timeout или 500.

Варианты решения:

Вариант 1: 202 Accepted + polling

@RestController
@RequiredArgsConstructor
public class ReportController {

    private final UseCaseDispatcher dispatcher;

    @PostMapping("/reports")
    @PreAuthorize("hasRole('user')")
    public ResponseEntity<ReportRequestResponse> request(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody @Valid ReportRequest req
    ) {
        var reportId = dispatcher.dispatch(new RequestReportCommand(key, req));
        return ResponseEntity.accepted()
            .header("Location", "/reports/" + reportId)
            .body(new ReportRequestResponse(reportId, "QUEUED"));
    }

    @GetMapping("/reports/{id}")
    @PreAuthorize("hasRole('user')")
    public ReportResponse get(@PathVariable Long id) {
        return dispatcher.dispatch(new GetReportQuery(id));
    }
}

POST возвращает 202 + reportId сразу (< 100ms). Клиент polling-ает GET до status: READY. На shutdown short POST не блокирует, генерация продолжается в background worker.

Вариант 2: @Async + Idempotency-Key

Для случаев, когда нужен один call с длительным processing — @Async + worker-pool с graceful shutdown configured.

@PostMapping("/process")
@PreAuthorize("hasRole('user')")
public ResponseEntity<ProcessResponse> process(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody @Valid ProcessRequest req
) {
    asyncProcessor.startAsync(key, req);
    return ResponseEntity.accepted().body(new ProcessResponse(key, "STARTED"));
}

asyncProcessor использует ThreadPoolTaskExecutor с waitForTasksToCompleteOnShutdown = true (см. Scheduled / @Async / outbox).

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

Если endpoint может быть быстрым (нагнали лишних DB-запросов) — оптимизировать. Часто долгий synchronous endpoint = bug, не feature.

Подробнее — Resilience → async polling.

Что запрещено

Custom WebServerCustomizer с awaitTermination(0)

R-SHUT-HTTP-X1:

// КАТАСТРОФА
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
    return factory -> factory.addConnectorCustomizers(connector -> {
        connector.setAsyncTimeout(0);
        ((AbstractProtocol<?>) connector.getProtocolHandler()).setKeepAliveTimeout(0);
    });
}

Аннулирует Spring graceful — Tomcat умирает мгновенно на SIGTERM, активные requests прерываются.

Если custom-настройки Tomcat действительно нужны — проверять, что awaitTermination не переопределён в 0.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Отсутствие preStop sleepR-SHUT-HTTP-2sleep 10 обязателен
preStop sleep < 5s на multi-node кластереR-SHUT-HTTP-2минимум 10s
Synchronous endpoint > 10sR-SHUT-HTTP-3202 + polling или @Async
WebServerCustomizer с awaitTermination(0)R-SHUT-HTTP-X1дефолт Spring graceful
Tomcat thread pool setQueueCapacity(0)R-SHUT-HTTP-1очередь нужна для дрейна
preStop через httpGet (более flaky)R-SHUT-HTTP-2exec sleep детерминированный
Без Idempotency-Key для long-running endpointsR-SHUT-HTTP-3заголовок обязателен

Куда дальше

  • Graceful Shutdown → раздел 2. HTTP drain — нормативные формулировки.
  • JVM/Spring конфигурация — server.shutdown: graceful.
  • Kubernetes — preStop детали, terminationGracePeriodSeconds.
  • Идемпотентность in-flight — клиент retry безопасен.
  • Бюджеты и observability — раскладка 60s budget.
  • Resilience → async polling — async-pattern для long-running.
  • Auth → idempotency — Idempotency-Key для money + long requests.