Кластер

По умолчанию Wippy работает как одна нода. Включение кластера превращает набор нод в единую координированную систему, разделяющую membership, кластерные имена процессов, распределённые блокировки и групповой обмен сообщениями поверх ограниченного ядра консенсуса Raft.

Кластеризация отключена до установки cluster.enabled: true. Всё описанное ниже инертно на одной ноде.

Что даёт кластеризация

  • Membership — каждая нода знает живой состав пиров через gossip с быстрым обнаружением сбоев.
  • Кластерные имена процессов — регистрация процесса под именем, разрешаемым с любой ноды, с выбором гарантий согласованности (см. Именование).
  • Распределённые блокировкиsystem.lock обеспечивает кластерное взаимное исключение с автоматическим освобождением при гибели держателя (см. Распределённые блокировки).
  • Группы процессов — публикация сообщений каждому члену именованной группы на всех нодах (см. Группы процессов).
  • Ядро консенсуса — небольшой, ограниченный Raft-кластер обеспечивает линеаризуемую основу, на которой строятся примитивы именования и блокировок.

Архитектура: ограниченный Raft

Включение каждой ноды в Raft плохо масштабируется: лидер реплицирует каждую запись лога на каждый пир, поэтому стоимость простаивающего лидера растёт с размером кластера. Wippy ограничивает Raft фиксированным ядром и позволяет остальным нодам работать через gossip. Каждая нода занимает одну из трёх ролей в конфигурации Raft:

Роль Количество (по умолчанию) В конфиг. Raft Получает репликацию Голосует
Voter до 5 (max_voters, нечётное) да да да
Standby до 4 (max_standbys) да да нет
Client без ограничений нет нет нет
  • Voters формируют кворум. Записи фиксируются, когда большинство voters подтверждает их. Количество voters всегда нечётное, чтобы большинство было однозначным.
  • Standbys — невотирующие члены, полностью реплицированные и готовые к работе. Когда voter уходит, лидер повышает наиболее приоритетный standby на освободившееся место, и кворум восстанавливается без ожидания новой ноды.
  • Clients — все ноды за пределами voters + standbys. Они не входят в конфигурацию Raft, поэтому лидер никогда не отправляет им записи лога. Они участвуют в gossip и направляют записи к члену Raft. Это сохраняет стоимость лидера постоянной (O(1)) независимо от размера кластера.

Поскольку standbys и clients могут поглотить оставшийся флот, кластер из сотен нод всё равно имеет ядро из 5 voters. Ограничения max_voters/max_standbys и делают дизайн «ограниченным».

Выбор voters

Лидер запускает reconciler, который при каждом изменении membership (с задержкой raft.reconcile_debounce, по умолчанию 2с) вычисляет, какие ноды должны быть voters, и применяет минимальный набор операций promote/demote. Выбор детерминирован — каждая нода выводит один и тот же порядок из одного gossip-вида — и управляется тремя gossip-подсказками:

  • raft.eligible — нода с eligible: false никогда не выбирается voter (используйте для нод, которые должны оставаться client или standby).
  • raft.priority — меньшее значение предпочтительнее при заполнении слотов voter; при равенстве выбирается по ID ноды.
  • failure_domain — voters распределяются по различным доменам (зонам/стойкам) в первую очередь, чтобы потеря одного домена не уничтожила большинство voters.

Операции применяются в порядке, сохраняющем кворум: сначала добавления и повышения, затем понижения, затем удаления.

Membership и gossip

Membership использует SWIM gossip (HashiCorp memberlist). Каждая нода привязывается к gossip-порту (по умолчанию 7946) и непрерывно обменивается небольшими сообщениями с пирами для обнаружения сбоев и распространения метаданных.

Нода присоединяется, указывая на одну или несколько существующих нод:

cluster:
  enabled: true
  name: node-2
  membership:
    join_addrs: "node-1:7946"

Первой ноде не нужны join_addrs — она стартует как seed. Присоединения повторяются с отступом, и нода, оказавшаяся изолированной, периодически пытается переприсоединиться, поэтому нода, перезапущенная с новым IP (обычно в Kubernetes), быстро сходится.

Gossip можно зашифровать общим ключом, встроенным или из файла:

cluster:
  membership:
    secret_file: /etc/wippy/cluster.key

Изменения membership (NodeJoined, NodeLeft, NodeUpdated) — это события, которые запускают Raft bootstrap, reconciliation voters, синхронизацию групп процессов и автоматическую очистку имён, принадлежащих ушедшей ноде.

Bootstrap

Начальный кластер формируется через gossip, а не по статическому списку пиров. Это следует паттерну bootstrap_expect из Consul/Nomad: каждой стартующей ноде сообщается ожидаемое количество нод, и они ждут, пока не увидят друг друга, прежде чем вместе сформировать кворум.

