guard/docs/research/envoy.md
loki5512344 cf9608ce5d
Initial commit: Rampart v0.2.0
Multi-layer DDoS protection for Minecraft servers.

- rampart-core: Edge node with XDP/eBPF + Rust L7 filtering
- rampart-manager: REST API with JWT auth, Redis sync
- rampart-cli: CLI tool for operators
- velocity-plugin: Domain check, HMAC verify, server registry, load balancer
- paper-plugin: Auto-registration, heartbeat, HMAC verify
- dashboard: React + Vite web UI for management
2026-07-20 20:53:32 +02:00

7.1 KiB
Raw Blame History

Envoy - EWMA, Circuit Breaker, xDS API

Актуально: v0.5+
Envoy как референс для алгоритмов балансировки.


EWMA балансировщик (как в Envoy)

Почему LEAST_CONN недостаточно

Проблема:
  Сервер A: 50 игроков, TPS 20 (быстрый)
  Сервер B: 48 игроков, TPS 12 (лагающий)
  LEAST_CONN выберет B → плохо

EWMA учитывает реальное время ответа:
  Сервер A: быстро → высокий score → больше игроков
  Сервер B: медленно → низкий score → меньше игроков

Формула

effective_load = rtt_ewma × (active_requests + 1)
rtt_ewma_new   = α × rtt_ewma_old + (1 - α) × rtt_sample
α = 0.95 (decay, параметр сглаживания)

Реализация

// balancer/ewma.rs

use std::sync::atomic::{AtomicU64, Ordering};

pub struct EwmaBackend {
    pub name: String,
    rtt_ewma_us: AtomicU64,    // EWMA в микросекундах
    active: AtomicU64,
}

impl EwmaBackend {
    pub fn new(name: String) -> Self {
        Self {
            name,
            rtt_ewma_us: AtomicU64::new(1000), // стартовое значение 1мс
            active: AtomicU64::new(0),
        }
    }

    pub fn record_rtt(&self, rtt: Duration) {
        let sample = rtt.as_micros() as u64;
        let old = self.rtt_ewma_us.load(Ordering::Relaxed);
        // EWMA: 95% старое + 5% новое измерение
        let new = (old * 95 + sample * 5) / 100;
        self.rtt_ewma_us.store(new, Ordering::Relaxed);
    }

    pub fn effective_load(&self) -> u64 {
        let rtt = self.rtt_ewma_us.load(Ordering::Relaxed);
        let active = self.active.load(Ordering::Relaxed);
        rtt.saturating_mul(active + 1)
    }

    pub fn acquire(&self) { self.active.fetch_add(1, Ordering::Relaxed); }
    pub fn release(&self) { self.active.fetch_sub(1, Ordering::Relaxed); }
}

pub struct EwmaBalancer {
    backends: Vec<Arc<EwmaBackend>>,
}

impl EwmaBalancer {
    pub fn select(&self) -> Option<Arc<EwmaBackend>> {
        self.backends.iter()
            .min_by_key(|b| b.effective_load())
            .cloned()
    }
}

Circuit Breaker

CLOSED → нормальная работа, трафик идёт
  ↓ TPS < 12 или timeout > 3 сек подряд (N раз)
OPEN → сервер выведен из ротации
  ↓ через 30 сек (recovery timeout)
HALF_OPEN → пробный трафик (1 соединение)
  ↓ успешно → CLOSED
  ↓ снова ошибка → OPEN (увеличиваем timeout × 2)
// balancer/circuit_breaker.rs

pub enum State {
    Closed,
    Open { tripped_at: Instant, timeout: Duration },
    HalfOpen,
}

pub struct CircuitBreaker {
    state: State,
    failure_count: u32,
    failure_threshold: u32,  // сколько ошибок до OPEN
}

impl CircuitBreaker {
    pub fn should_route(&mut self) -> bool {
        match &self.state {
            State::Closed => true,

            State::Open { tripped_at, timeout } => {
                if tripped_at.elapsed() >= *timeout {
                    self.state = State::HalfOpen;
                    true
                } else {
                    false
                }
            }

            State::HalfOpen => true,
        }
    }

    pub fn record_success(&mut self) {
        self.failure_count = 0;
        self.state = State::Closed;
    }

    pub fn record_failure(&mut self) {
        self.failure_count += 1;
        if self.failure_count >= self.failure_threshold {
            let timeout = match &self.state {
                State::Open { timeout, .. } => *timeout * 2, // exponential backoff
                _ => Duration::from_secs(30),
            };
            self.state = State::Open {
                tripped_at: Instant::now(),
                timeout: timeout.min(Duration::from_secs(300)), // max 5 мин
            };
        }
    }
}

Health Scoring

pub fn calculate_health_score(server: &ServerEntry) -> f64 {
    let tps_score = (server.tps / 20.0).min(1.0);           // 0..1
    let player_score = 1.0 - (server.online as f64 / server.max_players as f64);
    let mspt_score = (1.0 - server.mspt / 50.0).max(0.0);   // 50ms MSPT = 0 score
    let ram_score = 1.0 - (server.ram_used as f64 / server.ram_max as f64);

    // Взвешенная сумма
    tps_score * 0.40
    + player_score * 0.30
    + mspt_score * 0.20
    + ram_score * 0.10
}

// Балансировщик выбирает по score вместо LEAST_CONN
pub fn select_by_score(servers: &[ServerEntry]) -> Option<&ServerEntry> {
    servers.iter()
        .filter(|s| calculate_health_score(s) > 0.3) // минимальный порог
        .max_by(|a, b| {
            calculate_health_score(a)
                .partial_cmp(&calculate_health_score(b))
                .unwrap()
        })
}

Consistent Hashing (друзья на одном Hub)

// Игроки с одной группой попадают на один Hub
// При добавлении новых Hubs - минимальная миграция игроков

use std::collections::BTreeMap;

pub struct ConsistentHash {
    ring: BTreeMap<u64, String>, // hash → server_name
    vnodes: u32,  // виртуальные ноды (больше = равномернее)
}

impl ConsistentHash {
    pub fn new(vnodes: u32) -> Self {
        Self { ring: BTreeMap::new(), vnodes }
    }

    pub fn add_server(&mut self, name: &str) {
        for i in 0..self.vnodes {
            let key = hash(&format!("{}-{}", name, i));
            self.ring.insert(key, name.to_string());
        }
    }

    pub fn get_server(&self, player_uuid: &Uuid) -> Option<&str> {
        if self.ring.is_empty() { return None; }
        let hash = hash(&player_uuid.to_string());
        // Идём по кольцу вправо от hash
        self.ring.range(hash..)
            .next()
            .or_else(|| self.ring.iter().next())
            .map(|(_, name)| name.as_str())
    }
}

// Применение: для хабов, где важно чтобы друзья были рядом
// Для game серверов - EWMA (важна нагрузка, не стабильность)

xDS API (Envoy паттерн для динамической конфигурации)

Актуально v0.6+ - если нод станет 100+

xDS - протокол от Envoy/Istio для динамической доставки конфигурации нодам. Вместо того чтобы каждая нода поллила Redis - Manager пушит изменения через gRPC stream.

Manager (xDS сервер)
  ↓ gRPC stream (двунаправленный)
Edge ноды / LB ноды (xDS клиенты)

При изменении конфига (новый сервер, новое правило):
  Manager → push → все ноды получают обновление мгновенно
  Нет поллинга, нет задержки