29. Балансировка нагрузки. L4, L7, Round Robin, Least Connections

Балансировка нагрузки: зачем Go-разработчику понимать инфраструктуру

Для разработчика на PHP или классической Java балансировщик часто выглядит как «чёрный ящик», который просто раздает запросы между воркерами или серверами приложений. В Go модель конкурентности фундаментально меняет эту парадигму. Каждый HTTP-запрос в Go обрабатывается в изолированной горутине (гортине), которая ожидает I/O через netpoll (epoll/kqueue).

Представьте себе операционный зал крупного банка с десятью окнами кассиров. Если администратор в зале направляет посетителей строго по очереди (первого — в окно №1, второго — в окно №2...), а кассир в первом окне внезапно сталкивается со сложным оформлением ипотеки на 45 минут, то следующие клиенты окна №1 окажутся в ловушке многочасового ожидания, в то время как соседние кассиры будут скучать.

Балансировщик нагрузки (Load Balancer, LB) напрямую управляет жизненным циклом ваших горутин:

  1. Он решает, когда создать новое соединение (и, следовательно, новую гортину).
  2. Он управляет временем жизни соединения через Keep-Alive и health-checks.
  3. Он может принудительно разрывать соединения, что приводит к EOF на стороне Go и требует корректной обработки ошибок.

Понимание того, как данные проходят через L4/L7 уровни, почему одни алгоритмы быстрее других и как Go взаимодействует с сетевым стеком, критично для проектирования отказоустойчивых и высокопроизводительных сервисов.


L4 и L7: архитектура и под капотом

Балансировщики делятся на два основных класса в зависимости от уровня сетевой модели, на котором они принимают решения.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Client["Клиентский трафик (TCP/TLS)"]:::network --> LB{"Тип балансировщика"}

    subgraph L4Path ["Layer 4 (Transport: IP + Port)"]
        LB -- "L4" --> L4Engine["Ядро Linux: conntrack / nftables / LVS / DPDK / XDP"]
        L4Engine -->|"Пакетная пересылка (SNAT/DNAT)"| B1["Go Pod 1"]
        L4Engine -->|"Без терминации TLS"| B2["Go Pod 2"]
    end

    subgraph L7Path ["Layer 7 (Application: HTTP / gRPC)"]
        LB -- "L7" --> L7Engine["Userspace Proxy (Nginx / Envoy / HAProxy)"]
        L7Engine -->|"TLS Termination + HTTP Routing"| B3["Go Pod 3"]
        L7Engine -->|"Headers / Cookies / Path"| B4["Go Pod 4"]
    end
  

Layer 4 (Transport Layer)

Работает с IP-адресами и портами (TCP/UDP). Не читает полезную нагрузку.

  • Как работает: Использует conntrack в ядре Linux или полностью обходит ядро через DPDK/eBPF/XDP. Решение о маршрутизации принимается на основе заголовков TCP/IP.
  • Производительность: Максимальная. Обработка одного пакета занимает микросекунды. Минимум контекстных переключений (context switches). Балансировщик выступает как регулировщик на скоростном шоссе, распределяя машины по полосам без досмотра содержимого багажника.
  • Особенности: Не поддерживает TLS termination, не может маршрутизировать по URL/заголовкам. Требует, чтобы бэкенды принимали прямые TCP-соединения.

Layer 7 (Application Layer)

