Задача
Приложение аккуратно обрабатывает SIGTERM: перестаёт принимать новые соединения, дожидается завершения текущих запросов, закрывается. terminationGracePeriodSeconds — стандартные 30 секунд.
Во время rolling update мониторинг стабильно показывает всплеск ошибок 502/connection refused — небольшой, но заметный.
Вопрос: почему запросы попадают в под, который уже завершается, если приложение корректно реагирует на сигнал?
Что кажется очевидным
«Kubernetes сначала убирает под из балансировки, потом посылает SIGTERM. Раз ошибки есть — значит, приложение обрабатывает сигнал неправильно».
Это разумное предположение, и оно неверно в главном.
Как это работает на самом деле
Ключевой факт: при удалении пода Kubernetes запускает два независимых процесса параллельно, а не последовательно.
sequenceDiagram
participant API as API server
participant EP as Endpoint controller
participant KP as kube-proxy на нодах
participant KL as kubelet
participant P as Под
API->>API: под помечен на удаление<br/>(deletionTimestamp)
par Ветка 1: убрать из балансировки
API->>EP: под удаляется
EP->>API: обновить EndpointSlice
API->>KP: рассылка изменений
KP->>KP: обновить iptables/IPVS<br/>на КАЖДОЙ ноде
and Ветка 2: остановить контейнер
API->>KL: остановить под
KL->>P: preStop hook
KL->>P: SIGTERM
end
Note over KP,P: Гонка: контейнер может<br/>остановиться раньше,<br/>чем обновятся правила
Обе ветки стартуют одновременно. И вот в чём проблема: вторая ветка обычно быстрее первой.
Остановка контейнера — это локальное действие на одной ноде. А удаление из балансировки должно:
- дойти до контроллера endpoints;
- обновить объект EndpointSlice в API;
- разослать изменение всем kube-proxy в кластере;
- каждый kube-proxy должен перестроить правила iptables/IPVS на своей ноде.
На кластере в сотню нод это занимает заметное время. Плюс за пределами кластера могут быть ingress-контроллеры и облачные балансировщики со своими интервалами обновления.
Итог: приложение уже получило SIGTERM и закрыло слушающий сокет, а правила на части нод ещё направляют туда трафик. Запрос приходит в никуда — connection refused.
Ответ
Дело не в обработке SIGTERM. Причина — гонка между удалением из балансировки и остановкой контейнера: они идут параллельно, и остановка успевает раньше распространения правил.
Что с этим делать
Стандартное решение — задержать начало остановки, чтобы правила успели обновиться. Делается это через preStop hook:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: api
lifecycle:
preStop:
exec:
command: ["sleep", "10"]Логика такая: kubelet выполняет preStop до отправки SIGTERM. Пока под спит эти 10 секунд, он ещё жив и обслуживает запросы, а параллельно правила на нодах успевают обновиться. После этого приходит SIGTERM — и к этому моменту нового трафика уже нет.
Важные детали:
sleepв образе может отсутствовать. Вdistroless-образах нет шелла. Тогда используйтеpreStopс HTTP-запросом к самому приложению (httpGet), которое умеет уйти в режим «не принимаю новое», либо реализуйте задержку внутри обработчикаSIGTERMв коде.- Время
preStopвходит в grace period. ЕслиterminationGracePeriodSeconds: 30, аpreStopспит 10 секунд, приложению на завершение остаётся 20. Увеличивайте общий период соответственно. - По истечении grace period прилетает
SIGKILL— без вариантов. Долгие запросы, не уложившиеся в окно, будут оборваны. - Приложение всё равно должно уметь в graceful shutdown: закрыть listener, дождаться активных запросов, закрыть соединения к БД.
preStopрешает проблему сети, а не проблему незавершённых операций.
Порядок, который стоит держать в голове
- Под помечается на удаление,
deletionTimestampустановлен. - Параллельно: начинается удаление из endpoints и
kubeletзапускаетpreStop. - После
preStop—SIGTERMглавному процессу. - Приложение завершает текущие запросы.
- Если не уложилось в grace period —
SIGKILL.
Проверка
Убедиться, что настройка работает, проще всего нагрузочным тестом во время выкатки: запустите постоянный поток запросов через Service и сделайте kubectl rollout restart. При правильных preStop и grace period количество ошибок должно быть нулевым.