2. Envoy и sidecar
Envoy: Титановый прокси в коляске
В предыдущей статье 1. Service mesh мы подробно разобрали концептуальные предпосылки сервисных сеток: зачем архитекторы выносят логику повторов, таймаутов, распределенной трассировки и балансировки из бизнес-кода на уровень инфраструктурных прокси.
В подавляющем большинстве современных инсталляций Kubernetes (будь то Istio, AWS App Mesh, HashiCorp Consul Connect или Kuma) роль этой рабочей лошадки Data Plane выполняет один конкретный инструмент — Envoy Proxy.
Envoy — это высокопроизводительный распределенный L3/L4 и L7 прокси-сервер, изначально спроектированный инженерами компании Lyft, а ныне являющийся флагманским выпускным проектом фонда Cloud Native Computing Foundation (CNCF). Для опытного Go-разработчика Envoy — это не просто загадочный черный ящик, который «девопсы настраивают через Helm-чарты», а непосредственный сосед по поду, разделяющий с рантаймом Go ядро операционной системы, сетевой стек и процессорные ресурсы.
В этой статье мы заглянем под титановый капот Envoy, исследуем его потоковую модель без блокировок, детально разберем механизм перехвата пакетов и ответим на главный мировоззренческий вопрос: почему сам прокси написан на C++, а не на Go?
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 Pod["Kubernetes Pod (Network Namespace: localhost)"]
direction TB
App["Go Application<br/>(user-service)"]:::entry <-->|"syscall.Read/Write<br/>(Чистый HTTP)"| Envoy["Envoy Sidecar<br/>(Worker Threads: epoll)"]
end
Envoy <==>|"mTLS / HTTP/2<br/>(eth0)"| Cluster["Сеть Kubernetes Кластера"]
ControlPlane["Istiod (Control Plane)"] -.->|"gRPC xDS API (Стримы)"| Envoy
Mechanical Sympathy: Почему C++, а не Go?
Это один из самых увлекательных вопросов современной системной архитектуры. Язык Go по праву считается официальным языком облачной инфраструктуры: само ядро Docker, оркестратор Kubernetes, система мониторинга Prometheus, шлюзы Traefik и Terraform написаны на Go. Почему же абсолютным королем Data Plane стал Envoy, написанный на современном диалекте C++14?
Фундаментальный ответ: Борьба за предсказуемость хвостовых задержек (Tail Latency) и отсутствие сборщика мусора (Garbage Collector).
В рантайме Go сборщик мусора работает конкурентно (Concurrent Mark and Sweep). Несмотря на то, что паузы полной остановки мира (Stop-The-World) в современных версиях Go исчисляются микросекундами, фоновое сканирование кучи неизбежно расходует такты CPU (до 25% ресурсов ядер при интенсивных аллокациях), а при нехватке памяти рантайм принудительно активирует механизм Mark Assist.
Сетевой L7-прокси, перемалывающий сотни тысяч запросов в секунду (RPS) на узел:
- Непрерывно выделяет, нарезает и освобождает миллионы буферов под заголовки HTTP, фреймы HTTP/2 и фрагменты полезной нагрузки.
- В Go такой профиль нагрузки породил бы колоссальное давление на кучу и привел к неизбежным всплескам хвостовых задержек (Spikes) на критических перцентилях p99 и p99.9 (когда один запрос из тысячи зависает на 50–100 мс из-за чистки мусора).
- В C++ управление памятью является строго детерминированным. Envoy применяет кастомные арены памяти (Arena Allocators) и циклические кольцевые буферы, переиспользуя непрерывные области памяти без возврата операционной системе. Это обеспечивает плоский, абсолютно ровный график задержек с разбросом менее 1 мс даже при предельных пиковых нагрузках.
Потоковая модель Envoy (Threading Model)
Чтобы проксировать миллионы пакетов и не превратиться в узкое горлышко, Envoy выжимает максимум из архитектуры многоядерных процессоров. В его основе лежит событийно-ориентированная неблокирующая модель Event-Driven Non-Blocking I/O.
Внутри процесса Envoy функционируют два типа системных потоков операционной системы:
- Main Thread (Главный управляющий поток): Отвечает за начальный старт, чтение первичных конфигураций, координацию жизненного цикла, прием gRPC-стримов динамической конфигурации (xDS API) и экспорт метрик. Главный поток никогда не касается сокетов пользовательского трафика.
- Worker Threads (Рабочие потоки): Занимаются исключительно пересылкой и обработкой сетевых пакетов. По умолчанию Envoy запускает ровно столько потоков-воркеров, сколько физических ядер CPU выделено контейнеру.
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 EnvoyInternals["Внутренняя потоковая модель Envoy"]
Main["Main Thread<br/>(Прием xDS API, метрики, сигналы)"]
W1["Worker Thread 1 (Core 0)<br/>epoll loop"]
W2["Worker Thread 2 (Core 1)<br/>epoll loop"]
W3["Worker Thread 3 (Core 2)<br/>epoll loop"]
Main -.->|"xDS Updates (Lock-free)"| W1 & W2 & W3
end
Conn1["TCP Conn A"] -->|"Привязка на весь цикл жизни"| W1
Conn2["TCP Conn B"] -->|"Привязка на весь цикл жизни"| W2
Conn3["TCP Conn C"] -->|"Привязка на весь цикл жизни"| W3
Как Sidecar перехватывает трафик вашего Go-приложения?
Вы написали лаконичный HTTP-клиент на Go: http.Get("http://user-service/api/users"). Ваш код вообще ничего не знает об Envoy. Каким образом исходящий TCP-пакет волшебным образом оказывается в лапах прокси?
В кластере Kubernetes эта магия разворачивается в три этапа при участии механизма Mutating Admission Webhook и служебных контейнеров:
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
GoApp["Go-приложение (user-service)"]:::entry -->|"1. syscall.Write в сокет :80"| Kernel["Ядро Linux (Network Namespace)"]
Kernel -->|"2. netfilter (PREROUTING/OUTPUT)"| Rules["iptables правила (istio-init)"]
Rules -->|"3. REDIRECT"| EnvoyPort["Локальный сокет :15001"]
EnvoyPort --> Envoy["Envoy Worker (epoll)"]
Envoy -->|"4. Маршрутизация, mTLS"| PhysicalNet["eth0 -> Внешняя сеть"]
- Инъекция манифеста: Когда контроллер разворачивает под, мутирующий вебхук платформы на лету дописывает в спецификацию пода два контейнера: инициализирующий
istio-initи долгоживущийistio-proxy(бинарник Envoy). - Низкоуровневая настройка iptables: Контейнер
istio-initзапускается первым в статусеInitContainer. Он наделен специальной Linux-привилегиейNET_ADMIN. Контейнер исполняет набор низкоуровневых команд, которые переписывают таблицы маршрутизации ядра Linux (iptables) исключительно внутри изолированного сетевого пространства имен (Network Namespace) данного конкретного пода. - Захват трафика: Правила
iptablesпредписывают: любой исходящий TCP-трафик (кроме пакетов, сгенерированных самим служебным пользователем envoy UID 1337) перенаправлять через действиеREDIRECTна локальный порт15001. - Проксирование: Сетевой стек ядра заворачивает пакет на порт
15001, где его мгновенно вычитывает воркер Envoy.
Динамическая конфигурация: Магия xDS
Семейство этих динамических управляющих протоколов носит общее название xDS API (Any Discovery Service):
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
LDS["LDS<br/>(Listener Discovery)<br/>Слушающий порт :15001"]:::process --> RDS["RDS<br/>(Route Discovery)<br/>Сопоставление хостов и путей"]
RDS --> CDS["CDS<br/>(Cluster Discovery)<br/>Группа сервиса и лимиты"]
CDS --> EDS["EDS<br/>(Endpoint Discovery)<br/>Конкретные IP подов"]
Конвейер обработки входящего пакета внутри Envoy поэтапно проходит сквозь эту иерархию:
- LDS (Listener Discovery Service): Листенер слушает входящий сокет
15001и принимает TCP-соединение. - RDS (Route Discovery Service): L7-фильтр парсит заголовки HTTP/gRPC. Видя заголовок
Host: user-service, роутер сопоставляет его с нужным виртуальным маршрутом. - CDS (Cluster Discovery Service): Кластер определяет целевой бэкенд, его таймауты, параметры Circuit Breaker и протокол (HTTP/2 или gRPC).
- EDS (Endpoint Discovery Service): Envoy выбирает конкретный физический IP-адрес пода из актуального пула эндпоинтов и направляет туда байты.
Архитектурные ловушки для Go-разработчика
1. Двойные таймауты и ретраи
Распространенный антипаттерн — дублирование логики отказоустойчивости.
Если разработчик настроил в HTTP-клиенте Go политику 2. Retry и backoff на 3 попытки с экспоненциальным шагом, а DevOps-инженеры включили в Istio VirtualService еще 3 попытки, то при кратковременном сетевом сбое один исходящий вызов превратится в $3 \times 3 = 9$ сетевых ударов по зависимому сервису!
Правило: При работе под крылом Service Mesh удаляйте сложную логику ретраев и Circuit Breakers из прикладного Go-кода. Оставляйте только жесткий общий дедлайн через context.Context (как последний рубеж обороны приложения).
2. Потеря истинного IP клиента
Когда внешний клиент обращается к системе, сетевой путь выглядит так:
Клиент -> Ingress Gateway -> Envoy Sidecar A -> Go App A -> Envoy Sidecar B -> Go App B.
На уровне транспортного TCP-сокета входящее соединение в Go App всегда открывается с локального адреса 127.0.0.1 (или IP Sidecar-контейнера). Чтение поля r.RemoteAddr в Go вернет 127.0.0.1!
Решение: Для аудита безопасности и геолокации извлекайте реальный IP клиента исключительно из HTTP-заголовков X-Forwarded-For или X-Real-IP, которые Envoy формирует и передает по цепочке.
3. Graceful Shutdown и Race Conditions
Когда Kubernetes принимает решение о выводе пода из эксплуатации (например, при Rolling Update), системный сигнал SIGTERM отправляется одновременно во все контейнеры пода — и в ваше Go-приложение, и в контейнер Envoy.
Если Envoy завершит свой процесс на 100 миллисекунд раньше, чем Go-приложение успеет завершить текущие запросы и зафиксировать транзакцию в базе данных (выполнив исходящий RPC-вызов), ваше приложение мгновенно получит ошибку Connection Refused! Ведь правила iptables всё еще активны, а слушающий порт 15001 уже мертв.
Решение: В манифестах Kubernetes настраивают хук задержки остановки (preStop hook) для контейнера Envoy (sleep 5), гарантируя, что Sidecar продолжит обслуживать исходящий трафик до тех пор, пока Go-сервер не закроет все соединения.
Итог
- Envoy как эталон Data Plane: Событийный прокси на C++, исключающий накладные расходы на сборку мусора и обеспечивающий стабильные хвостовые задержки (Tail Latency).
- Lock-Free потоковая модель: Закрепление сокета за одним Worker Thread с собственным циклом
epollгарантирует отсутствие межпоточных блокировок памяти. - Прозрачность перехвата: Контейнер
istio-initперенаправляет трафик черезiptablesбез необходимости менять код Go-клиентов. - Динамика xDS API: Конфигурация маршрутов, эндпоинтов и сертификатов стримится по gRPC в память без перезагрузки процессов.
- Инженерная гигиена: Не дублируйте логику ретраев в Go-коде, извлекайте реальные IP из
X-Forwarded-Forи настраивайте хуки координации при остановке контейнеров.
Одной из самых востребованных и критичных функций связки Envoy и Service Mesh является автоматическая криптографическая защита межсервисных каналов без вмешательства в прикладной код. Как устроена аутентификация микросервисов по стандарту SPIFFE и шифрование трафика в облаке, мы разберем в следующей статье: 3. mTLS.