bootstrap_expect Поведение
0 Никогда не self-bootstrap; только присоединяться к уже существующему кластеру
1 Одна нода; немедленный bootstrap с собой как единственным voter
N Ждать, пока N eligible пиров стабильно видны в gossip, затем все выводят одинаковый список voters и формируют кластер

Для N-нодового bootstrap установите одинаковый bootstrap_expect: N на каждой начальной ноде. Каждая объявляет статус «pre-bootstrap» в gossip; как только ровно N таких пиров видны в течение короткого окна стабильности, каждая нода независимо вычисляет идентичный отсортированный набор voters и формирует кластер. Окно стабильности предотвращает преждевременный bootstrap из-за кратковременного частичного вида.

Ноды, запускающиеся позже, видят уже сформированный кластер и полностью пропускают bootstrap — reconciler лидера добавляет их как voters или standbys.

Ядро консенсуса Raft

Ядро консенсуса бездисковое: логи и снимки Raft хранятся только в памяти, поэтому нет каталога данных для подготовки. При перезапуске нода переприсоединяется к gossip и воспроизводит состояние от пиров. Это намеренно устраняет режимы отказа «персистентность против кворума», которые вводит дисковый Raft, и соответствует модели in-memory систем координации (Erlang global, Akka distributed data). Компромисс: долговечность кластера определяется живым кворумом, а не диском — см. Восстановление.

Raft не открывает собственного порта прослушивания. Он работает по internode-мешу — тем же TCP-соединениям, что используются для relay-трафика между нодами — с мультиплексированием через yamux. Internode-порт выбирается автоматически при запуске (диапазон 7950-7959, затем эфемерный), фиксируется и объявляется в gossip для обеспечения доступности от пиров. Единственный порт, который обычно нужно открыть — gossip.

FSM Raft хранит глобальный реестр имён: активные привязки name -> PID и незавершённые strong-резервирования. Именно это читают и пишут описанные ниже примитивы именования.

Именование и области имён

Процесс может быть зарегистрирован под именем и достигаться по этому имени вместо raw PID. Ключевое решение — область (scope), определяющая гарантию согласованности. Доступны четыре области от самой дешёвой/слабой до сильнейшей:

Область Основа Видимость Гарантия
Local карта на ноде только эта нода Мгновенно, локально; без координации
Eventual gossip CRDT кластер Eventually consistent; сходится после раундов gossip
Consistent Raft кластер Линеаризуемые записи; уникальный синглтон в кластере
Strong Raft + подтверждение всех нод кластер Consistent плюс каждая живая нода подтверждает до активации имени

Как выбирать:

  • Local — имена, значимые только на одной ноде (вспомогательный per-node процесс). Освобождается при выходе процесса. Нулевая стоимость.
  • Eventual — общекластерные имена сервисов, групп и присутствия, где кратковременное устаревание допустимо. Набор привязок полностью реплицируется на каждую ноду, поэтому подходит для ограниченного пространства имён, а не для одного имени на сущность с высокой кардинальностью, такую как процесс на сессию (адресуйте такие напрямую по PID). Когда два источника регистрируют одно имя, разрешение конфликта выбирает победителя, а проигравший процесс получает событие отмены (process.event.CANCEL) с причиной name revoked: <name>; он продолжает работу и может перерегистрироваться. Имена освобождаются при уходе ноды-владельца.
  • Consistent — стандартный выбор для кластерных именованных синглтонов. Побеждает первый пишущий: повторная регистрация того же имени на другой PID завершится ошибкой «already exists» и вернёт текущего владельца. Запись требует кворума, поэтому при разделении в меньшинстве она зависает. Чтение идёт с локальной реплики Raft и может отставать от записи на несколько миллисекунд.
  • Strong — небольшой набор control-plane синглтонов, где даже мгновенное устаревшее чтение опасно. Поверх гарантии Consistent регистрация открывает резервирование, которое должна подтвердить каждая живая нода до того, как имя станет авторитетным; любая нода, уже имеющая конфликтующую привязку, немедленно отклоняет запрос. Если дедлайн истекает до подтверждения всех нод, регистрация истекает и сообщает, какие ноды отсутствовали. Это основа распределённых блокировок.

Имена освобождаются автоматически: Local — при выходе процесса; Consistent и Strong — при выходе процесса (через мониторинг топологии) и при уходе ноды; Eventual — при уходе ноды. Разрешение для обмена сообщениями (process.send, process.terminate и т.д.) просматривает плоскости по порядку — Consistent, затем Eventual, затем Local — поэтому Consistent-имя перекрывает Eventual с той же строкой.

Lua-интерфейс именования находится на process.registry (register/lookup/unregister с областью) — см. справочник Process.

Группы процессов

