Геораспределённый NATS: superclusters, gateways, leaf nodes и mirror-стримы

Особые гео-топологии NATS: gateways и supercluster с interest-propagation, leaf nodes для edge, асинхронная репликация стримов через mirror/source между регионами — и почему RAFT-группу нельзя растягивать через океан

Это четвёртая статья серии «Погружение в NATS». Третья статья построила классический кластер на трёх нодах в одном дата-центре: Core-routes для транспорта, JetStream RAFT для durability. Три ноды в одной сети — с низкой и предсказуемой latency между ними — это то, на чём RAFT-кворум работает хорошо.

Геораспределённый NATS: superclusters, gateways, leaf nodes и mirror-стримы

Гео-распределение ломает это допущение. Если сервисы работают в нескольких регионах, встаёт классический вопрос: растянуть один кластер через океан или построить топологию из нескольких независимых кластеров, связанных между собой. NATS — один из немногих брокеров, для которого этот вопрос вообще имеет развёрнутый, зрелый ответ на уровне протокола, а не только «поднимите второй кластер и разбирайтесь сами». Это отдельный слой поверх всего, что было в предыдущих трёх статьях, и одна из причин, по которой NATS часто выбирают именно ради гео-сценариев.

В статье

Gateways и supercluster

Gateway — третий тип межсерверного соединения в NATS, отдельный от routes (Core-кластер) и leafnodes (о них — дальше). Если routes объединяют ноды внутри одного кластера в full-mesh, то gateway объединяет несколько независимых кластеров в supercluster — топологию, где у каждого кластера остаётся своё имя, свой набор нод и своя локальная автономность, но клиент, подключённый к любому кластеру, прозрачно видит подписки и (в определённых пределах) данные во всех остальных.

Конфигурация — блок gateway{}, симметричный по духу cluster{}:

gateway {
  name: cluster-a
  listen: 0.0.0.0:7222
  gateways: [
    {name: cluster-b, urls: ["nats://b1:7222", "nats://b2:7222"]}
  ]
}

name — имя этого кластера в контексте gateway. Если заданы и cluster.name, и gateway.name, они обязаны совпадать — иначе nats-server откажется стартовать с ошибкой cluster name conflicts between cluster and gateway definitions; проще всего указывать одно и то же имя. listen — порт для входящих gateway-соединений (по конвенции 7222, отдельно от 6222 route и 4222 клиентского). gateways — список других кластеров supercluster с их адресами; url — один адрес, urls — несколько, если в удалённом кластере несколько нод и хочется резервный вход. Как и с self-routes в Core-кластере (ст. 3), собственную запись можно включать в список без вреда — self-gateway-соединения игнорируются.

Каждая нода каждого кластера открывает свой gateway.listen независимо — gateway работает на уровне ноды, а не кластера как единой сущности. Точнее говорить не «одно соединение между кластерами», а по одному исходящему gateway-соединению с каждого сервера: каждый сервер кластера A выбирает одну (случайную) ноду из списка urls удалённого кластера B и держит к ней единственное исходящее соединение — плюс принимает входящие gateway-соединения от серверов B. Поэтому суммарно между двумя кластерами столько исходящих каналов, сколько серверов, а не один. Единой точки отказа нет: если выбранная удалённая нода упадёт, исходящее соединение переустановится к другой из списка.

graph TD subgraph CA["cluster-a"] A1["a1"] end subgraph CB["cluster-b"] B1["b1"] <--> B2["b2"] end A1 <-.->|gateway| B1 A1 <-.->|gateway| B2 style CA fill:#f9f3e3,stroke:#8b7355 style CB fill:#c9e4c5,stroke:#5b8a5e

graph TD
  subgraph CA["cluster-a"]
    A1["a1"]
  end
  subgraph CB["cluster-b"]
    B1["b1"] <--> B2["b2"]
  end

  A1 <-.->|gateway| B1
  A1 <-.->|gateway| B2

  style CA fill:#f9f3e3,stroke:#8b7355
  style CB fill:#c9e4c5,stroke:#5b8a5e
