Кластеризация
Высокодоступное развёртывание Meridian на нескольких нодах с peer-to-peer-архитектурой, gossip-протоколом и Raft-координацией бюджетов.
Обзор
Кластеризация Meridian обеспечивает высокую доступность через peer-to-peer-архитектуру: все ноды равноправны, обмениваются состоянием через gossip-протокол на базе HashiCorp memberlist, а строгая координация бюджетов реализована поверх Raft.
Кластер формируется из статического списка пиров, заданного в cluster_config.peers. Автоматическое service discovery в текущей версии не реализовано — см. раздел Service discovery.
Зачем нужна кластеризация
| Задача | Влияние без кластера | Решение в кластере |
|---|---|---|
| Single point of failure | Полный простой при падении единственной ноды | Распределённая архитектура с автоматическим failover |
| Пиковая нагрузка | Деградация под нагрузкой | Динамическое распределение трафика по нодам |
| Rate limit'ы провайдеров | Throttling и сбой обслуживания | Sharded rate limits — общий лимит делится на число нод |
| Региональные задержки | Высокий latency для удалённых пользователей | Гео-распределение с локальной обработкой |
| Окна обслуживания | Простой при апдейтах | Rolling-обновления без простоя |
Ключевые возможности
| Возможность | Описание |
|---|---|
| Peer-to-peer-сеть | Все ноды равноправны, нет master/slave |
| Gossip-протокол | Обмен метаданными и состоянием через memberlist |
| Raft для бюджетов | Строгая координация резерваций на основе go.etcd.io/raft |
| Sharded rate limits | Лимит провайдера автоматически шардится между нодами |
| PubSub-readiness | Per-node проба готовности через pub/sub-канал |
| Read-only UI | Страница статуса кластера в веб-интерфейсе |
Архитектура
P2P-сеть и gossip
Каждая нода:
- Принимает список пиров из
cluster_config.peersи подключается к ним черезmemberlist.Join. - Передаёт делту состояния (метаданные, события) по gossip-протоколу через UDP/TCP на gossip-порту.
- Участвует в Raft-кворуме для координации бюджетов (если задан
cluster_config.raft).
Если в peers не задано ни одного адреса или все пиры недоступны — нода работает в single-node mode без ошибок.
Что синхронизируется через gossip
- Метаданные нод (
node_id, регион). - Инвалидации конфигурации (
_invalidate:*ключи) — триггерят hot-reload на других нодах. - Записи в распределённом KV-store с разрешением конфликтов Last-Write-Wins (LWW) по wall clock.
LWW использует системные часы. На всех нодах кластера обязательно должен быть синхронизированный NTP — иначе resolve конфликтов даст некорректный результат.
Минимальный размер кластера
Рекомендация: не менее 3 нод для production-окружений и любых сценариев, где требуется кворум Raft.
| Размер | Отказоустойчивость | Сценарий |
|---|---|---|
| 3 ноды | 1 падение | Базовый production |
| 5 нод | 2 падения | Средний production |
| 7+ нод | 3+ падений | Большие enterprise-инсталляции |
Базовая конфигурация
Минимальный пример
{
"cluster_config": {
"enabled": true,
"region": "eu-central-1",
"peers": [
"node-1.meridian.internal:7946",
"node-2.meridian.internal:7946",
"node-3.meridian.internal:7946"
],
"gossip": {
"port": 7946,
"config": {
"timeout_seconds": 10,
"success_threshold": 3,
"failure_threshold": 3
}
},
"raft": {
"grpc_port": 9090,
"reservation_ttl": "60s"
}
}
}Поля верхнего уровня
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
enabled | boolean | Да | Включает кластерный режим |
peers | array of host:port | Нет | Список пиров для bootstrap (если пусто — single-node) |
region | string | Нет | Региональная метка (по умолчанию us-east-1) |
node_name | string | Нет | Имя ноды для идентификации (переопределяется env MERIDIAN_NODE_ID) |
gossip | object | Да | Параметры gossip-протокола |
raft | object | Нет | Параметры Raft для координации бюджетов |
discovery | object | Нет | Auto-discovery (СКОРО — см. ниже) |
pubsub_health | object | Нет | Per-node readiness probe |
Поля gossip
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
port | integer | 7946 | Порт gossip-коммуникации (UDP+TCP) |
config.timeout_seconds | integer | 10 | Таймаут health-проб |
config.success_threshold | integer | 3 | Сколько успешных проб подряд переводят ноду в healthy |
config.failure_threshold | integer | 3 | Сколько неуспешных проб переводят ноду в unhealthy |
Порт 7946 — стандарт memberlist для LAN-режима. Если вы измените значение, не забудьте открыть порт во всех сетевых политиках и группах безопасности на обоих протоколах (UDP и TCP).
Идентичность ноды
node_id определяется по такому приоритету:
- Env-переменная
MERIDIAN_NODE_ID— наивысший приоритет. - Поле
cluster_config.node_name. - Сгенерированный UUID (только когда
peersпуст и Raft не используется).
Если задан cluster_config.raft, node_id обязан совпадать с одним из hostname в списке peers. Иначе сервер откажется стартовать с ошибкой:
cluster_config: resolved node_id ... does not match any hostname in cluster_config.peers.
Установите MERIDIAN_NODE_ID или node_name в hostname текущей ноды.
Координация бюджетов через Raft
Для строгой согласованности бюджетов (виртуальных ключей, команд, заказчиков) Meridian использует Raft-консенсус. Резервации проходят через лидера Raft-шарда, что исключает race condition при одновременных запросах из разных нод.
Поля raft
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
grpc_port | integer | 9090 | gRPC-транспорт Raft |
reservation_ttl | string (duration) | "60s" | TTL для бюджет-резерваций |
olric_bind_port | integer | 3320 | Olric RESP-сервер (cluster-wide cache) |
olric_memberlist_port | integer | 3322 | Olric memberlist (внутренний gossip Olric) |
Raft-координация активна только когда одновременно заданы cluster_config.raft и Postgres-DSN (через config_store.type: postgres). В файловом config-store режиме Raft не используется.
Дополнительные сетевые требования при включённом Raft:
- TCP
9090— между всеми нодами (gRPC Raft). - TCP/UDP
3320,3322— между всеми нодами (Olric).
PubSub readiness probe
Меридиан-ноды публикуют сообщения HealthPing на канале _pubsub_health и переключают одноразовый readiness-gate, когда услышат каждого ожидаемого пира. Гейт питает поле cluster.pubsub в /health и решает, отдавать ли 200 OK от /internal/ready.
Поля pubsub_health
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
enabled | boolean | true (когда задан peers) | Запускает probe |
interval_seconds | integer | 5 | Период публикации HealthPing |
stale_threshold_seconds | integer | 15 | Сколько времени без HealthPing от пира помечает его stale в /health |
{
"cluster_config": {
"pubsub_health": {
"enabled": true,
"interval_seconds": 5,
"stale_threshold_seconds": 15
}
}
}Service discovery (В планах)
В текущей версии автоматическое service discovery не реализовано. Любая попытка задать cluster_config.discovery.enabled: true приведёт к ошибке старта:
service discovery is not implemented, use static peers.
Используйте статический список пиров (cluster_config.peers) — для всех сценариев, включая Kubernetes и облачные развёртывания (через DNS-hostname'ы и StatefulSet-ы).
Планы
| Механизм | Статус |
|---|---|
| etcd | В планах |
| Kubernetes API | В планах |
| Consul | не запланировано |
Если требуется dynamic discovery — следите за релизными нотами или используйте внешний механизм (например, шаблонизатор конфигурации, который перегенерирует peers на изменении топологии).
Шаблоны развёртывания
Docker Compose
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: meridian
POSTGRES_USER: meridian
POSTGRES_PASSWORD: change_me
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- meridian-net
meridian-1:
image: ghcr.io/neria-cloud/meridian:latest
hostname: meridian-1
environment:
MERIDIAN_NODE_ID: meridian-1
volumes:
- ./data-1:/data
ports:
- "8081:8080"
networks:
- meridian-net
command: ["-app-dir", "/data", "-host", "0.0.0.0", "-port", "8080"]
meridian-2:
image: ghcr.io/neria-cloud/meridian:latest
hostname: meridian-2
environment:
MERIDIAN_NODE_ID: meridian-2
volumes:
- ./data-2:/data
ports:
- "8082:8080"
networks:
- meridian-net
command: ["-app-dir", "/data", "-host", "0.0.0.0", "-port", "8080"]
meridian-3:
image: ghcr.io/neria-cloud/meridian:latest
hostname: meridian-3
environment:
MERIDIAN_NODE_ID: meridian-3
volumes:
- ./data-3:/data
ports:
- "8083:8080"
networks:
- meridian-net
command: ["-app-dir", "/data", "-host", "0.0.0.0", "-port", "8080"]
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- meridian-1
- meridian-2
- meridian-3
networks:
- meridian-net
volumes:
postgres_data:
networks:
meridian-net:
driver: bridgeВ каждом из каталогов ./data-N/config.json укажите ту же cluster_config со списком всех трёх hostname'ов:
{
"cluster_config": {
"enabled": true,
"region": "local",
"peers": [
"meridian-1:7946",
"meridian-2:7946",
"meridian-3:7946"
],
"gossip": {
"port": 7946,
"config": {
"timeout_seconds": 10,
"success_threshold": 3,
"failure_threshold": 3
}
},
"raft": {
"grpc_port": 9090,
"reservation_ttl": "60s"
}
},
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "postgres",
"port": "5432",
"user": "meridian",
"password": "change_me",
"db_name": "meridian",
"ssl_mode": "disable"
}
}
}Kubernetes (StatefulSet)
Для Kubernetes используйте StatefulSet с headless-сервисом — это даёт стабильные DNS-имена meridian-0.meridian-cluster.<namespace>.svc.cluster.local, которые попадают прямо в peers.
apiVersion: v1
kind: ConfigMap
metadata:
name: meridian-config
namespace: meridian
data:
config.json: |
{
"cluster_config": {
"enabled": true,
"region": "eu-central-1",
"peers": [
"meridian-0.meridian-cluster.meridian.svc.cluster.local:7946",
"meridian-1.meridian-cluster.meridian.svc.cluster.local:7946",
"meridian-2.meridian-cluster.meridian.svc.cluster.local:7946"
],
"gossip": {
"port": 7946,
"config": {
"timeout_seconds": 10,
"success_threshold": 3,
"failure_threshold": 3
}
},
"raft": {
"grpc_port": 9090,
"reservation_ttl": "60s"
}
},
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "postgres.meridian.svc.cluster.local",
"port": "5432",
"user": "meridian",
"password": "change_me",
"db_name": "meridian",
"ssl_mode": "require"
}
}
}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: meridian
namespace: meridian
spec:
serviceName: meridian-cluster
replicas: 3
selector:
matchLabels:
app: meridian
template:
metadata:
labels:
app: meridian
spec:
containers:
- name: meridian
image: ghcr.io/neria-cloud/meridian:latest
args: ["-app-dir", "/data", "-host", "0.0.0.0", "-port", "8080"]
env:
- name: MERIDIAN_NODE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- containerPort: 8080
name: http
- containerPort: 7946
name: gossip-tcp
protocol: TCP
- containerPort: 7946
name: gossip-udp
protocol: UDP
- containerPort: 9090
name: raft-grpc
volumeMounts:
- name: config
mountPath: /data
subPath: config.json
livenessProbe:
httpGet:
path: /internal/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /internal/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config
configMap:
name: meridian-config
items:
- key: config.json
path: config.json
---
apiVersion: v1
kind: Service
metadata:
name: meridian-cluster
namespace: meridian
spec:
clusterIP: None
selector:
app: meridian
ports:
- port: 7946
name: gossip-tcp
protocol: TCP
- port: 7946
name: gossip-udp
protocol: UDP
- port: 9090
name: raft-grpc
---
apiVersion: v1
kind: Service
metadata:
name: meridian
namespace: meridian
spec:
type: LoadBalancer
selector:
app: meridian
ports:
- port: 80
targetPort: 8080
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: meridian-pdb
namespace: meridian
spec:
minAvailable: 2
selector:
matchLabels:
app: meridianMERIDIAN_NODE_ID берётся из metadata.name пода (meridian-0, meridian-1, meridian-2) и совпадает с hostname'ом в peers.
Bare-metal / VM (systemd)
# /etc/meridian/config.json
{
"cluster_config": {
"enabled": true,
"region": "eu-central-1",
"peers": [
"meridian-1.internal:7946",
"meridian-2.internal:7946",
"meridian-3.internal:7946"
],
"gossip": {
"port": 7946,
"config": {
"timeout_seconds": 10,
"success_threshold": 3,
"failure_threshold": 3
}
}
}
}# /etc/systemd/system/meridian.service
[Unit]
Description=Meridian Gateway
After=network.target
[Service]
Type=simple
User=meridian
Group=meridian
Environment="MERIDIAN_NODE_ID=meridian-1.internal"
ExecStart=/usr/local/bin/meridian -app-dir /var/lib/meridian -host 0.0.0.0 -port 8080
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/meridian
[Install]
WantedBy=multi-user.targetDNS-записи meridian-{1,2,3}.internal должны разрешаться в IP всех трёх нод.
Управление и мониторинг
API
| Метод | Эндпоинт | Назначение |
|---|---|---|
GET | /api/cluster/status | Текущий статус кластера: режим, активные ноды, gossip health |
POST | /api/cluster/budget-syncer/trigger | Внеплановый запуск BudgetSyncer (минует периодический ticker) |
GET | /api/cluster/shards | Список Raft-шардов |
POST | /api/cluster/shards | Создание Raft-шарда |
GET | /api/cluster/shards/{shard_id} | Детали Raft-шарда |
DELETE | /api/cluster/shards/{shard_id} | Удаление Raft-шарда |
GET | /health | Сводный health, в т. ч. cluster.pubsub |
GET | /internal/ready | Readiness gate (учитывает pub/sub probe) |
GET | /internal/live | Liveness probe |
Пример ответа /api/cluster/status:
{
"enabled": true,
"mode": "cluster",
"active_nodes": 3,
"this_node": {
"node_id": "meridian-1.internal",
"region": "eu-central-1",
"gossip_port": 7946
},
"members": [
{
"node_id": "meridian-1.internal",
"address": "10.0.1.10:7946",
"region": "eu-central-1",
"state": "alive",
"is_self": true
},
{
"node_id": "meridian-2.internal",
"address": "10.0.1.11:7946",
"region": "eu-central-1",
"state": "alive",
"is_self": false
},
{
"node_id": "meridian-3.internal",
"address": "10.0.1.12:7946",
"region": "eu-central-1",
"state": "alive",
"is_self": false
}
],
"config": {
"gossip_port": 7946,
"peers": [
"meridian-1.internal:7946",
"meridian-2.internal:7946",
"meridian-3.internal:7946"
],
"region": "eu-central-1",
"timeout_seconds": 10
},
"gossip_health": {
"health_score": 0,
"num_members": 3
}
}gossip_health.health_score — 0 означает идеальное состояние; чем выше, тем хуже (рост вызывают unsuspended/dead-ноды или таймауты проб).
Веб-интерфейс
В Meridian UI есть страница Cluster (раздел /workspace/cluster) — read-only-обзор статуса. Конфигурация кластера через UI не редактируется — изменения вносятся в config.json и требуют перезапуска ноды.
На странице отображаются:
- Mode —
Cluster/Single Node. - Active Nodes — число живых нод.
- Region — региональная метка текущей ноды.
- Gossip Health —
Healthy(0) или score. - Node ID — идентификатор текущей ноды.
- Cluster Nodes — таблица всех нод (Node ID, Address, Region, State).
- Configuration — порт gossip, probe timeout, список пиров, conflict resolution (
Last-Write-Wins (wall clock)), rate-limit стратегия (Sharded (limit / node_count)).
Устранение неполадок
Открытые сетевые порты — справка
| Порт | Протокол | Когда нужен | Между |
|---|---|---|---|
7946 | TCP + UDP | gossip (memberlist), всегда | все ноды |
9090 | TCP | Raft gRPC, при включённом Raft | все ноды |
3320 | TCP/UDP | Olric RESP-сервер | все ноды |
3322 | TCP/UDP | Olric memberlist | все ноды |
8080 | TCP | HTTP API gateway, всегда | внешний LB → ноды |
Семантическое кеширование
Интеллектуальное кэширование ответов на основе семантической близости. Снижает стоимость и задержку за счёт выдачи закэшированных ответов на семантически близкие запросы.
Установка Enterprise версии
Запуск Meridian Enterprise из Docker-образа, подключение лицензии и проверка корректного старта.