Кластеризация

Высокодоступное развёртывание Meridian на нескольких нодах с peer-to-peer-архитектурой, gossip-протоколом и Raft-координацией бюджетов.

Enterprise

Обзор

Кластеризация 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-readinessPer-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"
    }
  }
}

Поля верхнего уровня

ПолеТипОбязательноеОписание
enabledbooleanДаВключает кластерный режим
peersarray of host:portНетСписок пиров для bootstrap (если пусто — single-node)
regionstringНетРегиональная метка (по умолчанию us-east-1)
node_namestringНетИмя ноды для идентификации (переопределяется env MERIDIAN_NODE_ID)
gossipobjectДаПараметры gossip-протокола
raftobjectНетПараметры Raft для координации бюджетов
discoveryobjectНетAuto-discovery (СКОРО — см. ниже)
pubsub_healthobjectНетPer-node readiness probe

Поля gossip

ПолеТипПо умолчаниюОписание
portinteger7946Порт gossip-коммуникации (UDP+TCP)
config.timeout_secondsinteger10Таймаут health-проб
config.success_thresholdinteger3Сколько успешных проб подряд переводят ноду в healthy
config.failure_thresholdinteger3Сколько неуспешных проб переводят ноду в unhealthy

Порт 7946 — стандарт memberlist для LAN-режима. Если вы измените значение, не забудьте открыть порт во всех сетевых политиках и группах безопасности на обоих протоколах (UDP и TCP).

Идентичность ноды

node_id определяется по такому приоритету:

  1. Env-переменная MERIDIAN_NODE_ID — наивысший приоритет.
  2. Поле cluster_config.node_name.
  3. Сгенерированный 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_portinteger9090gRPC-транспорт Raft
reservation_ttlstring (duration)"60s"TTL для бюджет-резерваций
olric_bind_portinteger3320Olric RESP-сервер (cluster-wide cache)
olric_memberlist_portinteger3322Olric 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

ПолеТипПо умолчаниюОписание
enabledbooleantrue (когда задан peers)Запускает probe
interval_secondsinteger5Период публикации HealthPing
stale_threshold_secondsinteger15Сколько времени без 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: meridian

MERIDIAN_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.target

DNS-записи 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/readyReadiness gate (учитывает pub/sub probe)
GET/internal/liveLiveness 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_score0 означает идеальное состояние; чем выше, тем хуже (рост вызывают unsuspended/dead-ноды или таймауты проб).

Веб-интерфейс

В Meridian UI есть страница Cluster (раздел /workspace/cluster) — read-only-обзор статуса. Конфигурация кластера через UI не редактируется — изменения вносятся в config.json и требуют перезапуска ноды.

На странице отображаются:

  • ModeCluster / Single Node.
  • Active Nodes — число живых нод.
  • Region — региональная метка текущей ноды.
  • Gossip HealthHealthy (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)).

Устранение неполадок


Открытые сетевые порты — справка

ПортПротоколКогда нуженМежду
7946TCP + UDPgossip (memberlist), всегдавсе ноды
9090TCPRaft gRPC, при включённом Raftвсе ноды
3320TCP/UDPOlric RESP-сервервсе ноды
3322TCP/UDPOlric memberlistвсе ноды
8080TCPHTTP API gateway, всегдавнешний LB → ноды

Содержание