Supercluster: два независимых кластера, объединённые gateway-соединением

Interest-propagation: трафик идёт туда, где есть подписчик

Ключевое инженерное решение gateway — оптимизированная передача интереса (interest-only propagation), а не наивная трансляция всего трафика во все кластеры. Кластер A не заливает в B каждое сообщение каждого subject, опубликованного локально. Вместо этого B сообщает A, на что у него есть подписка, и A пересылает через gateway только те сообщения, которые действительно кому-то нужны на другой стороне. Когда подписка на B появляется — интерес распространяется на A; когда последний подписчик уходит — интерес гаснет, и трафик по этому subject перестаёт пересекать границу кластеров.

Практическое следствие: если в регионе B никто не подписан на orders.>, публикации orders.> в регионе A физически не попадают в B — ни по одному сообщению. Межрегиональный канал не тратится на трафик, который некому читать. Это не отдельная настройка, а поведение gateway по умолчанию.

Отдельно обрабатываются queue-подписки: интерес по ним распространяется не более одного раза на пару «аккаунт + subject» и исчезает, когда пропадает последний участник очереди в удалённом кластере — так поддерживаются семантика queue groups (ст. 1) в масштабе всего supercluster, а не одного кластера.

Мост от RabbitMQ и Kafka

Federation/Shovel RabbitMQ — ближайший концептуальный родственник, но с обратным направлением инициативы. В статье про Federation и Shovel downstream-кластер сам подписывается на upstream и явно копирует поток через exchange- или queue-federation; это конфигурация на уровне конкретного exchange/queue, и трафик течёт в одну заданную сторону, пока её явно не настроят в обе. Gateway работает на уровне всего кластера сразу, симметрично в обе стороны, и решение «гнать или не гнать» принимается автоматически по интересу подписчиков, а не по заранее сконфигурированной policy на конкретный exchange.

Kafka MirrorMaker 2 — тоже иной подход: явный consumer-producer pipeline, который вычитывает топики одного кластера и переиспользует их в другой, с переименованием топиков и собственным consumer-lag. Это репликация данных, поднятая как отдельный сервис поверх обоих кластеров, а не встроенный в брокер механизм маршрутизации. Gateway ближе к «расширению видимости» — тот же subject, тот же трафик, просто через границу кластеров; MirrorMaker 2 — это отдельный конвейер, физически копирующий сообщения из одного топика в другой.

Leaf nodes

Leaf node — третий примитив топологии, и у него нет прямого аналога ни в RabbitMQ, ни в Kafka. Это не член кластера (leaf node не входит в Core full-mesh через routes) и не узел supercluster (не участвует в gateway). Это отдельный процесс nats-server, который инициирует одно исходящее соединение к какому-то hub-кластеру и через него получает доступ к его subject-пространству, оставаясь при этом полностью автономным локально.

Конфигурация — зеркальная пара блоков leafnodes{} на двух концах. На стороне hub достаточно открыть порт:

leafnodes {
  listen: 0.0.0.0:7422
}

На стороне leaf-ноды — указать, куда подключаться:

leafnodes {
  remotes: [
    {urls: ["nats://a1:7422"]}
  ]
}

Порт 7422 — конвенция (как 6222 для routes и 7222 для gateway), не жёстко зашитое значение. remotes может содержать несколько urls для отказоустойчивости и несколько записей remotes, если одна и та же leaf-нода должна подключаться к нескольким разным hub-кластерам одновременно (в разные аккаунты).

graph LR subgraph Hub["hub-кластер (ЦОД)"] H1["a1"] end subgraph Edge["edge-площадка (магазин, завод, филиал)"] L1["leaf1"] C1["локальные клиенты"] C1 <--> L1 end L1 -.->|leafnode: solicited| H1 style Hub fill:#f9f3e3,stroke:#8b7355 style Edge fill:#c9e4c5,stroke:#5b8a5e

