30. Service Discovery и балансировка в микросервисах

Динамическая природа микросервисов и проблема маршрутизации

В классическую эпоху системного администрирования сетевые адреса были железобетонными константами: база данных жила на сервере db.internal:5432, а кэш — на соседней машине cache.internal:6379. Адреса серверов можно было записать в конфигурационный файл или даже приклеить малярным скотчем на серверную стойку в датацентре.

В современной микросервисной парадигме и контейнерной оркестрации (Kubernetes, Nomad, AWS ECS) экземпляры сервисов рождаются и умирают ежеминутно: автомасштабирование по нагрузке (HPA), rolling update деплои, выселение подов (eviction) из-за нехватки памяти превращают IP-адрес пода в эфемерную сущность, живущую порой всего несколько минут. Захардкоженные конфиги или DNS-записи с долгим TTL приводят к connection refused и timeout при каждом деплое.

Service Discovery (SD) решает фундаментальную задачу динамического обнаружения доступных эндпоинтов: как найти живых адресатов в постоянно меняющемся кластере? Для Go-разработчика это не просто абстракция, а критический слой, напрямую влияющий на net/http.Transport, пулы соединений и время отклика.

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 Registry ["Реестр сервисов (Service Registry)"]
        SD["Consul / etcd / CoreDNS / K8s API"]:::network
    end

    subgraph ServiceBCluster ["Микросервис B (Динамические поды)"]
        B1["Pod 1: 10.244.1.15"]:::process
        B2["Pod 2: 10.244.2.40"]:::process
        B3["Pod 3: 10.244.3.82 (Новый)"]
    end

    B1 -.->|"Регистрация / Heartbeat"| SD
    B2 -.->|"Регистрация / Heartbeat"| SD
    B3 -.->|"Регистрация / Heartbeat"| SD

    Client["Клиент (Микросервис A на Go)"] -->|"1. Где Service B?"| SD
    SD -- "2. Список IP: [10.244.1.15, 10.244.2.40, ...]" --> Client
    Client -->|"3. Балансировка и запрос напрямую"| B2
  

Архитектурные паттерны: Client-Side vs Server-Side

Существует два фундаментальных подхода к балансировке и обнаружению:

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
    subgraph ClientSide ["Client-Side Discovery"]
        C1["Go Client"]:::entry -->|"Опрос реестра"| Reg1["Consul / etcd"]
        C1 -->|"Локальный Round-Robin"| P1["Pod 1"]
        C1 -->|"Прямой TCP"| P2["Pod 2"]
    end

    subgraph ServerSide ["Server-Side Discovery"]
        C2["Go Client"]:::entry -->|"Запрос на VIP: http://orders-service"| LB["L4/L7 Балансировщик<br/>(Envoy / Kube-Proxy)"]
        LB -->|"Маршрутизация"| P3["Pod 3"]
        LB -->|"Маршрутизация"| P4["Pod 4"]
    end
  
  1. Client-Side Discovery: Клиент сам опрашивает реестр (Consul, etcd, Kubernetes DNS) и выбирает бэкенд.

    • Плюсы: Полный контроль над алгоритмом, отсутствие единой точки отказа в маршрутизации, возможность кастомных стратегий (consistent hashing, locality-aware routing).
    • Минусы: Клиент должен знать протокол и уметь обрабатывать ошибки, усложняется код, дублирование логики между сервисами.
    • В Go: Прямой вызов consul или etcd API, кастомный RoundTripper, использование net.Dialer с кастомным DialContext.
  2. Server-Side Discovery: Клиент обращается к виртуальному адресу (VIP), а балансировщик (L4/L7) маршрутизирует трафик.

    • Плюсы: Прозрачность для клиента, централизованное управление, легкое внедрение mTLS и rate limiting.
    • Минусы: Дополнительный сетевой hop, latency overhead, сложность отладки.
    • В Go: Работа через 27. Прокси, Reverse Proxy и API Gateway (Envoy, HAProxy, K8s Ingress). Клиент общается с localhost:8080 (sidecar) или внешним VIP.

DNS как базовый механизм discovery в Go

Kubernetes и многие облачные провайдеры реализуют SD через DNS. При запросе svc.default.svc.cluster.local CoreDNS для обычного Service возвращает один виртуальный ClusterIP (балансировку между подами затем делает kube-proxy на уровне L4), а список IP подов отдаёт только headless-сервис (clusterIP: None). Go использует встроенный net.Resolver для преобразования имени в IP.

Go
// Идиоматичное создание resolver с кастомным dialer для контроля TTL
import "net"

func NewResolver() *net.Resolver {
    return &net.Resolver{
        PreferGo: true, // Использовать встроенный Go-парсер вместо системного cgo
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            // Кастомный dial к конкретному DNS-серверу (напр. CoreDNS)
            d := &net.Dialer{Timeout: 2 * time.Second}
            return d.DialContext(ctx, "udp", "127.0.0.1:53")
        },
    }
}

Балансировка нагрузки: алгоритмы и реализация

В Go нет встроенного LB-модуля для списка бэкендов. Мы реализуем его через net/http.RoundTripper или кастомный DialContext.

Основные алгоритмы:

  • Round Robin: Простой, но уязвим к cache stampede и неравномерной загрузке.
  • Least Connections: Требует метрик состояния, сложно реализовать в stateless-среде без sidecar.
  • Consistent Hashing: Сохраняет аффинность сессий, критично для кэшей и очередей.
  • Random + Weighted: Наивный, но эффективный при большом количестве инстансов (закон больших чисел).
Go
package main

import (
    "fmt"
    "net"
    "net/http"
    "sync"
    "sync/atomic"
    "time"
)

