28. nginx, Envoy и HAProxy. Как работают современные прокси

Введение: Зачем Go-разработчику знать о прокси?

В современной бэкенд-архитектуре Go-приложение редко работает в вакууме. Оно стоит за слоем сетевых компонентов, которые берут на себя маршрутизацию, балансировку, TLS-терминацию, логирование и защиту от DDoS. Понимание того, как устроены современные прокси, критически важно для проектирования производительных систем, правильной настройки net/http клиента/сервера и диагностики сложных сетевых проблем.

Казалось бы, если Go настолько производителен и способен легко удерживать миллионы горутин, зачем вообще ставить перед ним сторонние прокси? Ответ кроется в разделении ответственности и аппаратной эффективности:

  • Перекладывание криптографических вычислений (TLS Termination) на специализированный слой освобождает процессорные такты Go для бизнес-логики.
  • Защита от медленных клиентов (атаки Slowloris) предотвращает блокировку системных ресурсов приложения.
  • Балансировка и динамический роутинг между подами в Kubernetes (Service Mesh) требуют мгновенной реакции на изменение топологии кластера без перекомпиляции и перезапуска сервисов.

Прокси делятся на два основных типа:

  1. Forward Proxy — работает на стороне клиента, перенаправляя исходящие запросы (редко используется в бэкенде).
  2. Reverse Proxy — работает на стороне сервера, принимая запросы от клиентов и перенаправляя их на бэкенд-сервисы. Именно этот тип доминирует в Go-экосистеме.

По уровню работы с сетевыми протоколами прокси разделяются на:

  • L4 (Transport Layer) — оперирует IP-адресами и портами (TCP/UDP). Не читает тело запроса.
  • L7 (Application Layer) — парсит HTTP, gRPC, WebSocket. Может маршрутизировать по заголовкам, пути, методу.

nginx: Архитектура событийной модели

nginx — это событийно-ориентированный прокси, написанный на C Игорем Сысоевым в 2002–2004 годах для решения знаменитой проблемы C10k (обслуживание 10 000 одновременных подключений на одном сервере). Его архитектура построена вокруг модели master-worker.

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
    Master["Master-процесс (Root)<br/>Чтение конфигурации, открытие 80/443 сокетов, форк воркеров"]

    Master --> W1["Worker 1 (User nginx)<br/>1 поток CPU | epoll loop"]
    Master --> W2["Worker 2 (User nginx)<br/>1 поток CPU | epoll loop"]
    Master --> W3["Worker 3 (User nginx)<br/>1 поток CPU | epoll loop"]
    Master --> W4["Worker 4 (User nginx)<br/>1 поток CPU | epoll loop"]

    W1 <--> Epoll1["epoll (Linux) / kqueue (BSD)<br/>Тысячи неблокирующих дескрипторов"]
    W2 <--> Epoll2["epoll / kqueue"]
    W3 <--> Epoll3["epoll / kqueue"]
    W4 <--> Epoll4["epoll / kqueue"]
  
  1. Master-процесс читает конфигурацию, открывает и bind-ит сокеты, а затем форкает worker-процессы.
  2. Worker-процессы изолированы друг от друга. Каждый воркер работает в одном потоке ОС и использует epoll (на Linux) или kqueue (на BSD/macOS) для асинхронного мониторинга тысяч соединений.
  3. Когда клиент подключается, accept() возвращает дескриптор. nginx регистрирует его в epoll с флагом EPOLLIN. Когда данные приходят, ядро будит воркер, и он читает данные, парсит HTTP, выполняет логику роутинга и отправляет ответ.

