При выкатке новой версии сервиса 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 — как настроить пулы потоков.