Классический кластер NATS: routes, RAFT и надёжный failover

Два уровня кластеризации NATS: full-mesh routes для Core и RAFT (meta-group + per-stream) для JetStream; кворум, реплики R3, placement и как кластер переживает падение ноды без потери данных

Это третья статья серии «Погружение в NATS». Первая статья разобрала Core NATS — subjects, pub/sub, request/reply; вторая добавила JetStream — persistence, streams, consumers. Обе статьи сознательно оставались на одном узле: один процесс nats-server, один диск, один порт.

Классический кластер NATS: routes, RAFT и надёжный failover

Один узел — это SPOF по определению, и рано или поздно встаёт вопрос: что произойдёт, если этот узел упадёт? Ответ у NATS не единый, и это первое, что стоит понять перед тем, как заводить кластер в проде: слово «кластер» здесь означает две разные вещи одновременно, и их легко перепутать.

В статье

Два уровня кластеризации

Когда говорят «кластер 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

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
Два уровня кластеризации NATS: Core routes (транспорт) и JetStream RAFT (durability) — на одних и тех же трёх нодах

Мост от 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 первым.

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

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
Full-mesh: 3 ноды объявляют друг друга через routes, каждая соединена с каждой

С этого момента 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

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
Meta-group (assignment, все ноды кластера) и per-stream RAFT-группа (данные, только ноды-реплики этого stream)

Нечётность — не рекомендация, а математика кворума. 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

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
Failover R3-стрима: падение лидера n1, RAFT-выборы среди оставшихся реплик, n2 становится новым лидером

В выводе 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-распределённых сценариев.

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

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

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

Комментарии