Координация остановки в распределённой системе: drain трафика, in-flight запросы, очереди
Когда отдельно взятый сервис умеет завершаться корректно, это половина дела. Вторая половина начинается, когда таких сервисов десять, между ними сетевые вызовы и общие очереди, и кто-то нажал «выкатить новую версию». В этот момент важно не как отдельный экземпляр гасит свой HTTP-сервер, а как вся система согласованно проводит трафик мимо уходящих узлов, доделывает уже запущенные операции и не теряет сообщения, которые в дороге.
Расскажу, как я смотрю на координацию остановки на уровне распределённой системы: что должно знать окружение, что обязан уметь сервис, и где обычно ломается этот «мирный уход со сцены».
Зачем вообще координация
В одиночном сервисе «остановиться корректно» означает «не оборвать активные запросы и не потерять состояние». В системе из многих сервисов прибавляются ещё две задачи.
- Не отправить трафик в узел, который уже перестал его принимать.
- Не оставить распределённую операцию (саге, цепочке вызовов, обработке сообщения) в подвешенном состоянии.
Без координации остановка одного узла превращается в каскад мелких ошибок у соседей. Каждый отдельно валиден, в сумме — повышенный фон 5xx во время каждого деплоя.
Анатомия мирной остановки
Разбираю по фазам, которые в идеале происходят при выводе экземпляра.
Сигнал намерения
Кто-то решает: «этот узел уходит». Это может быть деплой, scale-down, плановая работа на инфраструктуре. Важно: решение принято и зафиксировано, узел знает, что уходит.
Уход из видимости
Балансировщики, service registry, оркестратор — всё, что направляет трафик, узнаёт, что узел больше не нужен. Это не мгновенно: даже самые быстрые механизмы (Kubernetes Endpoints, Consul, Eureka) распространяют изменения за секунды.
Drain входящего трафика
Узел перестаёт принимать новый трафик и обрабатывает только то, что уже принял.
Завершение in-flight операций
Активные запросы, обрабатываемые сообщения, фоновые задачи — всё, что начато, доводится до состояния, из которого можно безопасно остановиться.
Финальная остановка
Закрываются ресурсы (соединения с БД, брокерами, кешами), процесс завершается.
Между фазами есть тайминги. Если убрать любую — в проде увидите ошибки. Если ускорить какую-то слишком резко — тоже.
Drain трафика: где он реально происходит
Главный недооценённый момент — drain происходит не на узле. Узел не может «убрать сам себя из балансировки» волшебным образом. Это решает внешний компонент: API gateway, ingress, service mesh, kube-proxy. И они делают это с задержкой.
Если узел получил сигнал «уходи» и сразу закрыл порт, между моментом получения сигнала и моментом обновления маршрутов есть окно (обычно 1–5 секунд). В этом окне трафик идёт на узел, который его уже не принимает. Клиент видит connection refused.
Поэтому в распределённой системе drain делается в два такта.
- Узел сигналит «не шлите мне больше трафика» (через readiness probe, через consul deregister, через явный API gateway-вызов). Внешние компоненты обновляют маршруты.
- Узел ждёт, пока обновление распространится. Это может быть фиксированный sleep или ожидание явного подтверждения.
- Только после этого начинается завершение активных запросов и закрытие портов.
Параметры этого ожидания — архитектурное решение. Они зависят от инфраструктуры: какой балансировщик, как часто обновляет таблицы маршрутов, какая средняя задержка у service mesh.
In-flight запросы: до какой точки доводить
Когда узел перестал принимать новые запросы, у него остаётся какой-то набор активных. Их надо завершить — но до какой степени?
Вариант «дождаться всех» работает только когда у запросов короткий хвост. Если есть запросы на минуты (например, построение тяжёлых отчётов), полная отработка займёт неприемлемо долго и заблокирует деплой.
Здоровый подход — выделить два класса запросов:
- Короткие синхронные. Ждём до завершения, обычно 10–30 секунд достаточно.
- Длинные операции. Не должны быть синхронными в принципе. Архитектурно — это асинхронная задача с сохранением состояния. На остановке узла либо передаём задачу другому, либо помечаем как «в процессе» и при перезапуске возобновляем.
Если у вас в синхронном API сидит запрос на 30 секунд, у вас не проблема graceful shutdown, у вас архитектурная проблема. Лечится переводом таких операций в асинхронный режим с командой и статусом.
Очереди и потребители
Узлы, которые читают из очередей, гасятся по своей логике. У них нет «входящего трафика» в классическом смысле — есть pull сообщений из брокера.
Алгоритм:
- Сигнал на остановку получен.
- Прекращаем брать новые сообщения. Конкретно — выходим из poll/consume цикла или перестаём вызывать
basicGet. - Дорабатываем сообщения, которые уже взяли.
- Делаем явный commit/ack.
- Закрываем сессию с брокером.
Главная ошибка — закрыть соединение, не доработав сообщения. Брокер не получит ack, переотправит сообщение другому потребителю. Если у обработки нет идемпотентности — у вас удвоенная обработка.
Архитектурно это означает: идемпотентность обработчиков сообщений — обязательное требование, не опция. Не потому что «вдруг сеть моргнёт», а потому что любой деплой потребителя — потенциальный duplicate delivery, если retention позволяет.
Распределённые операции: саги и цепочки
Самая нетривиальная часть. У вас в полёте сага: «зарезервировать товар → списать деньги → оформить доставку». Узел, выполняющий координацию, уходит. Что должно произойти?
Здесь спасает архитектурная привычка: state саги хранится снаружи координатора. В БД, в специальном state store, в чём угодно, что переживёт перезапуск. Сам координатор — это процесс, который читает состояние и продвигает его дальше. Если координатор остановится — другой экземпляр прочитает то же состояние и продолжит.
В таком устройстве остановка узла безопасна на любом шаге саги: на любом следующем шаге саги другой экземпляр увидит «нужно сделать это» и сделает.
Если координация сидит в памяти узла — у вас не сага, а корутина с распределённой иллюзией. Любое падение или остановка останется хвостом в неопределённом состоянии.
Координация деплоев: rolling vs all-at-once
Архитектор должен договориться с оркестратором о темпе обновления. На сервисах с N экземплярами выводить все одновременно нельзя — некому будет принимать трафик. Стандартные стратегии:
- Rolling update. По одному (или по N штук) экземпляру за раз. Старые продолжают работать, пока новые встают на место.
- Surge. Сначала поднять новые, потом убрать старые. Требует ресурсов на переходный период.
- Blue-green. Полная вторая копия системы, переключение трафика на неё, старая остаётся на откат.
В каждой стратегии есть параметры, которые определяют поведение под нагрузкой: maxUnavailable, maxSurge, время ожидания между шагами. Это не операционные мелочи, это архитектурное решение: насколько вы готовы потерять capacity на время деплоя, и сколько времени готовы ждать стабилизацию.
Backpressure при деплое
На большом флоте — сотни экземпляров, постоянные деплои — даже rolling update создаёт всплеск нагрузки на оставшихся. Например, выкатываем 10% флота за раз. На время деплоя 10% capacity нет, остальные 90% обрабатывают 100% трафика — это +11% нагрузки.
Если сервис работал на 80% мощности, +11% переводят его на 89% — терпимо. Если работал на 95% — деплой превращает 95% в 105%, и часть запросов тайм-аутится. Архитектурное правило: реальный запас мощности должен быть с учётом темпа выкатывания. Иначе каждый деплой — мини-инцидент.
Координация остановок при инцидентах
Иногда узлы уходят не по плану. Внезапные kill, OOM, изоляция сети. В этих случаях нет фазы drain, нет фазы in-flight завершения. Узел просто пропадает.
Архитектурный план на этот случай:
- Любая активная сессия (HTTP, websocket) должна уметь переподключиться к другому узлу. Без потери логического состояния.
- Любая операция в саге должна быть восстанавливаемой из снаружи хранимого состояния.
- Любое сообщение в обработке должно вернуться в очередь (если узел не успел ack).
Это требования не к остановке, это требования к архитектуре в целом. Корректная остановка — лучший случай распределённого сценария. Худший случай — внезапный обрыв — должен тоже быть в плане.
Тестирование
Координация остановки плохо ловится модульными тестами. Что я обычно встраиваю в pre-prod окружение:
- Регулярный rolling deploy под нагрузкой и измерение error rate. Цель — не выше базового шума.
- Сценарии «один узел внезапно умер»: chaos-инструменты, которые вырубают экземпляр без grace period.
- Тесты deduplication для очередей: повторное появление сообщения не должно создавать второй заказ.
- Восстановление прерванной саги после рестарта координатора.
Эти проверки выявляют проблемы, которые не видны на одном спокойном сервере, но проявляются под реальной нагрузкой во время инцидентов.
Архитектурный чек-лист
- Каждый узел умеет сигналить «не шлите трафик» отдельно от закрытия порта.
- Между сигналом и закрытием — пауза на распространение маршрутов.
- Длинные операции — асинхронные, с восстанавливаемым состоянием.
- Все обработчики сообщений идемпотентны.
- Состояние распределённых процессов (сага, оркестрация) хранится снаружи узлов.
- Темп rolling update согласован с запасом мощности.
- Сценарий внезапного обрыва так же предусмотрен, как мирная остановка.
- Регулярные тесты деплоев и chaos-сценариев в pre-prod.
Что запомнить
Корректная остановка отдельного сервиса — необходимое, но недостаточное условие. В распределённой системе остановка — это командный спорт: узел сигналит, балансировщик уводит, очередь принимает обратно недосделанное, координатор саги подхватывает с того места, где остановился. Архитектура должна делать это рутинной операцией, без героизма дежурного инженера. Когда деплой превратился в нон-ивент — значит, вы спроектировали это правильно.