Под капотом и Zero-Copy

  • Каждый воркер имеет свой пул файловых дескрипторов и собственную кучу (malloc).
  • nginx использует системный вызов sendfile() для zero-copy передачи статических файлов с диска на сетевой интерфейс, минуя пользовательское пространство (splice() в исходниках nginx не используется; sendfile не работает для проксирования ответов бэкенда и поверх TLS — без kTLS данные приходится шифровать в user-space). Данные перекачиваются напрямую из page cache ядра в буфер сетевой карты по DMA (Direct Memory Access), не загрязняя кэш L1/L2 процессора.
  • Конфигурация статична при запуске. Для динамического обновления используется open_file_cache и reconfigure через SIGHUP.
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 TraditionalCopy ["Классический read/write (4 переключения контекста, 2 копии CPU)"]
        Disk1["Диск"]:::data -->|"DMA"| PageCache1["Page Cache"]
        PageCache1 -->|"CPU Copy"| UserBuf["User-Space Буфер"]
        UserBuf -->|"CPU Copy"| SockBuf1["Socket Buffer"]
        SockBuf1 -->|"DMA"| NIC1["Сетевая карта"]
    end

    subgraph ZeroCopySendfile ["sendfile / splice (2 переключения контекста, 0 копий CPU)"]
        Disk2["Диск"]:::data -->|"DMA"| PageCache2["Page Cache"]
        PageCache2 -->|"Прямой DMA дескриптор"| NIC2["Сетевая карта"]
    end
  
Go
// Пример того, как Go-клиент работает через прокси.
// В отличие от C-прокси, Go абстрагирует epoll через netpoller,
// но при подключении к upstream все равно использует стандартный socket API.
func main() {
    transport := &http.Transport{
        Proxy: http.ProxyFromEnvironment,
        // В продакшене здесь настраиваются dialer, keep-alive и TLS конфигурации
        // для соответствия поведению nginx/HAProxy.
    }
    client := &http.Client{Transport: transport}
    resp, err := client.Get("http://backend-service:8080/health")
    if err != nil {
        log.Fatalf("proxy dial failed: %v", err)
    }
    defer resp.Body.Close()
    // ... обработка ответа
}

HAProxy: L4-мастер и производительность

HAProxy (High Availability Proxy) исторически позиционируется как высокопроизводительный балансировщик, делающий акцент на L4 и L7. Исторически он был однопоточным event loop-ом, но с версии 1.8 поддерживает многопоточность: параметр nbthread по умолчанию равен числу доступных CPU (на платформах с привязкой к CPU; иначе — 1).

  • Архитектура: Каждый поток обрабатывает свои соединения через epoll (в старых версиях — один поток на процесс). Архитектура спроектирована так, чтобы минимизировать контекстные переключения.
  • TCP остаётся в ядре: Управление окном перегрузки и ACK выполняет ядро ОС, а не HAProxy. HAProxy экономит копирования с помощью splice() (опции option splice-auto/request/response), перекладывая данные между сокетами внутри ядра.
  • Zero-copy и буферизация: Использует фиксированные пулы буферов (memory pools) для избежания фрагментации кучи. Данные копируются минимальное количество раз между буферами.

Envoy: Data Plane, xDS и Service Mesh

Envoy — прокси нового поколения, написанный на C++ инженерами Lyft, а ныне флагманский проект CNCF. Он стал основой Service Mesh, например Istio (у Linkerd собственный прокси linkerd2-proxy на Rust), благодаря разделению на Control Plane и Data Plane.

  • Data Plane: Сам Envoy (sidecar или gateway). Не имеет состояния. Все конфигурации приходят динамически.
  • xDS API: Протокол для передачи конфигурации от control plane к data plane (поверх gRPC-стримов). Включает:
    • LDS (Listener Discovery Service): порты и привязка сокетов.
    • CDS (Cluster Discovery Service): пулы бэкенд-сервисов.
    • RDS (Route Discovery Service): правила маршрутизации и соответствия URL.
    • SDS (Secret Discovery Service): динамическая ротация TLS-сертификатов без разрыва сессий.
  • Observability: Встроенная поддержка OpenTelemetry, Prometheus метрик, детального логирования.
  • C++ под капотом: Нет GC. Память управляется вручную или через arena-аллокаторы. Это дает предсказуемую нагрузку на CPU, но требует строгого контроля за утечками.
flowchart LR
    Client["Клиент"] -->|"HTTPS/TLS"| Envoy["Envoy Sidecar<br/>(Data Plane)"]
    Envoy -->|"xDS gRPC"| Control["Control Plane<br/>(Config Source)"]
    Envoy -->|"L4/L7 роутинг"| Backend["Бэкенд-сервис"]
    Envoy -->|"metrics/logs"| Observability["Prometheus / Jaeger"]

    style Envoy fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    style Control fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#f8fafc
  

