Это четвёртая статья серии «Погружение в NATS». Третья статья построила классический кластер на трёх нодах в одном дата-центре: Core-routes для транспорта, JetStream RAFT для durability. Три ноды в одной сети — с низкой и предсказуемой latency между ними — это то, на чём RAFT-кворум работает хорошо.
Гео-распределение ломает это допущение. Если сервисы работают в нескольких регионах, встаёт классический вопрос: растянуть один кластер через океан или построить топологию из нескольких независимых кластеров, связанных между собой. 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
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
Зачем это нужно: ценность для 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 в одном домене это не нужно.
(своя 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
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: 1Mirror-стрим между регионами:
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, что происходит под нагрузкой).
Комментарии