Перейти к содержанию

Балансировка нагрузки

Когда у тебя N инстансов API — кто-то должен решить, на какой отправить конкретный запрос. Этот «кто-то» — load balancer. Его выбор и настройка определяют, выживет ли система при росте, отказе ноды или внезапном пике.

L4 vs L7

🧩 Простыми словами. L4 (transport) и L7 (application) — это уровни OSI-модели. Названия пугают, разница простая:

  • L4 LB видит TCP/UDP пакеты. Знает «откуда → куда», IP, порт. Не знает HTTP-путь, не понимает заголовков. Очень быстрый, almost zero overhead.
  • L7 LB парсит HTTP-запрос. Видит method, path, headers, cookies. Может роутить /api/users → service-A, /api/orders → service-B. Дороже по CPU.
graph TB
    subgraph L4
        TCP[TCP packet] --> L4LB[L4 LB<br/>HAProxy TCP, AWS NLB]
        L4LB --> Pod1[Pod 1]
        L4LB --> Pod2[Pod 2]
    end
    subgraph L7
        HTTP[HTTP request] --> L7LB[L7 LB<br/>nginx, envoy, ALB]
        L7LB -->|/api/users| ServA[Service A]
        L7LB -->|/api/orders| ServB[Service B]
        L7LB -->|host=admin.x| Admin[Admin]
    end

⚙️ Под капотом. L4 чаще DSR (Direct Server Return) или просто NAT — десятки миллионов pps на одной ноде. L7 терминирует TCP, парсит HTTP, иногда TLS — на порядок дороже, но даёт path-routing, sticky sessions, header injection, A/B-routing.

💥 Зачем это нужно. L4 — для raw throughput (gRPC pass-through, WebSocket). L7 — для микросервисной маршрутизации, canary deploys, header-based routing.

Свойство L4 L7
Speed Очень быстро Медленнее
HTTP-aware Нет Да
TLS termination Нет (passthrough) Да
Path-based routing Нет Да
Headers / cookies Нет Да
WebSocket / gRPC Прозрачно Нужна поддержка

🛠 В Go-проекте. На фронт обычно nginx/Traefik (L7). Перед ним — облачный LB (AWS NLB, GCP TCP LB) на L4 для DDoS-устойчивости и multi-AZ. Внутри Kubernetes — Service (L4 через kube-proxy) + Ingress (L7 через nginx-ingress или envoy).

Алгоритмы балансировки

Round-robin

Простейший. Каждый следующий запрос — следующему инстансу по кругу.

req1 → A
req2 → B
req3 → C
req4 → A

✅ Дёшево, равномерно при одинаковых инстансах. ❌ Не учитывает текущую нагрузку. Если запрос на A повис на 30 секунд — round-robin всё равно даст ему следующий.

Weighted round-robin

Инстансы с разной мощностью получают разный вес. A:3, B:1, C:1 → A получает 3/5 трафика. Используется при разнородных машинах или canary deploy (canary:1, stable:9).

Least connections

LB смотрит текущее число открытых соединений и шлёт на наименее загруженного.

✅ Хорошо для long-lived connections (gRPC streaming, WebSocket). ❌ LB должен помнить состояние. В кластере LB-инстансов — нужен shared state.

Weighted least connections

Тот же least-conn, но с весами. Дефолт в HAProxy.

IP-hash

server = hash(client_ip) % N. Один клиент всегда на один инстанс. Использовали до Redis-сессий — для sticky session на нативной L4-балансировке.

Consistent hashing

🧩 Простыми словами. Обычный hash-by-key плох при добавлении/удалении ноды: все ключи переcчитываются. Consistent hashing располагает ноды на «кольце» хешей, и при изменении количества нод пересчитывается только малая часть ключей (в среднем 1/N).

graph TD
    H((hash ring)) --- N1[Node 1]
    H --- N2[Node 2]
    H --- N3[Node 3]
    K1[key1] -.idёт по часовой стрелке.-> N2
    K2[key2] -.-> N3
    K3[key3] -.-> N1

