Это третья статья серии «Погружение в NATS». Первая статья разобрала Core NATS — subjects, pub/sub, request/reply; вторая добавила JetStream — persistence, streams, consumers. Обе статьи сознательно оставались на одном узле: один процесс nats-server, один диск, один порт.
Один узел — это SPOF по определению, и рано или поздно встаёт вопрос: что произойдёт, если этот узел упадёт? Ответ у NATS не единый, и это первое, что стоит понять перед тем, как заводить кластер в проде: слово «кластер» здесь означает две разные вещи одновременно, и их легко перепутать.
В статье
- Два уровня кластеризации
- Core-кластер: routes
- JetStream RAFT: meta-group и per-stream
- Placement и sizing
- Failover вживую
- Вывод
Два уровня кластеризации
Когда говорят «кластер NATS», почти всегда имеют в виду одно из двух — и это не одно и то же.
Уровень 1 — Core-кластер. Несколько nats-server соединены между собой routes в full-mesh: каждая нода знает о каждой, и подписка на любой ноде видит публикацию на любой другой. Это транспортная связность — сообщение из точки A попадёт в точку B, даже если publisher и subscriber подключены к разным нодам. Она ничего не хранит и не даёт гарантий доставки — это то же самое at-most-once поведение Core, что и на одном узле (ст. 1), просто растянутое на несколько процессов.
Уровень 2 — JetStream-кластер. Отдельная подсистема поверх Core-кластера, которая использует RAFT — протокол консенсуса — чтобы реплицировать данные streams и consumers между нодами с формальными гарантиями: запись подтверждена, только когда её видит большинство реплик. Это уровень durability и HA данных.
Путаница возникает ровно потому, что оба уровня работают на одних и тех же трёх процессах и поднимаются одной командой docker compose up. Но включённый Core-кластер (cluster{routes}) без JetStream не даёт никакой сохранности данных — это просто более широкая транспортная сеть. И наоборот, JetStream не может реплицировать данные без работающего Core-кластера под собой: RAFT-группам нужен канал связи между нодами, а этот канал — как раз routes.
graph TD
subgraph L1["Уровень 1 — Core: routes, full-mesh gossip"]
direction LR
A1["n1"] <--> A2["n2"]
A2 <--> A3["n3"]
A1 <--> A3
end
subgraph L2["Уровень 2 — JetStream: RAFT-группы поверх routes"]
direction LR
B1["n1"] -.->|RAFT| B2["n2"]
B2 -.->|RAFT| B3["n3"]
B1 -.->|RAFT| B3
end
L1 -->|"даёт транспорт для"| L2
style L1 fill:#f9f3e3,stroke:#8b7355
style L2 fill:#c9e4c5,stroke:#5b8a5e
Мост от RabbitMQ и Kafka. В статье про кластер RabbitMQ было то же самое разделение под другими именами: кластер Erlang-нод реплицирует метаданные автоматически, но содержимое очереди — только если тип очереди это поддерживает (quorum queue на Raft). «Кластеризация ≠ HA» там было ключевой оговоркой; в NATS она звучит почти дословно так же: Core-кластеризация ≠ durability.
Core-кластер: routes
Конфигурация Core-кластера — блок cluster{} в конфиге ноды:
server_name: n1
listen: 0.0.0.0:4222
cluster {
name: c1
listen: 0.0.0.0:6222
routes: ["nats://n2:6222", "nats://n3:6222"]
}
listen внутри cluster{} — отдельный порт для входящих route-соединений (по конвенции — 6222, но это не жёстко зашитое значение, просто общепринятый выбор), не путать с клиентским портом 4222. routes — список адресов соседних нод. Важная деталь: себя в списке указывать не нужно — self-routes сервер просто игнорирует.
Дальше вступает протокол обнаружения: узлу достаточно знать хотя бы один живой адрес, чтобы попасть в кластер — этот адрес называют seed. Как только соединение с seed установлено, ноды начинают госсипить друг другу списки всех известных участников, и каждая нода автоматически достраивает соединения до всех остальных — получается full-mesh: N нод — N×(N−1)/2 route-соединений. Явно перечислять в routes всех соседей не обязательно (достаточно одного живого seed), но для небольшого статического кластера из 3 нод проще и надёжнее явно перечислить всех — тогда кластер не зависит от того, поднялся ли конкретный seed первым.
routes: [n2, n3]"] <--> N2["n2
routes: [n1, n3]"] N2 <--> N3["n3
routes: [n1, n2]"] N1 <--> N3 style N1 fill:#c9e4c5,stroke:#5b8a5e style N2 fill:#c9e4c5,stroke:#5b8a5e style N3 fill:#c9e4c5,stroke:#5b8a5e
graph LR
N1["n1
routes: [n2, n3]"] <--> N2["n2
routes: [n1, n3]"]
N2 <--> N3["n3
routes: [n1, n2]"]
N1 <--> N3
style N1 fill:#c9e4c5,stroke:#5b8a5e
style N2 fill:#c9e4c5,stroke:#5b8a5e
style N3 fill:#c9e4c5,stroke:#5b8a5e
С этого момента subject-based маршрутизация становится прозрачной для клиента: подписчик на n3 получит сообщение, опубликованное на n1, без какой-либо дополнительной настройки — сервер сам решает, на какие ноды разослать сообщение, опираясь на то, какие подписки видел через gossip. Клиенту не нужно знать топологию — только адрес любой живой ноды кластера (а лучше — списком, ровно по той же причине, что и в RabbitMQ: если клиент знает только один адрес, эта нода становится SPOF на стороне клиента, даже когда кластер жив).
Что этот уровень не даёт: сообщение, опубликованное в Core subject, по-прежнему живёт только на время доставки. Если на момент публикации нет подписчика ни на одной ноде кластера — сообщение потеряно, как и на одном узле. Routes размножают доставку, а не хранение.
JetStream RAFT: meta-group и per-stream
Когда на всех нодах включён jetstream{}, поверх Core-кластера поднимается второй, независимый механизм — консенсус через RAFT. У него две области ответственности, и они организованы как две разные категории RAFT-групп.
Meta-group. Все JetStream-ноды кластера входят в одну общую RAFT-группу — meta-group. Она отвечает за административные решения: на каких нодах создать stream, где разместить его реплики, куда назначить consumer. У meta-group есть свой лидер, который обрабатывает JetStream API-запросы (stream add, consumer add и так далее) и распределяет размещение по кластеру. Это не про данные конкретного stream — это про то, «кто где живёт».
Per-stream / per-consumer RAFT-группы. Каждый stream с числом реплик больше 1 получает собственную RAFT-группу из тех нод, где физически размещены его реплики — не обязательно все ноды кластера. У этой группы отдельный лидер (может отличаться от лидера meta-group), который принимает публикации и реплицирует записи последователям. Consumers с состоянием (durable, с сохранённой позицией) в кластерном режиме тоже могут иметь собственную RAFT-группу поверх того же набора нод.
Число реплик задаётся при создании — --replicas N, в терминологии community это называют R1/R3/R5: R1 — без репликации (данные на одной ноде, JetStream просто хранит их локально, RAFT не участвует), R3 — три реплики, R5 — пять. Запись в stream считается подтверждённой (committed), когда её видит большинство реплик — кворум, ⌊N/2⌋ + 1. Для R3 это 2 из 3, для R5 — 3 из 5.
graph TD
subgraph MG["Meta-group — все ноды кластера"]
M1["n1"] <-.->|RAFT: placement, assignment| M2["n2"]
M2 <-.-> M3["n3"]
M1 <-.-> M3
end
subgraph SG["Per-stream RAFT — EVENTS, R3"]
S1["n1 (leader)"] -->|replicate| S2["n2 (follower)"]
S1 -->|replicate| S3["n3 (follower)"]
end
MG -->|"назначает размещение"| SG
style MG fill:#f9f3e3,stroke:#8b7355
style SG fill:#c9e4c5,stroke:#5b8a5e
Нечётность — не рекомендация, а математика кворума. 3 реплики переживают потерю 1 ноды (кворум 2 из 3 сохраняется), 5 — потерю 2 (кворум 3 из 5). Чётное число избыточно по своей цене: R2 (кворум 2 из 2) не переживает вообще ни одной потери, а R4 (кворум 3 из 4) переживает ровно одну — столько же, сколько R3, но ценой лишней полной копии (при этом больше пяти реплик JetStream и не даёт — R5 это потолок). Вдобавок при симметричном разделении сети чётного кластера (2 против 2) большинство не набирает ни одна половина, и прогресс встаёт с обеих сторон. Отсюда практическое правило: реплик всегда 1, 3 или 5, никогда не 2 или 4.
Потеря большинства. Если для конкретного stream недоступно больше половины его реплик (например, R3 и упали сразу 2 из 3 нод, где он размещён), stream теряет кворум и перестаёт принимать новые публикации — не потому, что данные повреждены, а потому что RAFT физически не может подтвердить запись без большинства. Это осознанный выбор в пользу консистентности (CP из CAP): лучше отказать в записи, чем зафиксировать её только на меньшинстве и потом не суметь понять, какая копия верна. Как только кворум восстанавливается (нода возвращается или её RAFT-пир переносят на другую машину), stream снова принимает записи, а отставшая реплика досинхронизирует лог.
Мост от RabbitMQ и Kafka
Stream replicas vs quorum queue RabbitMQ. Это прямая параллель, а не аналогия — оба используют RAFT для одного и того же: реплицировать записываемый лог по большинству и пережить падение лидера без потери подтверждённых данных. В статье про кластер RabbitMQ quorum queue — это RAFT-группа поверх набора нод-реплик конкретной очереди; per-stream RAFT-группа JetStream устроена буквально так же, только единица репликации — stream, а не очередь. Нечётность реплик, кворум N/2+1, потеря доступности меньшинства при split-brain — везде одни и те же следствия одного и того же протокола.
Kafka: replication factor + ISR — другой протокол с похожей целью. Kafka не использует RAFT для репликации партиций (хотя с KRaft начал использовать RAFT для метаданных кластера — контроллера, о нём ниже). Вместо голосования большинством там ISR (in-sync replicas) — список реплик, которые не отстают от лидера партиции больше допустимого лага; запись с acks=all считается подтверждённой, когда её получили все реплики из ISR. Разница практическая: ISR может сжиматься до одной реплики при деградации, и тогда acks=all формально выполняется, даже если избыточности уже нет — в отличие от RAFT-кворума, где число нужных подтверждений жёстко зафиксировано числом реплик и не адаптируется незаметно для оператора.
Meta-group vs KRaft controller quorum. Здесь параллель почти буквальная. Начиная с KRaft (замена ZooKeeper) Kafka тоже держит отдельную RAFT-группу — controller quorum — которая управляет метаданными кластера: какие топики существуют, где партиции, кто лидер каждой из них. Это ровно та же роль, что у meta-group в JetStream: не хранение сообщений, а консенсус по структуре кластера, на основе которого затем работает репликация самих данных.
Placement и sizing
Meta-group решает, на каких конкретно нодах разместить реплики нового stream — это называется placement. По умолчанию алгоритм распределяет реплики так, чтобы минимизировать пересечение с уже существующими размещениями (балансировка нагрузки между нодами), но placement можно и нужно ограничивать явно, когда у кластера есть физическая топология — теги нод, зоны доступности, требования вроде «не размещать все реплики в одной стойке». Управление тегами и явными ограничениями placement — это уже вопрос эксплуатации конкретного кластера, здесь достаточно знать, что рычаг существует.
Почему обычно 3, а не 5. Три реплики — это минимум, при котором кластер вообще переживает падение одной ноды с сохранением кворума, и для большинства сценариев этого достаточно: одновременная потеря 2 из 3 нод — редкое событие, если только это не единая точка отказа на уровне инфраструктуры (одна стойка, один ЦОД). Стоимость R3 — три полные копии каждого сообщения на диске и сетевой round-trip на подтверждение записи внутри кластера при каждой публикации; для R5 то же самое, но с пятью копиями и более широким кворумом.
Когда 5. Пять реплик оправданы, если нужно пережить одновременную потерю двух нод — например, кластер размещён в трёх зонах доступности и должен продолжать работать при полном отказе одной зоны (2 из 5 нод), либо когда операционные окна обслуживания пересекаются с риском единичного отказа (плановое обслуживание одной ноды + внезапное падение другой). Цена — пропорционально больше диска, памяти под RAFT-состояние и сетевого трафика на репликацию; для большинства нагрузок это неоправданно, и 5 реплик стоит вводить осознанно, а не «на всякий случай».
Geo-распределённая кластеризация — supercluster через gateways, leaf nodes для периферийных площадок, mirror streams между кластерами — это отдельный, более сложный слой топологии, вне scope этой статьи; ему посвящена четвёртая статья серии.
Failover вживую
Рабочий стенд — nats/02-cluster в digital-cookbook. Три ноды (образ nats:2.12), у каждой свой конфиг с server_name, портами и списком routes, у всех включён jetstream{} со своим store_dir. Именно этот стенд будет использоваться в статьях про клиентов на Go и Java дальше в серии.
git clone https://github.com/khorost-tech/digital-cookbook.git
cd digital-cookbook/nats/02-cluster
docker compose up -d
После нескольких секунд, пока ноды находят друг друга через gossip, кластер сформирован: meta-group видит все три ноды.
curl -s localhost:8222/jsz | jq '.meta_cluster'
# cluster_size: 3, leader: "n1" (или n2/n3 — кто победил в выборах)
Создаём stream с тремя репликами и публикуем сообщение:
nats --server localhost:4222 stream add EVENTS --subjects "events.>" --replicas 3 --storage file --defaults
nats --server localhost:4222 pub events.test "hello"
nats --server localhost:4222 stream info EVENTS
В блоке Cluster Information — лидер и две реплики со статусом current: все три ноды синхронизированы, RAFT-группа стрима здорова.
Дальше — падение ноды. Останавливаем ту, что сейчас лидер (или любую — сценарий работает одинаково):
docker compose stop n1
Проверяем stream через любую из оставшихся нод:
nats --server localhost:4223 stream info EVENTS
sequenceDiagram
participant Client
participant n1 as n1 (leader)
participant n2 as n2 (follower)
participant n3 as n3 (follower)
Client->>n1: publish events.test
n1->>n2: replicate
n1->>n3: replicate
n2-->>n1: ack
Note over n1,n2: Кворум 2 из 3 — запись committed
Note over n1: n1 падает
n2->>n2: RAFT-выборы
n2->>n3: RAFT-выборы
Note over n2: n2 избран новым лидером
Client->>n2: publish events.test2 (переподключение)
n2->>n3: replicate
n3-->>n2: ack
Note over n2,n3: Кворум 2 из 3 сохраняется — запись committed
В выводе stream info видно: Leader переехал на живую ноду, упавшая помечена OFFLINE, а Messages/First Sequence/Last Sequence — те же, что были до падения. Данные не потеряны, потому что запись была подтверждена кворумом до того, как нода упала. Кластер при этом продолжает принимать новые публикации — 2 из 3 реплик всё ещё формируют кворум:
nats --server localhost:4223 pub events.test2 "during outage"
nats --server localhost:4223 stream info EVENTS # Messages: 2
Возвращаем ноду обратно — она подключается, RAFT-группа стрима досылает ей отставшие записи лога, и через несколько секунд все три реплики снова current:
docker compose start n1
Отдельно от аварийного сценария есть управляемый: cluster step-down — попросить лидера стрима уступить место, не дожидаясь его падения. Полезно перед плановым обслуживанием ноды-лидера — тогда переключение происходит контролируемо, а не по таймауту:
nats stream cluster step-down EVENTS
Полный список команд и ожидаемый вывод — в README демо.
Вывод
NATS-кластер — это два независимых механизма на одних и тех же нодах, и различие между ними определяет, чего вы на самом деле добились, включив кластеризацию. Core-кластер (cluster{routes}, full-mesh, gossip) даёт транспортную связность: subject, опубликованный на одной ноде, дойдёт до подписчика на любой другой — но ничего не хранит и переживает те же ограничения at-most-once, что и Core на одном узле. Durability и HA данных — это отдельный слой, JetStream RAFT: meta-group управляет размещением, per-stream/per-consumer RAFT-группы реплицируют сами данные с кворумом большинства.
Практический вывод простой: если вам нужна просто более широкая транспортная сеть — достаточно Core-кластера. Если вам нужна гарантия «сообщение переживёт падение ноды» — нужен JetStream с --replicas 3 (или 5) и понимание, что кворум — это математика, а не настройка, которую можно обойти. Три ноды переживают падение одной, пять — падение двух; чётные размеры избегают по другой причине, чем «они ничего не переживают»: R2 действительно не переживает ни одной потери (кворум 2 из 2), а вот R4 переживает одну (кворум 3 из 4) — ровно как R3, только дороже на целую реплику и без выигрыша, да ещё и встаёт при симметричном разделении сети 2×2. Оттого выбор всегда нечётный.
Дальше в серии: четвёртая статья — supercluster, gateways, leaf nodes и mirror streams, то есть кластеризация поверх кластеризации для geo-распределённых сценариев.
Комментарии