Устойчивые сервисы: таймауты, повторы и корректное завершение
Как удержать Node-сервис в рабочем состоянии, когда его зависимости, сеть и оркестратор ведут себя плохо одновременно.
- дренировать выполняющуюся работу и завершаться чисто, когда оркестратор присылает
SIGTERM; - отличать проверку жизнеспособности (liveness) от проверки готовности (readiness) и знать, какая из них управляет трафиком;
- ставить жёсткий таймаут на каждый исходящий вызов зависимости с помощью
AbortController; - повторять только идемпотентную работу с экспоненциальной задержкой и джиттером -- и знать, когда повторять НЕЛЬЗЯ;
- оборачивать ненадёжную зависимость в предохранитель, чтобы один больной бэкенд не уронил вас.
Эта тема опирается на Архитектуру микросервисов (как сервисы декомпозируются) и Логирование и наблюдаемость (как вы видите происходящее). Здесь мы сосредоточены исключительно на выживании при частичном отказе. Предполагается, что вы уже уверенно владеете промисами, async/await и циклом событий Node.
1. Корректное завершение: SIGTERM -- это просьба, а не убийство
На платформе контейнеров (Kubernetes, ECS, Nomad или просто docker stop) перезапуск -- рутина: новый деплой, уменьшение масштаба, вывод узла из работы или проваленная проверка здоровья -- всё это останавливает ваш процесс. Платформа делает это в два шага. Сначала она посылает SIGTERM -- вежливое "пожалуйста, завершайтесь и выходите". Затем, после льготного периода (30 секунд по умолчанию в Kubernetes), если вы всё ещё работаете, она посылает SIGKILL, который нельзя перехватить, заблокировать или обработать, и который завершает вас мгновенно.
Наивный сервис игнорирует SIGTERM. Действие Node по умолчанию для этого сигнала -- немедленный выход, поэтому каждый обслуживаемый в этот момент запрос обрывается на середине ответа, открытые транзакции БД брошены, а клиент видит сброс соединения. Корректное завершение означает перехват SIGTERM и по порядку:
- Перестать принимать новую работу -- вызвать
server.close(), который прекращает слушать новые соединения, но позволяет выполняющимся запросам завершиться. - Провалить готовность -- начать возвращать "не готов", чтобы балансировщик перестал направлять новый трафик на этот экземпляр.
- Дренировать выполняющиеся запросы -- дождаться завершения текущих обработчиков, ограничившись таймаутом.
- Закрыть зависимости -- завершить пулы БД, сбросить логи, отключиться от брокера сообщений.
- Выйти с кодом 0.
Аналогия. SIGTERM -- это просьба магазину закрыться. Аккуратный магазин запирает переднюю дверь, чтобы новые покупатели не входили, обслуживает уже находящихся внутри, подбивает кассу, затем гасит свет. Неаккуратный магазин просто вырубает питание, когда покупатели на середине оплаты. SIGKILL и есть отключение питания -- с ним ничего нельзя поделать, именно поэтому вы хотите закончить до его прихода.
SIGKILL в итоге всё равно убьёт вас на середине работы -- худшее из обоих миров. Дайте дренированию, скажем, 25 секунд (комфортно меньше 30с льготного периода платформы) и вызовите process.exit(1), если оно превышено, залогировав, какие запросы оставались открытыми.
Ещё одна тонкость в Kubernetes: есть гонка между удалением платформой вашего пода из эндпоинтов Service и отправкой SIGTERM. В течение краткого окна балансировщик может слать вам трафик уже после начала завершения. Обычное решение -- поспать пару секунд после получения SIGTERM (или сначала провалить готовность и выждать) перед вызовом server.close(), чтобы удаление эндпоинта успело распространиться.