Опирается на правила:
R-SHUT-HTTP-1…R-SHUT-HTTP-3иR-SHUT-HTTP-X1из Graceful Shutdown Style Guide → раздел 2. HTTP drain.
Важно знать
- In-flight requests дожимаются до response, новые принимаются до switch readiness в OUT_OF_SERVICE.
preStophook со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 sleep | R-SHUT-HTTP-2 | sleep 10 обязателен |
preStop sleep < 5s на multi-node кластере | R-SHUT-HTTP-2 | минимум 10s |
| Synchronous endpoint > 10s | R-SHUT-HTTP-3 | 202 + 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-2 | exec sleep детерминированный |
Без Idempotency-Key для long-running endpoints | R-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.