graph LR
  subgraph Hub["hub-кластер (ЦОД)"]
    H1["a1"]
  end
  subgraph Edge["edge-площадка (магазин, завод, филиал)"]
    L1["leaf1"]
    C1["локальные клиенты"]
    C1 <--> L1
  end

  L1 -.->|leafnode: solicited| H1

  style Hub fill:#f9f3e3,stroke:#8b7355
  style Edge fill:#c9e4c5,stroke:#5b8a5e
Leaf node: edge-площадка инициирует одно соединение к hub-кластеру, сохраняя локальную автономность

Зачем это нужно: ценность для edge

Практическая разница между leaf node и «ещё одним членом кластера» — в степени связности. Full-mesh routes подразумевают, что все ноды кластера более-менее равноправны и рассчитаны на стабильный, быстрый канал между собой — потеря связности с одной нодой сказывается на кворуме RAFT у всех. Leaf node сознательно устроена иначе: она никогда не входит в RAFT-группы hub-кластера, у неё нет права голоса в его meta-group, и при разрыве соединения с hub локальные publishers и subscribers на leaf-ноде продолжают работать друг с другом как ни в чём не бывало — просто перестают видеть subject-пространство hub, пока соединение не восстановится.

Это именно то поведение, которое нужно на периферии: магазин с локальным NATS-сервером, обслуживающим кассы и терминалы в помещении, который иногда (не всегда) хочет обменяться данными с центральным офисом; завод с локальной IoT-телеметрией, которая должна продолжать работать при обрыве канала в облако; филиал с нестабильным или дорогим каналом в основной ЦОД. Ни RabbitMQ, ни Kafka не предлагают встроенного примитива именно с этими свойствами — federation в RabbitMQ асинхронна, но обе стороны предполагаются постоянно живыми членами federation-топологии, а не «то на связи, то нет» edge-узлом с полной локальной автономностью по умолчанию.

Технически leaf-соединение может пробрасывать не только Core subjects, но и JetStream — тема отдельного, более специфического сценария (JetStream domains), который выходит за рамки этой статьи; здесь важно только то, что сам факт leaf-топологии не требует JetStream ни на одной из сторон.

Гео и JetStream: mirror/source

Gateway решает транспортную задачу — subject-пространство видно через границу кластеров. Но что с данными JetStream? Здесь начинается самая частая ошибка при проектировании гео-топологий: попытка растянуть одну RAFT-группу — meta-group или per-stream — через gateway на несколько регионов, чтобы получить единый stream, реплики которого физически разбросаны по континентам.

Почему нельзя растягивать RAFT-группу через высокий RTT

RAFT требует подтверждения от большинства реплик на каждую запись до того, как она будет считаться committed (ст. 3). Если реплики размещены в разных регионах, каждая запись должна дождаться round-trip до кворума реплик через межрегиональный канал — а это не единицы миллисекунд локальной сети, а десятки и сотни миллисекунд через океан. Мейнтейнеры NATS в обсуждениях описывают так называемые stretch-кластеры — конфигурации, где одна RAFT-группа сознательно растянута через высокую latency — как продвинутый сценарий и последнее средство, с неформальным ориентиром на RTT порядка 100–150 мс между нодами и только при действительно хорошем сетевом канале. Для сравнения: обычным, не растянутым кластерам docs.nats.io советуют держать RTT в пределах единиц миллисекунд внутри ЦОД и десятков — между регионами. Каждая публикация в такой stream ждёт этот round-trip — приложение, которое раньше публиковало за единицы миллисекунд внутри одного ЦОД, начинает упираться в таймауты клиента, если латency не была заложена в архитектуру заранее.

Это не ограничение конкретной реализации, а математика консенсуса: RAFT не может подтвердить запись быстрее, чем самый медленный участник кворума успевает ответить. Растягивать meta-group или per-stream RAFT-группу через гео-расстояния можно — NATS этого не запрещает, — но это осознанный компромисс в пользу немедленной консистентности ценой latency на каждую операцию записи, и годится он только при небольшом числе регионов с действительно хорошим межрегиональным каналом. Для большинства гео-сценариев это не то, что нужно.

Mirror и source: асинхронная альтернатива