// ServiceBalancer реализует client-side LB с health-check и round-robin
type ServiceBalancer struct {
    endpoints  []string
    currentIndex int64
    clients    map[string]*http.Client
    mu         sync.RWMutex
    resolver   *net.Resolver
}

func NewBalancer(endpoints []string, timeout time.Duration) *ServiceBalancer {
    // Инициализация клиентов с настроенным Transport
    // Важно: MaxIdleConnsPerHost по умолчанию = 2. 
    // Для LB это критично: при ротации IP пул быстро исчерпается.
    tr := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 50, 
        IdleConnTimeout:     90 * time.Second,
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        ForceAttemptHTTP2: true,
    }

    c := &http.Client{
        Timeout:   timeout,
        Transport: tr,
    }

    clients := make(map[string]*http.Client)
    for _, ep := range endpoints {
        clients[ep] = c
    }

    return &ServiceBalancer{
        endpoints: endpoints,
        clients:   clients,
        resolver:  net.DefaultResolver,
    }
}

// NextEndpoint выбирает следующий доступный эндпоинт
func (b *ServiceBalancer) NextEndpoint() (string, error) {
    // Копируем срезы для избежания race conditions при чтении
    b.mu.RLock()
    eps := make([]string, len(b.endpoints))
    copy(eps, b.endpoints)
    b.mu.RUnlock()

    if len(eps) == 0 {
        return "", fmt.Errorf("no endpoints available")
    }

    // Atomic инкремент для lock-free round-robin
    idx := atomic.AddInt64(&b.currentIndex, 1)
    return eps[idx%int64(len(eps))], nil
}

Health Checks и graceful degradation

Discovery бесполезен без валидации состояния бэкенда. Пассивные health checks (ошибки на уровне TCP/HTTP) приводят к отправке трафика на "больные" инстансы до таймаута соединения. Активные checks (polling /health) требуют собственного цикла.

В Go это реализуется через http.Client с кастомным CheckRedirect и Transport.DialContext, либо через отдельный goroutine с time.Ticker. При падении check'а инстанс временно исключается из пула.

Go
// Пример интеграции health check в dialer
func (b *ServiceBalancer) dialWithHealth(ctx context.Context, network, addr string) (net.Conn, error) {
    // 1. Проверка статуса инстанса в локальном кэше
    status := b.getHealthStatus(addr)
    if status == Down {
        return nil, fmt.Errorf("endpoint %s is unhealthy", addr)
    }

    // 2. Реальный dial
    d := &net.Dialer{Timeout: 3 * time.Second}
    conn, err := d.DialContext(ctx, network, addr)
    if err != nil {
        b.markAsDown(addr) // Пассивная маркировка при fail
    }
    return conn, err
}

Go-специфика: Transport, connection pooling и сетевой стек

net/http.Transport — это ядро сетевого взаимодействия в Go. Он управляет пулом соединений через netpoller (epoll на Linux, kqueue на macOS/BSD).

Когда DNS меняет IP, старое соединение (net.Conn) не инвалидируется автоматически. Go продолжает использовать его до IdleConnTimeout или пока оно не упадет. Это вызывает TCP state mismatch: клиент держит ESTABLISHED соединение, а сервер уже закрыл его (или переназначил порт), что приводит к use of closed network connection или broken pipe.

sequenceDiagram
    autonumber
    participant Client as Go Client (net/http.Transport)
    participant DNS as DNS / K8s CoreDNS
    participant OldPod as Старый Pod (10.244.1.5)
    participant NewPod as Новый Pod (10.244.1.9)

    Client->>DNS: Запрос A-записи (TTL = 10s)
    DNS-->>Client: IP: 10.244.1.5
    Client->>OldPod: Открытие TCP и Keep-Alive сессии (IdleConnTimeout = 90s)

    Note over OldPod: Деплой! Kubernetes удаляет старый Pod!
    OldPod-->>Client: FIN / RST или разрыв канала

    Note over Client: DNS обновился на 10.244.1.9!
    Note over Client: Но в пуле Transport все еще лежит idleConn к 10.244.1.5!
    Client->>OldPod: Запрос через старое соединение из пула
    Note over Client: Ошибка: use of closed network connection / connection refused!
  

Механика работы Transport:

  1. При запросе ищется свободный net.Conn в пуле (idleConn).
  2. Если нет — создается новое через DialContext.
  3. Соединение регистрируется в netpoll для асинхронного чтения/записи.
  4. При IdleConnTimeout или MaxIdleConns соединение закрывается через conn.Close(), что освобождает FD и кэш-линии.

Итог

  1. Service Discovery в Go чаще реализуется через DNS или легковесные клиенты (Consul/etcd), а не тяжелые фреймворки.
  2. Client-Side LB дает контроль, но требует ручной реализации health checks и инвалидации кэша. Server-Side (Envoy/K8s) снимает нагрузку с кода, но добавляет latency.
  3. DNS TTL и http.Transport должны быть согласованы. MaxIdleConnsPerHost по умолчанию = 2, что убивает производительность при частой ротации IP.
  4. netpoller и epoll делают Go высокопроизводительным, но старые соединения с изменившимся DNS требуют явной инвалидации или таймаутов.
  5. На собеседованиях ожидают понимания trade-offs между атомарными счетчиками и mutex, влияния TTL на TCP-пулы и стратегии graceful degradation при падении бэкендов.

Мы разобрали, как Go-клиент динамически находит и маршрутизирует трафик. Следующий шаг — как кэшировать ответы на границе инфраструктуры, чтобы разгрузить сервисы от избыточных запросов. В следующей статье мы углубимся в тему: 31. CDN, Edge и кеширование HTTP.