Ключевое применение:

  • Cache cluster. При добавлении Redis-ноды большинство ключей не двигаются.
  • Sharding. То же.
  • Sticky service mesh. Linkerd / envoy используют consistent hash для закрепления ключа за инстансом (нужно для in-memory state).

Реализация на Go: github.com/buraksezer/consistent, github.com/lafikl/consistent.

import "github.com/buraksezer/consistent"

cfg := consistent.Config{
    PartitionCount: 271, // простое число > nodes*replicas
    ReplicationFactor: 20, // virtual nodes на физический
    Load: 1.25,
    Hasher: hasher{},
}
c := consistent.New(nil, cfg)
c.Add(node1); c.Add(node2); c.Add(node3)
member := c.LocateKey([]byte("user:42"))

Random / random-two-choices

«Random» — да, тупо случайный. Удивительно, но почти как round-robin для большого числа однородных инстансов. Power of two choices (выбираем случайных два, берём менее загруженного) — почти как least-conn, но без state. Используется в envoy.

📊 Чек-лист выбора алгоритма.

Workload Алгоритм
Stateless API, ровные запросы Round-robin
Long-lived (gRPC, WS) Least connections
Caching tier (Redis, Memcached) Consistent hash
Heterogeneous fleet Weighted RR
Canary deployment Weighted RR / по headers
Cloud-native, low-latency Power of two choices

Sticky sessions

🧩 Простыми словами. «Прибей клиента к одному инстансу». Реализуется через куку JSESSIONID или header. Нужна, если приложение хранит состояние локально.

❌ Антипаттерн в большинстве случаев: stateless приложения масштабируются горизонтально, состояние выносим в Redis/БД.

✅ Иногда оправдано: WebSocket к конкретной shard, in-memory game state.

⚙️ Failure mode. При падении инстанса все его «прибитые» клиенты разом теряют сессии — большая боль, если состояние было в памяти.

Health checks

LB должен знать, какие инстансы живы. Опрашивает каждые N секунд:

  • Active. LB сам ходит на /health или TCP-конект. Простой и стандартный.
  • Passive. Смотрит на статусы реальных запросов: 5xx или таймаут — пометил unhealthy.
  • Combined. Active как минимум, passive как ускоритель.
