Архитектура бэкенда
Гонка дерегистрации: останови маршрутизацию, прежде чем перестать принимать
Ты сделал всё правильно: настоящий SIGTERM-обработчик, приложение — PID 1, обработчик перестаёт принимать новые соединения в тот же миг, как сигнал приходит, и дренирует те, что в полёте. Деплоишь — и всё равно видишь всплеск ошибок connection-refused — но только секунду-другую, только в начале каждого раската. Причина — гонка, которую почти никто не угадывает с первого раза. Когда под начинает завершаться, две вещи происходят параллельно: оркестратор шлёт SIGTERM твоему процессу и велит остальному кластеру перестать слать тебе трафик. Вторая часть не мгновенна — она распространяется через control plane, kube-proxy на каждой ноде и балансировщик, занимая от менее секунды на маленьком кластере до десятков секунд на большом. Твой процесс, повинуясь SIGTERM, закрывает слушатель немедленно. Так что на окно распространения есть балансировщик, всё ещё уверенно шлющий запросы в под, что уже закрыл дверь — и каждый из них получает отказ. Ты перестал принимать раньше, чем мир перестал маршрутизировать.
Двое часов, запущенных вместе, кончающихся врозь
Ментальная модель, что ломается здесь, — «балансировщик знает момент, когда я получил SIGTERM». Не знает. Удаление endpoint и доставка сигнала — это независимые, конкурентные действия, запущенные одним событием:
- Путь сигнала быстрый и локальный: kubelet на твоей ноде шлёт SIGTERM в твой контейнер за миллисекунды.
- Путь маршрутизации медленный и распределённый: API-сервер помечает endpoint not-ready, это изменение распространяется в kube-proxy каждой ноды (или во внешний балансировщик, или в ingress-контроллер, который может опрашивать по интервалу), и только тогда правила маршрутизации реально перестают слать тебе.
Поскольку путь маршрутизации в конечном счёте согласован (eventually consistent), есть окно — от «SIGTERM пришёл» до «последний роутер перестал слать тебе трафик» — в течение которого кластер всё ещё считает тебя валидным бэкендом. Сообщаемые числа: менее секунды на маленьких кластерах, но 10–30 секунд на больших или с опрашивающими ingress-контроллерами. Если твой обработчик закрывает слушающий сокет в тот же миг, как приходит SIGTERM, каждый запрос, прибывающий в это окно, упирается в закрытый порт, и клиент получает connection refused — инверсия проблемы из урока один. Там ты вышел слишком рано и оборвал работу в полёте; здесь ты перестаёшь принимать слишком рано и отвергаешь работу, которую LB ещё шлёт.
Фикс: меняй порядок, а не просто добавляй обработчик
Лекарство — заставить маршрутизацию остановиться раньше, чем ты перестаёшь принимать — перевернуть порядок двух часов. Есть два дополняющих рычага:
- Сначала завали readiness-пробу. Readiness — сигнал, которым оркестратор решает, слать ли тебе. В тот миг, как начинаешь выключаться, флипни свой readiness-эндпоинт в failing (или unready). Это запускает часы дерегистрации через нормальный механизм — но не заканчивает их, потому что распространение всё ещё занимает время.
- Добавь preStop-сон, чтобы покрыть распространение. Поскольку preStop hook бежит перед SIGTERM, и оркестратор блокируется на нём,
preStop, который просто спит несколько секунд (обычно 5–15s, под реальную задержку распространения твоего кластера), держит процесс открытым и принимающим, пока статус not-ready расходится. Только после сна приходит SIGTERM, и твой обработчик закрывает слушатель — к этому моменту роутеры догнали и больше не шлют тебе новый трафик.
Принцип: продолжай обслуживать, пока не уверен, что тебе больше ничего не маршрутизируется, затем перестань принимать, затем дренируй. Одного обработчика мало; порядок относительно дерегистрации — вот вся суть.
Не ломай readiness неправильным способом
Частый автогол: люди делают так, что liveness-проба и readiness-проба делят один эндпоинт, или дают SIGTERM-обработчику немедленно возвращать ошибки из health-эндпоинта. Если liveness-проба начинает падать во время выключения, оркестратор может решить, что контейнер сломан, и убить или перезапустить его, обрезав твой дренаж. Держи liveness проходящей (процесс жив, и дренаж — здоровое поведение) и завали только readiness (не шли мне новый трафик). Две пробы отвечают на разные вопросы: liveness спрашивает «перезапустить тебя?», readiness спрашивает «маршрутизировать к тебе?» — и во время выключения честные ответы «нет» и «нет нового трафика» соответственно.
Почему это работает
Почему платформа не может просто сделать удаление endpoint синхронным с сигналом, чтобы не было окна, вокруг которого надо инженерить? Потому что маршрутизация в распределённом кластере — не единый рубильник, который можно щёлкнуть атомарно — это реплицированное состояние, размазанное по многим независимым компонентам, и держать реплицированное состояние идеально согласованным на каждом изменении — ровно та дорогая координация, которой распределённые системы избегают ради пропускной способности. Когда под становится not-ready, этот факт должен дойти до API-сервера, записаться в объект endpoints, быть замеченным kube-proxy каждой ноды (который затем переписывает локальные правила iptables или IPVS) и отдельно дойти до любого внешнего балансировщика или ingress-контроллера, часть которых узнаёт изменения опросом по своему графику, а не пушем. Каждый из этих хопов независимо быстр, но коллективно асинхронен, и нет глобальных часов, говорящих «все обновились, теперь отпускай сигнал». Сделать это синхронным означало бы блокировать каждое завершение на подтверждении изменения самым медленным роутером во флоте — связав выключение пода с кластерным консенсусом, что заставило бы деплои ползти и само падало бы всякий раз, когда любой роутер медлит или недоступен. Так что платформа выбирает конечную согласованность и вручает тебе инструменты перекрыть разрыв: readiness, чтобы запустить часы, и preStop, чтобы переждать. Глубокий урок — тот же, что юнит про circuit breaker долбил снова и снова: в распределённой системе нельзя предполагать, что два события, запущенных вместе, наблюдаются вместе, и любая корректность, зависящая от их порядка, должна обеспечиваться намеренно, а не предполагаться.
| Момент | Твой процесс | Балансировщик | Результат |
|---|---|---|---|
| Без защиты, SIGTERM пришёл | Закрывает слушатель мгновенно | Ещё маршрутизирует (не распространилось) | Connection refused на окно |
| Сначала завалить readiness | Продолжает принимать | Начинает помечать тебя not-ready | Часы запущены, ещё не кончились |
| preStop спит 5–15s | Продолжает принимать | Распространение завершается | Роутеры перестают слать новое |
| SIGTERM после сна | Теперь закрывает слушатель | Больше не маршрутизирует к тебе | Нет отказанных соединений |
Сервис с верным SIGTERM-обработчиком, закрывающим слушатель немедленно, всё равно видит короткий всплеск connection-refused в начале каждого деплоя. Почему?
Во время выключения почему надо завалить readiness-пробу, но держать liveness-пробу проходящей?
- 01Что такое гонка дерегистрации и почему она вызывает ошибки connection-refused?
- 02Как починить гонку и почему завалить readiness, но не liveness?
Даже идеальный SIGTERM-обработчик отвергает соединения, если закрывает слушатель слишком рано, потому что завершение запускает двое часов сразу, кончающихся врозь: доставка сигнала — быстрый локальный путь в миллисекунды, тогда как дерегистрация endpoint — медленный распределённый путь через API-сервер, kube-proxy каждой ноды и любой внешний или опрашивающий балансировщик — в конечном счёте согласованный, занимающий менее секунды на маленьких кластерах, но 10–30s на больших. В это окно балансировщик всё ещё маршрутизирует в под, чья дверь уже закрыта, давая ошибки connection-refused, инверсию обрыва-слишком-рано из урока один. Фикс — порядок: сначала завали readiness-пробу, чтобы запустить часы дерегистрации, и добавь preStop-сон около 5–15s, под реальное распространение, что держит процесс принимающим, пока маршрутизация не догнала — только тогда SIGTERM закрывает слушатель. Держи liveness проходящей, чтобы оркестратор не принял здоровый дренаж за сломанный контейнер и не перезапустил посреди выключения. С безопасно сдренированной маршрутизацией и наконец закрытым слушателем следующий урок берётся за то, что идёт после закрытия двери: дренаж запросов в полёте и закрытие каждого ресурса в правильном порядке.