Классическая сцена. Команда выкатывает релиз, деплой проходит зелёным, kubectl rollout status рапортует об успехе — а в графиках ингресса на минуту вырастает горка пятисотых. Не тысячи, обычно доли процента, но стабильно, каждый деплой.

Дальше начинается фольклор. «Ну это же рестарт, при рестарте всегда так». «Добавьте ретраи на клиенте». «Деплойтесь ночью». Самый частый вариант — на графики просто перестают смотреть в момент выката.

На самом деле rolling update умеет проходить вообще без потерь, и readiness-проба тут ни при чём — она отвечает за другую половину задачи. Потери происходят на выключении подов, и причина в том, что удаление пода — это не последовательность шагов, а гонка.

Что происходит, когда под удаляют

Возьмём момент, когда деплоймент решил убить старый под. Дальше в кластере параллельно, независимо друг от друга, стартуют две цепочки событий.

Цепочка первая, быстрая. kubelet на ноде видит, что у пода проставлен deletionTimestamp, и начинает завершение: выполняет preStop-хук, если он есть, затем шлёт процессу SIGTERM. Всё это происходит локально, на той же ноде — то есть почти мгновенно.

Цепочка вторая, медленная. Контроллер endpoints тоже видит удаление и убирает под из EndpointSlice. Это изменение уходит в API-сервер, оттуда его должны получить все, кто на него подписан: kube-proxy на каждой ноде кластера, который потом перепишет свои правила iptables или ipvs; контроллер ингресса, который держит собственный список апстримов; сайдкары или прокси меша, если он есть; иногда ещё облачный балансировщик со своей дерегистрацией.

Никакой синхронизации между этими цепочками нет. Kubernetes не обещает, что второе завершится раньше первого — и обычно оно не завершается. Первая цепочка укладывается в миллисекунды, вторая на живом кластере занимает от сотен миллисекунд до нескольких секунд: watch-события, очереди контроллеров, перезапись правил на сотне нод.

Итог: некоторое время трафик продолжает идти на под, который уже получил SIGTERM. Если приложение на SIGTERM честно и быстро умирает — как учат все туториалы по graceful shutdown — оно закрывает слушающий сокет ровно в тот момент, когда прокси всё ещё считают его живым апстримом. Ядро на входящий SYN отвечает RST, ингресс видит connection refused и отдаёт клиенту 502.

Отдельная ирония: чем аккуратнее приложение завершается, тем быстрее оно перестаёт принимать соединения и тем больше пятисотых вы получаете. Приложения, которые на SIGTERM тупят несколько секунд, случайно оказываются в выигрыше.

Как убедиться, что это именно оно

Прежде чем чинить, стоит подтвердить диагноз, потому что 502 бывают и от других причин.

Признаки этой конкретной гонки:

  • Пятисотые кучкуются в окне выката и исчезают между деплоями.

  • В логах ингресса у этих запросов заполнен upstream_addr, и это адрес пода, которого уже нет. Сам код ошибки от прокси — connect() failed (111: Connection refused) в nginx-варианте или аналог.

  • Число ошибок примерно пропорционально числу заменяемых подов: выкатили 20 реплик — получили примерно вдвое больше ошибок, чем на 10.

  • Ошибки приходят и с «холодных» путей: если трафик идёт не только через ингресс, но и между сервисами внутри кластера, страдает и внутренний трафик, просто там это чаще видно как ретраи.

Полезно посмотреть на это глазами: в одном терминале держать kubectl get endpointslices -w для сервиса, в другом гонять hey или vegeta, и в третьем запустить выкат. Момент, когда ошибки уже пошли, а под из EndpointSlice ещё не исчез, видно невооружённым глазом.

Лечение первое: preStop

Самое эффективное средство от гонки — не пытаться её выиграть, а дать медленной цепочке фору. Для этого в под добавляется preStop-хук, который просто ждёт. kubelet выполняет его до SIGTERM, то есть приложение продолжает как ни в чём не бывало принимать трафик, пока по кластеру расходится обновление эндпоинтов.

Начиная с Kubernetes 1.30 для этого есть нативное действие, без всякого шелла:

lifecycle:
  preStop:
    sleep:
      seconds: 10

На версиях постарше приходится делать так:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sleep", "10"]

— и вот тут ждёт первая засада: в distroless- и scratch-образах никакого /bin/sleep нет. Хук молча падает, вы видите в событиях FailedPreStopHook, и всё работает ровно так же плохо, как без него. Проверяйте, что бинарник в образе действительно есть.

Вторая засада — арифметика с terminationGracePeriodSeconds. Время preStop входит в общий грейс-период, а не добавляется к нему. При дефолтных 30 секундах, десятисекундном ожидании и приложении, которому нужно 25 секунд на долив запросов, вы получите SIGKILL посреди работы. Правило простое: грейс-период должен быть больше, чем preStop плюс реальное время дренажа, с запасом.

