lenec ru

← все посты

Координация остановки в распределённой системе: drain трафика, in-flight запросы, очереди

15K

Когда отдельно взятый сервис умеет завершаться корректно, это половина дела. Вторая половина начинается, когда таких сервисов десять, между ними сетевые вызовы и общие очереди, и кто-то нажал «выкатить новую версию». В этот момент важно не как отдельный экземпляр гасит свой 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 сообщений из брокера.

Алгоритм:

  1. Сигнал на остановку получен.
  2. Прекращаем брать новые сообщения. Конкретно — выходим из poll/consume цикла или перестаём вызывать basicGet.
  3. Дорабатываем сообщения, которые уже взяли.
  4. Делаем явный commit/ack.
  5. Закрываем сессию с брокером.

Главная ошибка — закрыть соединение, не доработав сообщения. Брокер не получит 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.

Что запомнить

Корректная остановка отдельного сервиса — необходимое, но недостаточное условие. В распределённой системе остановка — это командный спорт: узел сигналит, балансировщик уводит, очередь принимает обратно недосделанное, координатор саги подхватывает с того места, где остановился. Архитектура должна делать это рутинной операцией, без героизма дежурного инженера. Когда деплой превратился в нон-ивент — значит, вы спроектировали это правильно.

Комментарии 1

  • Андрей Крылов

    Про graceful shutdown в FastAPI больно отдельно: если SIGTERM приходит во время длинной фоновой таски (BackgroundTasks), uvicorn честно ждёт, но pod terminationGracePeriodSeconds часто меньше реального хвоста. Мы пришли к тому, что фон вынесен в отдельный воркер (arq), а API-под держит только короткие запросы и graceful=15s покрывает p99. Как у вас в проектах с большим RPS — drain через preStop sleep или через readiness=false с явным сигналом балансировщику?

Войдите, чтобы оставить комментарий.