Механическая симпатия: Событийная модель vs Горутинная

Здесь кроется фундаментальное различие между классическими прокси и Go-приложениями.

Аспект nginx / HAProxy / Envoy (C/C++) Go Runtime
Модель выполнения 1 поток ОС = 1 воркер = 1 epoll loop. M:N планировщик. Миллионы горутин на одном потоке.
Контекстные переключения Минимальны. Переключение внутри воркера происходит только при epoll_wait. Частые переключения между goroutine и OS thread (sysmon, netpoll).
Работа с памятью Ручное управление / arena / malloc. Кэш-локальность критична. GC, Escape Analysis. Данные могут уходить в кучу, создавая нагрузку на сборщик.
Сетевой стек Пользовательский или ядро (splice/sendfile). netpoller (epoll/kqueue) + sysmon + runtime.netpoll.

Почему это важно для Go-разработчика?
Когда вы настраиваете Go-сервер за nginx или Envoy, вы должны понимать, что прокси не является прозрачным.

  1. TLS Overhead: Терминация TLS на прокси снимает CPU-нагрузку с Go-приложения, но добавляет задержку на шифрование/дешифрование. Используйте аппаратное ускорение (Intel QAT, AWS Nitro) или nginx с ngx_http_ssl_module и session resumption.
  2. Keep-Alive: Go net/http по умолчанию использует HTTP/1.1 с keep-alive. Если прокси не настроен на проксирование keep-alive, каждое новое соединение потребует TCP handshake + TLS handshake. Это убивает производительность при высокой нагрузке.
  3. Buffer Size: Прокси буферизируют тело запроса. Если размер тела превышает буфер (например, 16KB в nginx), данные записываются на диск. Go должен это учитывать при чтении Request.Body.

Ловушки и типичные вопросы на собеседованиях

  1. Как nginx обрабатывает 100k одновременных подключений?
    Не создает 100k потоков. Использует 1 epoll на воркер. Сокеты становятся non-blocking. Когда данных нет, воркер спит в epoll_wait (syscall, который не блокирует CPU). При приходе данных ядро будит воркер, он читает, обрабатывает и снова засыпает.

  2. Почему Envoy использует xDS вместо статичного конфига?
    Динамическая конфигурация позволяет обновлять правила роутинга, балансировки и TLS-сертификаты без перезапуска прокси. Это критично для оркестрации в Kubernetes, где pod'ы постоянно создаются и удаляются.

  3. В чем разница между sendfile и splice в контексте прокси?
    sendfile копирует данные из page cache в socket buffer. splice перемещает данные между файловыми дескрипторами (например, от сокета к диску) через ядро, вообще не заходя в user-space. nginx использует sendfile для статических файлов (опция tcp_nopush работает вместе с sendfile), а splice для проксирования поддерживает, например, HAProxy.

  4. Как прокси влияют на GC в Go?
    Косвенно. Если прокси закрывает соединения, Go-клиент вынужден постоянно создавать новые net.Conn и аллоцировать буферы. Это увеличивает давление на GC. Настройка http.Transport.MaxIdleConns и IdleConnTimeout напрямую решает эту проблему.


Итог

Современные прокси (nginx, HAProxy, Envoy) решают задачи, которые Go-рантайм не берёт на себя: L4/L7 маршрутизация, сложная TLS-терминация, zero-copy I/O, observability и защита от атак. Понимание их устройства позволяет:

  • Правильно настраивать net/http транспорт для минимизации накладных расходов.
  • Избегать утечек портов и TIME_WAIT.
  • Выбирать инструмент под задачу: nginx для L7-веб, HAProxy для L4-балансировки, Envoy для Service Mesh.

Мы разобрали, как трафик обрабатывается на уровне прокси. Следующий шаг — понять, как именно трафик распределяется между бэкендами. В следующей статье мы детально разберем: 29. Балансировка нагрузки. L4, L7, Round Robin, Least Connections.