Группы процессов — кластерный publish/subscribe и средство управления членством по образцу Erlang pg. Процесс вступает в именованную группу; широковещательное сообщение расходится по internode-мешу к членам группы на всех нодах и доставляется по принципу best-effort. Группы eventually consistent и независимы от Raft — для выбора получателей они используют представление членства из gossip — поэтому продолжают работать даже пока ядро консенсуса сходится.

Типичные операции: вступление/выход из группы, broadcast всем членам (или только локальным), список членов и мониторинг группы на события вступления/выхода. При присоединении новой ноды группы согласовывают членство через прямое handshake, а фоновый anti-entropy цикл устраняет расхождения со временем.

См. Группы процессов для Lua API и тип записи pg.scope для конфигурации.

Распределённые блокировки

system.lock — кластерное взаимное исключение, построенное непосредственно на области Strong. Захват блокировки регистрирует её имя в области Strong на вызывающий процесс; освобождение снимает регистрацию. Поскольку Strong требует подтверждения каждой живой ноды, в кластере может существовать не более одного держателя.

local ok, err = system.lock.acquire("orders.migration")
if ok then
  -- критическая секция: только один держатель в кластере
  system.lock.release("orders.migration")
end

Захват fail-fast (неблокирующий): если блокировка уже занята, возврат немедленно, поэтому вызывающие реализуют собственный retry/backoff. Блокировка автоматически освобождается при выходе держателя или уходе его ноды — зависших блокировок нет. См. справочник System для точных сигнатур.

Конфигурация

Полный справочник по ключам — в разделе Конфигурация. Минимальные формы:

Одна нода (разработка):

cluster:
  enabled: true
  name: dev
  raft:
    bootstrap_expect: 1

Трёхнодовый voting-кластер:

cluster:
  enabled: true
  name: node-1
  failure_domain: us-east-1a
  membership:
    join_addrs: "node-2:7946,node-3:7946"
    secret_file: /etc/wippy/cluster.key
  raft:
    bootstrap_expect: 3

Только gossip-клиент (присоединяется для именования/обмена, никогда не запускает Raft):

cluster:
  enabled: true
  name: edge-7
  membership:
    join_addrs: "node-1:7946,node-2:7946"
  raft:
    role: client

Порты

Назначение Порт Протокол Ключ конфигурации
Gossip (membership) 7946 TCP + UDP cluster.membership.bind_port
Internode-меш (relay + Raft) авто TCP cluster.internode.bind_port

Отдельного Raft-порта нет — Raft мультиплексируется по internode-мешу. Internode-порт назначается автоматически и объявляется через gossip, поэтому только gossip-порт требует предсказуемого открытия.

Наблюдаемость

Здоровье кластера доступно через стандартный эндпоинт Prometheus и через проверки готовности.

Ключевые метрики:

Метрика Значение
raft_state 0 = follower, 1 = candidate, 2 = leader
raft_term Текущий term Raft; быстрый рост сигнализирует о чехарде выборов
raft_voters / raft_non_voters Живые voters и standbys в конфигурации
raft_leader_changes_total Смены лидера; должны быть близки к нулю в здоровом кластере
raft_voter_churn_burst_total Всплески операций добавления/удаления voters; устойчивый churn — тревожный сигнал
gossip_members{state} Количество по состоянию (alive/suspect/dead/left)
gossip_convergence_seconds Время между gossip-событиями

Встроенные проверки готовности (подключены к liveness-эндпоинту):

  • gossip — здоров, пока gossip health score ноды остаётся низким, с окном grace при запуске, чтобы переприсоединяющаяся нода не была убита преждевременно.
  • raft last-contact — follower-voter сообщает об ошибке, если долго не слышал лидера; standby терпит более долгий разрыв; лидеры всегда проходят.
  • broadcast группы процессов — ошибка, если группа не видит broadcast-трафика длительное время, что сигнализирует о заблокированном event loop или устойчивом разделении.

Восстановление и режимы отказа

Поскольку ядро консенсуса бездисковое, долговечность обеспечивается живым кворумом, а не диском. Практические правила:

  • Держите большинство voters живыми. При 5 voters вы выдерживаете 2 одновременных отказа; standbys повышаются для заполнения освободившихся слотов. При потере большинства записи (новые Consistent/Strong-регистрации и захваты блокировок) зависают до восстановления кворума. Существующие имена и поиск продолжают обслуживаться с локальных реплик.
  • Лидер проактивно исключает voter, который одновременно молчит по heartbeat и мёртв по gossip, чтобы мёртвый voter не блокировал кворум постоянно, пока повышается standby.
  • Для восстановления кластера, потерявшего кворум, перезапустите отказавшие ноды. Они переприсоединяются к gossip, и выжившие члены принимают их обратно. Распределение voters по failure_domain — именно то, что предотвращает потерю кворума из-за отказа одной зоны.

Смотрите также