Работает с содержимым протокола (HTTP, gRPC, TLS).

  • Как работает: Полностью пересобирает TCP-сегменты в userspace. Прокси (например, 28. nginx, Envoy и HAProxy. Как работают современные прокси) держит открытым соединение до клиента, создает новое до бэкенда и пересылает байты. Требует буферизации, парсинга заголовков и управления состоянием.
  • Производительность: Ниже L4 из-за аллокаций памяти под буферы, парсинга и дополнительных syscall (read/write). Современные прокси (Envoy, HAProxy) оптимизируют это через zero-copy и memory pools.
  • Особенности: Включает WAF, rate-limiting, аутентификацию, перекодирование протоколов, sticky-sessions.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Client["Клиент (TCP/TLS)"]:::entry -->|"SYN, ACK, Payload"| LB["Балансировщик"]
    LB -->|"Решение на основе IP:Port"| L4["L4 LB (conntrack/DPDK)"]
    LB -->|"Решение на основе HTTP-заголовков"| L7["L7 LB (userspace proxy)"]
    L4 -->|"Проксирование пакетов"| Backend["Бэкенд (Go Service)"]
    L7 -->|"Проксирование стримов/буферов"| Backend
    Backend -->|"Ответы"| LB
    LB -->|"Ответы"| Client
  

Алгоритмы распределения трафика

Выбор алгоритма определяет нагрузку на CPU, память и предсказуемость latency вашего сервиса.

Round Robin (RR)

Отдает каждый новый запрос следующему серверу в цикле.

  • Плюсы: Stateless, минимальные накладные расходы на CPU.
  • Минусы: Игнорирует текущую загрузку. Если один бэкенд обрабатывает тяжелые запросы (например, генерация PDF), он станет «узким горлышком», пока RR продолжит слать туда новые запросы.
  • Go-специфика: Идеален для stateless-сервисов с однородной нагрузкой. Не требует отслеживания состояния в LB.

Least Connections (LC)

Отдает запрос серверу с наименьшим количеством активных соединений.

  • Плюсы: Адаптивен к разнице во времени обработки запросов. Предотвращает перегрузку «медленных» нод.
  • Минусы: Stateful. Требует хранения таблицы соединений в памяти LB. При масштабировании таблицы conntrack могут возникать hotspots на CPU из-за атомарных операций инкремента/декремента счетчиков.
  • Go-специфика: Критически важен, если ваш сервис выполняет CPU-bound или I/O-bound операции с разной длительностью. Go-сервер с net/http по умолчанию держит соединения открытыми (Keep-Alive), поэтому «активное соединение» в LB часто означает «несколько ожидающих горутин».

Weighted Round Robin / Least Connections

Добавляет коэффициент веса (capacity) каждой ноде. Позволяет учитывать разное железо в кластере (например, ноды с 8 ядрами получают в два раза больше запросов, чем ноды с 4 ядрами).

Consistent Hashing (Последовательный хеш)

Маршрутизирует запросы к одной ноде на основе хеша от ключа (например, user_id или session_id).

  • Механизм: Использует виртуальное кольцо (virtual nodes). При добавлении/удалении ноды меняется только ~1/N часть трафика, а не 100% (как при простом хешировании hash(key) % N).
  • Применение: Кеширование, sticky-sessions, распределение по tenants.
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph HashRing ["Кольцо Consistent Hashing с Virtual Nodes"]
        Ring["Виртуальное кольцо хэшей: [0 .. 2^32-1]"]
        N1A["Node A (vnode 1)"]
        N2A["Node B (vnode 1)"]
        N1B["Node A (vnode 2)"]
        N2B["Node B (vnode 2)"]
        Key["hash(user_id=42)"]

        Key -.->|"По часовой стрелке"| N1B
    end
  

Go и балансировщики: специфика взаимодействия

Go-рантайм и net/http спроектированы с упором на высокую степень конкурентности и эффективное управление соединениями. Балансировщик становится частью этого цикла.

1. Keep-Alive и пулы соединений

По умолчанию http.Server в Go использует persistent-соединения. Это означает, что один TCP-канал может обслуживать десятки запросов последовательно в рамках одной горутины.

  • Для LB: Это снижает нагрузку на CPU балансировщика (меньше SYN/ACK пакетов) и улучшает latency (нет рукопожатий TLS/TCP).
  • Настройка: На стороне сервера время жизни простаивающего keep-alive-соединения задаёт http.Server.IdleTimeout (если он не задан, используется ReadTimeout; без обоих соединения не закрываются по простою). На стороне Go-клиента (http.Transport) важно ограничивать IdleConnTimeout (90 с — значение http.DefaultTransport; у нового http.Transport по умолчанию лимита нет) и MaxIdleConnsPerHost (по умолчанию 2), чтобы избежать утечки соединений при отключении бэкендов.