// типичный /health в Go-сервисе
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    if !db.Healthy() || !redis.Healthy() {
        http.Error(w, "unhealthy", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

📌 Liveness vs Readiness (Kubernetes).

  • Liveness. «Жив ли процесс?» — если нет, перезапустить. Простая проверка.
  • Readiness. «Готов ли принимать трафик?» — если нет, исключить из балансировки. Возвращай fail при прогреве кэша, миграции, downstream outage.

Failover и redundancy

graph TB
    User --> DNS{DNS}
    DNS --> LB1[LB primary<br/>AZ-A]
    DNS --> LB2[LB standby<br/>AZ-B]
    LB1 --> A1[App AZ-A]
    LB1 --> A2[App AZ-A]
    LB2 --> B1[App AZ-B]
    LB2 --> B2[App AZ-B]
    A1 --> DB[(DB primary)]
    B1 --> DBR[(DB replica)]
    DB <-.replication.-> DBR
  • Active-active. Оба LB принимают трафик. Higher capacity, но сложнее координация.
  • Active-passive. Standby ждёт failover (через keepalived / VRRP / DNS-flip). Проще, но capacity = 1.

Failure modes для LB:

  • LB упал — нужен второй LB с floating IP (VRRP) или anycast.
  • App упал — health check отметит, LB исключит. Через 1–10 секунд новые запросы туда не пойдут.
  • DB primary упал — promote replica. Сложнее, см. databases.md.

Геобалансировка

🧩 Простыми словами. Пользователь в Лондоне должен попасть в LB в Лондоне, а не в Сан-Франциско. Решается двумя путями:

DNS-LB (GeoDNS)

DNS-сервер видит исходный IP запроса (по EDNS Client Subnet) и отдаёт IP ближайшего LB. Например, AWS Route 53 latency-based routing.

✅ Работает на любом приложении. ❌ DNS кешируется (TTL 60s минимум) — failover медленный.

Anycast

Один и тот же IP-адрес объявляется из разных датацентров. Сетевой маршрутизатор сам выберет ближайший. Так работают CDN, DNS-серверы Google (8.8.8.8).

✅ Очень быстрый failover (BGP withdraw). ❌ Нужна поддержка от провайдера. Сложно.

CDN

Edge-кэш для статики и иногда динамики. Cloudflare, Fastly, CloudFront, Yandex CDN. Снимает первый hop с твоей инфры. Дешевле, чем покупать второй датацентр.

🔥 Пример: API сервис в проде

graph TB
    User --> CDN[Cloudflare<br/>edge cache]
    CDN --> ALB[AWS ALB<br/>L7, TLS terminate]
    ALB --> Ing[nginx-ingress<br/>L7, K8s]
    Ing --> SvcA[Service api]
    Ing --> SvcB[Service search]
    SvcA --> P1[Pod 1]
    SvcA --> P2[Pod 2]
    SvcA --> P3[Pod 3]

Двухуровневая балансировка: облачный ALB перед K8s, ingress внутри. ALB на L7 делает TLS-termination + WAF, ingress — path-routing внутрь.

❌ Типичные ошибки

  • Один LB без HA → SPOF.
  • Health check на / (фронт) — не отражает state DB. Делай /ready отдельно.
  • Sticky sessions на stateless API → ненужная связанность.
  • Не настроен keepalive upstream → новые TCP-коннекты на каждый запрос.
  • DNS TTL 1 час → failover часами.
  • Long-lived gRPC через L7 LB без поддержки HTTP/2 → деградация до HTTP/1.

🤖 Что спрашивает AI-ментор

  • Когда выбирать L4, а когда L7?
  • В чём разница round-robin и least-connections? Когда какой лучше?
  • Что такое consistent hashing и зачем оно для кэшей?
  • Что такое sticky session и почему это часто плохая идея?
  • Liveness vs readiness probe в Kubernetes — в чём разница?

📊 Уровни глубины

L1. Знаешь, что такое LB, что round-robin делит запросы по кругу. Видел nginx.

L2. Различаешь L4/L7, алгоритмы, написал /health. Понимаешь readiness vs liveness.

L3. Настраивал HAProxy/envoy в проде. Использовал consistent hashing для кэша. Делал failover (active-passive с keepalived). Знаешь BGP/anycast, TLS termination, WAF, mTLS, debugged sticky session bugs.

📝 Подумай

  1. У тебя gRPC сервис с long-lived connections. Какой алгоритм балансировки выберешь и почему?
  2. Зачем нужны virtual nodes (replicas) в consistent hashing?
  3. Health check возвращает 200, но БД мертва. Что не так с проверкой?
Ответ
  1. Round-robin плох: запрос «прилипает» к одному коннекту → один инстанс перегружен. Least-connections или random-two-choices, чтобы балансировать по числу активных стримов. Альтернатива — client-side LB через service mesh (envoy) с правильным policy.
  2. Без виртуальных нод одна физическая нода занимает один сектор кольца — при удалении/добавлении пересчёт неравномерный (одни ноды берут много, другие почти ничего). Virtual nodes (100–1000 per phys node) сглаживают распределение.
  3. Health-проверка не пингует зависимости. Это liveness check, а нужен readiness: проверять db.Ping(), доступность downstream-сервисов. Если один из них умер — /ready возвращает 503, LB исключает под из балансировки.

Дальше: queues.md — асинхронная коммуникация, без которой не выживет ни один большой сервис.