Правильный инструмент для типичного гео-сценария — не растягивать одну RAFT-группу, а держать в каждом регионе свою RAFT-группу (со своим локальным, быстрым кворумом) и реплицировать данные между регионами асинхронно, через gateway, отдельным механизмом: mirror и source.

  • Mirror — полная асинхронная копия одного stream: всё та же последовательность сообщений, тот же порядок, только с задержкой репликации. У mirror-стрима нет собственных publishers — писать в него напрямую нельзя, только читать через consumer.
  • Source — то же самое, но может собирать данные из нескольких stream одновременно, объединяя их в один поток (в отличие от mirror, у которого ровно один источник).

Создаётся mirror-стрим со стороны принимающего кластера — источник ничего специально не настраивает:

nats stream add MIRROR --server nats://localhost:4223 \
  --mirror SOURCE --storage file --replicas 1 --defaults

Если кластеры соединены через gateway (как в этой статье) и находятся в одном аккаунте и JetStream-домене, достаточно просто имени исходного stream — gateway делает его видимым по имени без дополнительной адресации (это проверено на демо-стенде: mirror в cluster-b догоняет SOURCE из cluster-a). Более сложные сценарии — mirror через отдельный JetStream domain (например, в связке с leaf node, а не gateway) или cross-account mirror — требуют явного external{}/domain в JSON-конфигурации стрима; в рамках supercluster с gateway в одном домене это не нужно.