Сколько ставить в sleep? Пять секунд обычно закрывают вопрос на небольших кластерах, десять — разумный дефолт, на больших кластерах с сотнями нод и нагруженным API-сервером бывает нужно и пятнадцать. Ориентируйтесь на замер: сколько реально проходит от удаления пода до исчезновения его адреса из правил на дальней ноде.

Лечение второе: приложение должно уметь дренаж

preStop даёт фору, но не отменяет второй половины работы. После SIGTERM приложение обязано:

  1. перестать принимать новые соединения (закрыть listener);

  2. доработать уже принятые запросы;

  3. только потом завершиться.

Огромное количество сервисов пункт второй игнорирует: получил SIGTERM — вызвал os.Exit(0). Все запросы, которые были в обработке в этот момент, превращаются в оборванные соединения на стороне клиента. Причём это уже не 502 от ингресса, а разрыв посреди ответа, что для клиента обычно хуже.

В Go это srv.Shutdown(ctx) вместо простого выхода, в Java с Spring Boot — server.shutdown=graceful, в Node — server.close() с ожиданием, в Python/gunicorn — корректная обработка сигнала мастером. Механика везде одна, отличается только синтаксис.

Лечение третье: keep-alive, о котором все забывают

Даже с идеальным preStop и честным дренажом остаётся третий слой проблемы, и он самый неочевидный.

Удаление пода из EndpointSlice влияет только на новые соединения. Уже установленные TCP-соединения — а между ингрессом и подами, как и между сервисами, живут долгоживущие keep-alive-пулы — продолжают указывать ровно туда же, куда указывали. Никто их не разрывает: conntrack-запись существует, правила балансировки к ней уже не применяются.

Это значит, что клиентский прокси может слать запросы в умирающий под ещё долго после того, как тот исчез из всех списков. Разорвать эту связь может только сам под — и правильный способ это сделать не «закрыть соединение», а отдать на текущий запрос заголовок Connection: close, чтобы клиент корректно закрыл пул и переоткрыл его в другое место. Большинство фреймворков делают это автоматически при graceful shutdown, но, например, самописные HTTP-серверы и некоторые gRPC-конфигурации — нет. В gRPC за это отвечает GOAWAY, который посылается при GracefulStop().

Если после первых двух лечений остаётся тонкий хвост ошибок — почти наверняка дело в этом.

Вторая половина выката: новые поды

Всё вышесказанное — про выключение. Но пятисотые при деплое бывают и с другого конца, и там как раз работает readiness — при условии, что она не врёт.

Типовая ошибка — TCP-проба вместо HTTP. Сокет открывается на старте процесса, задолго до того, как приложение прогрело пул соединений, накатило кеши и вообще научилось отвечать. Проба зелёная, под в endpoints, трафик пошёл, приложение отдаёт ошибки. То же самое с HTTP-пробой, которая ходит на эндпоинт, отвечающий раньше, чем сервис готов.

Второй источник — maxUnavailable. При дефолтной стратегии деплоймент имеет право вывести часть подов из строя до того, как поднимутся новые. Если вы живёте близко к пределу по мощности, оставшиеся реплики просто не переваривают трафик, и это уже не 502, а 504 по таймауту. Для сервисов с узким запасом ставьте maxUnavailable: 0 и maxSurge: 1 — выкат будет дольше, но без просадки ёмкости. И проверьте, что PodDisruptionBudget существует, иначе дренаж ноды устроит вам всё то же самое, только без вашего участия.

Итоговый минимум

Конфиг, который закрывает основную массу случаев:

spec:
  terminationGracePeriodSeconds: 45
  containers:
  - name: app
    lifecycle:
      preStop:
        sleep:
          seconds: 10
    readinessProbe:
      httpGet:
        path: /readyz
        port: 8080
      periodSeconds: 5

плюс graceful shutdown в приложении и maxUnavailable: 0 для сервисов без запаса по ёмкости.

И проверка, которую стоит делать один раз, а потом просто держать в регламенте: запустить постоянную нагрузку на сервис и выкатить релиз. Ноль ошибок — значит, конфигурация честная. Любая горка — значит, где-то из четырёх мест (preStop, дренаж, keep-alive, готовность новых подов) осталась дыра.

Zero-downtime деплой в Kubernetes — не свойство платформы, которое включается само. Это несколько строк в манифесте и десяток строк в приложении, и почти в каждом кластере, куда я приходил, хотя бы одной из них не хватало.

Расскажите, кстати, кто как замеряет пропагацию эндпоинтов на больших кластерах — есть ли у вас цифры, сколько реально занимает путь от удаления пода до последней ноды? Мне попадались значения от полусекунды до семи, и разброс явно зависит не только от размера кластера.