2. Graceful Shutdown и draining

При деплое или масштабировании LB должен перестать слать трафик на ноду и подождать завершения текущих запросов.

sequenceDiagram
    autonumber
    participant LB as Load Balancer
    participant Srv as Go Service (Pod)

    Note over Srv: Получен сигнал SIGTERM от Kubernetes
    Srv->>LB: Health Check возвращает 503 Service Unavailable (или Draining)
    Note over LB: Исключение ноды из upstream-пула для новых запросов!

    Note over Srv: Вызов srv.Shutdown(ctx)
    Note over Srv: 1. Закрытие сокета Accept()<br/>2. Ожидание завершения активных горутин
    Srv-->>LB: Завершение ответов на in-flight запросы
    Note over Srv: Все горутины завершены, сокеты закрыты
    Note over Srv: Выход процесса с кодом 0
  
  • В Go: http.Server.Shutdown(ctx) не убивает горутины. Он отключает Accept() на сокете и ждет завершения уже запущенных обработчиков.
  • Синхронизация: Таймауты LB (drain timeout) должны быть строго больше таймаута http.Server.Shutdown(). Иначе LB закроет сокет, а Go-горутина попытается записать в net.Conn, получив broken pipe и panic, если ошибка не обработана.

3. Health Checks

  • TCP-чек: Балансировщик подключается к порту и ждет SYN-ACK. Быстрый, но не проверяет логику приложения.
  • HTTP-чек: Отправляет GET /health и ждет статус 200. Требует легковесного эндпоинта. Никогда не ставьте в health-check запросы к БД или тяжелым сервисам — это создаст петлю зависаний.
  • Go-специфика: Go-сервер должен корректно обрабатывать CLOSE_WAIT со стороны LB. Если LB разрывает соединение, Go-горутина, ожидающая чтение, получит io.EOF или net.OpError.
Go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        // Логика health-check должна быть мгновенной
        w.WriteHeader(http.StatusOK)
    })

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        IdleTimeout:       120 * time.Second, // Синхронизировать с LB keep-alive
    }

    go func() {
        log.Printf("Server listening on %s", srv.Addr)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("HTTP server error: %v", err)
        }
    }()

    // Блокировка сигнала для graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // 1. Отключаем Accept новых соединений
    // 2. Ждем завершения активных горутин
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    log.Println("Server exited properly")
}

Итог

  1. L4 vs L7: L4 работает на уровне IP/Port (быстро, conntrack/ядро), L7 на уровне протокола (гибко, userspace, буферизация). Выбор зависит от требований к маршрутизации и latency.
  2. Алгоритмы: RR прост и stateless, LC адаптивен но stateful (требует ресурсов на tracking), Consistent Hashing нужен для sticky-sessions и минимизации ребалансировки.
  3. Go-специфика: net/http использует persistent-соединения и netpoll. Балансировщик напрямую влияет на пул соединений, количество горутин и время graceful shutdown.
  4. Синхронизация: Таймауты LB (drain), Go (IdleConnTimeout, Shutdown timeout) и health-checks должны работать согласованно, чтобы избежать broken pipe и утечки горутин.
  5. Архитектурный совет: Держите Go-сервисы stateless. Выносите сессию/состояние в Redis/Memcached. Это позволяет использовать любой LB-алгоритм без sticky-sessions и упрощает масштабирование.

Мы разобрали, как трафик распределяется между нодами. Но как сервисы узнают, какие ноды существуют, и как динамически обновлять эту информацию без перезагрузки балансировщика? В следующей статье мы перейдем к: 30. Service Discovery и балансировка в микросервисах.