28. nginx, Envoy и HAProxy. Как работают современные прокси
Введение: Зачем Go-разработчику знать о прокси?
В современной бэкенд-архитектуре Go-приложение редко работает в вакууме. Оно стоит за слоем сетевых компонентов, которые берут на себя маршрутизацию, балансировку, TLS-терминацию, логирование и защиту от DDoS. Понимание того, как устроены современные прокси, критически важно для проектирования производительных систем, правильной настройки net/http клиента/сервера и диагностики сложных сетевых проблем.
Казалось бы, если Go настолько производителен и способен легко удерживать миллионы горутин, зачем вообще ставить перед ним сторонние прокси? Ответ кроется в разделении ответственности и аппаратной эффективности:
- Перекладывание криптографических вычислений (TLS Termination) на специализированный слой освобождает процессорные такты Go для бизнес-логики.
- Защита от медленных клиентов (атаки Slowloris) предотвращает блокировку системных ресурсов приложения.
- Балансировка и динамический роутинг между подами в Kubernetes (Service Mesh) требуют мгновенной реакции на изменение топологии кластера без перекомпиляции и перезапуска сервисов.
Прокси делятся на два основных типа:
- Forward Proxy — работает на стороне клиента, перенаправляя исходящие запросы (редко используется в бэкенде).
- 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"]
- Master-процесс читает конфигурацию, открывает и bind-ит сокеты, а затем форкает
worker-процессы. - Worker-процессы изолированы друг от друга. Каждый воркер работает в одном потоке ОС и использует
epoll(на Linux) илиkqueue(на BSD/macOS) для асинхронного мониторинга тысяч соединений. - Когда клиент подключается,
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-клиент работает через прокси.
// В отличие от 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, вы должны понимать, что прокси не является прозрачным.
- TLS Overhead: Терминация TLS на прокси снимает CPU-нагрузку с Go-приложения, но добавляет задержку на шифрование/дешифрование. Используйте аппаратное ускорение (Intel QAT, AWS Nitro) или
nginxсngx_http_ssl_moduleиsession resumption. - Keep-Alive: Go
net/httpпо умолчанию использует HTTP/1.1 с keep-alive. Если прокси не настроен на проксирование keep-alive, каждое новое соединение потребует TCP handshake + TLS handshake. Это убивает производительность при высокой нагрузке. - Buffer Size: Прокси буферизируют тело запроса. Если размер тела превышает буфер (например, 16KB в
nginx), данные записываются на диск. Go должен это учитывать при чтенииRequest.Body.
Ловушки и типичные вопросы на собеседованиях
-
Как
nginxобрабатывает 100k одновременных подключений?
Не создает 100k потоков. Использует 1epollна воркер. Сокеты становятся non-blocking. Когда данных нет, воркер спит вepoll_wait(syscall, который не блокирует CPU). При приходе данных ядро будит воркер, он читает, обрабатывает и снова засыпает. -
Почему
Envoyиспользует xDS вместо статичного конфига?
Динамическая конфигурация позволяет обновлять правила роутинга, балансировки и TLS-сертификаты без перезапуска прокси. Это критично для оркестрации в Kubernetes, где pod'ы постоянно создаются и удаляются. -
В чем разница между
sendfileиspliceв контексте прокси?
sendfileкопирует данные из page cache в socket buffer.spliceперемещает данные между файловыми дескрипторами (например, от сокета к диску) через ядро, вообще не заходя в user-space.nginxиспользуетsendfileдля статических файлов (опцияtcp_nopushработает вместе сsendfile), аspliceдля проксирования поддерживает, например, HAProxy. -
Как прокси влияют на 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.