graph LR subgraph RA["Регион A — cluster-a"] SA["Stream SOURCE
(своя RAFT-группа)"] end subgraph RB["Регион B — cluster-b"] SB["Stream MIRROR
(своя RAFT-группа)"] end SA -.->|"асинхронная репликация
через gateway"| SB style RA fill:#f9f3e3,stroke:#8b7355 style RB fill:#c9e4c5,stroke:#5b8a5e

graph LR
  subgraph RA["Регион A — cluster-a"]
    SA["Stream SOURCE
(своя RAFT-группа)"] end subgraph RB["Регион B — cluster-b"] SB["Stream MIRROR
(своя RAFT-группа)"] end SA -.->|"асинхронная репликация
через gateway"| SB style RA fill:#f9f3e3,stroke:#8b7355 style RB fill:#c9e4c5,stroke:#5b8a5e
Mirror между регионами: своя RAFT-группа в каждом кластере, асинхронная репликация через gateway

Active-active и DR

Mirror/source по умолчанию — однонаправленная репликация: пишут в оригинал, читают из копии. Для disaster recovery этого достаточно как есть: DR-кластер в другом регионе держит асинхронную копию критичных stream, и если основной регион полностью выходит из строя, DR-кластер может принять роль основного (с оговоркой, что данные, не успевшие реплицироваться до сбоя, будут потеряны — это стандартный компромисс асинхронной репликации, RPO больше нуля).

Для active-active — когда запись должна приниматься в обоих регионах одновременно — mirror/source сам по себе не подходит: это read-only копия, писать в неё нельзя. Схемы с двусторонней записью в разных регионах (например, круговая репликация между двумя независимыми stream, каждый из которых source для другого) — рабочий, но существенно более сложный паттерн, с собственными вопросами про дедупликацию и разрешение конфликтов; здесь он только упоминается, чтобы обозначить границу того, что решает простой mirror.

Мост от RabbitMQ и Kafka

Тот же выбор — «растянуть консенсус» vs «независимые кластеры + асинхронная репликация» — обсуждался в статье про Federation и Shovel: растянуть один кластер RabbitMQ через регионы там тоже называлось плохой идеей ровно по той же причине (кворум quorum queue через WAN). Federation и Shovel в RabbitMQ решают ту же задачу, что mirror/source в NATS — асинхронная связь между независимыми брокерами без разделяемого консенсуса, просто с другим набором ручек: source/mirror работает на уровне стрима с фиксированной семантикой «одна копия, один источник», а federation/shovel гибче конфигурируется на уровне exchange или очереди, но требует больше явной настройки.

Демонстрация

Рабочий стенд — nats/03-geo в digital-cookbook. Топология: cluster-a (одна нода a1, с leafnodes{listen} для leaf-соединения) и cluster-b (две ноды b1/b2, обычный Core-кластер как в 02-cluster), связанные через gateway, плюс отдельная leaf-нода leaf1, инициирующая соединение к a1.

git clone https://github.com/khorost-tech/digital-cookbook.git
cd digital-cookbook/nats/03-geo
docker compose up -d

Один нюанс, который стоит знать заранее: у gateway-связанного supercluster из совсем маленьких кластеров meta-group JetStream не всегда режется строго по границе cluster.name — при малом числе JetStream-нод она может расшириться на весь supercluster. Чётное суммарное число JetStream-нод в этом случае — практический анти-паттерн: кворум может не набраться при рассогласовании, ровно по той же причине, по которой в ст. 3 не рекомендовались 2 или 4 реплики стрима. Поэтому в стенде cluster-b — две ноды, а не одна: 1 (cluster-a) + 2 (cluster-b) = 3, нечётное число, meta-group стабильна. Важная оговорка про «арбитра»: чётность правит число JetStream-включённых серверов, а нода без JetStream в meta-RAFT вообще не входит и кворум не меняет — «облегчённый арбитр без JetStream» здесь не работает. Если нужна лишняя нода ради нечётности, она должна быть JetStream-включённой (может просто не держать пользовательских стримов), либо кластеры сразу сайзят под нечётное суммарное число JetStream-нод. В проде NATS для JetStream рекомендует 3 или 5 JetStream-серверов в кластере, и на таких размерах вопрос чётности снимается сам.

Аккаунт $SYS в этом стенде настроен намеренно: nats-server требует явно заданный system-аккаунт, когда одновременно определены gateway и leafnodes, — иначе сервер не стартует. Учётных данных $SYS для команд nats server report мы при этом не заводим, поэтому состояние gateway/supercluster смотрим через monitoring endpoints, как и в ст. 3:

curl -s localhost:8222/varz | grep -A6 '"meta"'   # meta.cluster_size: 3
curl -s localhost:8222/gatewayz                   # outbound/inbound gateway-соединения

Публикация в cluster-a, видимая в cluster-b только при наличии подписчика (interest-propagation):

nats sub --server nats://localhost:4223 "geo.>" &
nats pub --server nats://localhost:4222 geo.test "hello-from-a"

Публикация с leaf-ноды, доходящая до hub:

nats pub --server nats://localhost:4224 edge.ping "hello-from-leaf"
curl -s localhost:8222/leafz   # leafnodes: 1

Mirror-стрим между регионами:

nats stream add SOURCE --server nats://localhost:4222 \
  --subjects "orders.>" --storage file --replicas 1 --defaults
nats pub --server nats://localhost:4222 orders.eu.1 "order-1"

nats stream add MIRROR --server nats://localhost:4223 \
  --mirror SOURCE --storage file --replicas 1 --defaults
nats stream info MIRROR --server nats://localhost:4223   # Messages: 1, Lag: 0

Полный список команд, топология портов и объяснение конфигурации — в README демо.

Вывод

Гео-распределение в NATS — не один механизм, а три разных инструмента для трёх разных задач, и путать их — источник большинства архитектурных ошибок в этой области. Gateway объединяет независимые кластеры в supercluster с оптимизированной передачей интереса: трафик между регионами течёт только туда, где есть реальный подписчик, а не транслируется вслепую. Leaf nodes — не член кластера и не узел supercluster, а edge-топология с локальной автономностью: площадка продолжает работать при потере связи с hub, в отличие от полноправного члена RAFT-группы. Mirror/source — асинхронная репликация данных JetStream между независимыми, локально-консистентными кластерами; растягивать одну RAFT-группу через высокий RTT ради мгновенной консистентности можно, но это осознанный компромисс, а не путь по умолчанию — для большинства гео-сценариев асинхронная репликация ближе к тому, что реально нужно.

Дальше в серии: пятая статья — производительность и надёжность на конкретных цифрах (nats bench, tuning, что происходит под нагрузкой).

Документация

Обсуждение в Telegram

Присоединиться →

Комментарии