7. Networking в Kubernetes
Сетевой стек Kubernetes: CNI, путь пакета, eBPF и сетевые ловушки в Go
В главах, посвященных Linux и Docker, мы детально препарировали изоляцию сетевых пространств имен (network namespaces) и виртуальные пары интерфейсов (veth). На одном сервере все это выглядит стройно и прозрачно. Но когда мы объединяем в единый вычислительный пул сотни физических серверов и тысячи контейнеров, сетевая инженерия превращается в сложнейшую распределенную паутину.
Сеть в Kubernetes — это не просто возможность отправить TCP-байт из одного угла дата-центра в другой. Это динамический фундамент для Service Discovery, балансировки трафика без единой точки отказа, нулевого доверия (Zero Trust) и криптографической изоляции.
Для инженера, пишущего на Go, понимание пути сетевого пакета от сокета в горутине до сетевой карты хоста — это разница между паническим поиском причин «загадочных задержек в 5 секунд» и мгновенным точечным исправлением одной строчки в конфигурации.
1. Три священных правила сети Kubernetes
Архитекторы Kubernetes (опираясь на опыт внутренней системы Google Borg) сознательно отказались от динамического NAT (Port Mapping в Docker), сделав ставку на модель «плоской сети».
Сетевая модель K8s покоится на трех фундаментальных аксиомах:
- IP-per-Pod (У каждого пода — уникальный адрес): Каждый под в кластере получает свой собственный маршрутизируемый IP-адрес из диапазона Pod CIDR. Контейнеры внутри одного пода делят единый сетевой стек и единый IP-адрес (через служебный инфраструктурный
pauseконтейнер). - Свободная связь внутри ноды (No NAT on host): Любой под на рабочем узле может отправить пакет любому другому поду на том же узле напрямую, без трансляции сетевых адресов (NAT).
- Свободная связь между нодами (No NAT across cluster): Любой под на ноде A может общаться с любым подом на ноде B напрямую по его Pod IP без NAT. Адрес отправителя, который видит принимающий под, должен в точности совпадать с реальным IP пода-источника.
2. Путь пакета: Pod-to-Pod на одной ноде
Как байты, отправленные вашей функцией http.Post в Go, физически добираются до соседнего пода на том же сервере?
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 HostNode["Физический хост / Worker Node"]
subgraph PodA["Pod A (Network Namespace A)"]
GoA["Go Application A\n(write() в TCP socket)"]
EthA["eth0 (IP: 10.244.1.5)"]
GoA --> EthA
end
VethA["veth0-A (Virtual Ethernet)"]
Bridge["Linux Bridge: cni0\n(Коммутатор L2 в ядре)"]
VethB["veth0-B (Virtual Ethernet)"]
subgraph PodB["Pod B (Network Namespace B)"]
EthB["eth0 (IP: 10.244.1.6)"]
GoB["Go Application B\n(read() из сокета)"]
EthB --> GoB
end
EthA <-->|"veth pair"| VethA
VethA <--> Bridge
Bridge <--> VethB
VethB <-->|"veth pair"| EthB
end
- Go-приложение вызывает системный вызов
write()в дескриптор TCP-сокета. - Сетевой стек ядра внутри изолированного сетевого пространства имен пода (
Network Namespace) формирует IP-пакет. - Пакет выходит через интерфейс
eth0. Ноeth0— это всего лишь один конец виртуального кабеля (veth pair). - Второй конец кабеля —
veth0-A— находится в корневом пространстве имен хоста и подключен к виртуальному программному коммутатору ядра — мостуcni0(Bridge). - Мост
cni0анализирует Ethernet-фрейм, заглядывает в свою CAM-таблицу MAC-адресов и перенаправляет фрейм в интерфейсveth0-B. - Пакет мгновенно поднимается через спаренный
eth0прямо в сетевой стек Pod B, где проснувшаяся горутина считывает байты вызовомread().
Весь процесс происходит внутри памяти ядра Linux через структуры sk_buff без копирования данных через физическую сеть.
3. Межузловой трафик и архитектура CNI-плагинов
Что происходит, если Pod A на ноде 1 отправляет запрос поду Pod C на ноде 2? Мост cni0 видит, что MAC-адрес получателя отсутствует в локальной таблице, и выталкивает пакет в таблицу маршрутизации хоста (default route).
Здесь ядро встает перед вопросом: «Как доставить пакет с частным адресом 10.244.2.15 через физическую сеть дата-центра, которая ничего не знает про внутренние IP подов?»
Ответ на этот вопрос дает CNI (Container Network Interface) — стандарт сетевых плагинов, конфигурирующих маршрутизацию в Kubernetes.
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 Node1["Node 1 (192.168.1.10)"]
Pod1["Go Pod 1\n(10.244.1.5)"]
CNI1["CNI / Kernel Routing"]:::system
Pod1 --> CNI1
end
subgraph PhysicalNet["Физическая сеть L2/L3 (Switch / Fabric)"]
Wire["Транспорт: VXLAN (UDP 4789) ИЛИ BGP Route ИЛИ eBPF Direct"]
end
subgraph Node2["Node 2 (192.168.1.20)"]
CNI2["CNI / Decapsulation"]:::process
Pod2["Go Pod 2\n(10.244.2.15)"]
CNI2 --> Pod2
end
CNI1 --> Wire --> CNI2
3.1. Overlay-сети (VXLAN) — Flannel
Классический подход «сети поверх сети». Пакет пода целиком заворачивается в стандартный UDP-пакет (инкапсуляция по протоколу VXLAN) и летит по физической сети хостов на порт 4789.
- Плюсы: Работает везде — в любом облаке, в гетерогенных сетях, не требует настройки физических коммутаторов.
- Минусы: Заметный оверхед CPU на упаковку/распаковку пакетов ядром. Дополнительные 50 байт оверхеда на заголовки, что снижает эффективный размер MTU (с 1500 до 1450 байт) и может вызывать фрагментацию пакетов в высокоскоростных Go-сервисах.
3.2. Нативная маршрутизация (BGP) — Calico
Calico превращает каждый физический сервер кластера в полноценный BGP-маршрутизатор (Border Gateway Protocol). Демон Calico анонсирует подсети подов на физические коммутаторы стойки (Top-of-Rack Switches).
- Плюсы: Пакеты летят с физической скоростью проводов (Native Routing) без малейшей инкапсуляции и без потерь MTU. Минимальная утилизация CPU хоста.
- Минусы: Требует тесной интеграции с сетевыми администраторами ЦОД и поддержки BGP в физической сети.
3.3. Новая эра: eBPF — Cilium
Современный золотой стандарт высоконагруженной инфраструктуры. Cilium полностью переосмысливает сетевую парадигму Linux, отказываясь от устаревших подсистем iptables и медленных цепочек ядра.
4. Сервисы и Kube-proxy: Ловушка Conntrack
Как мы знаем из 2. Pod, Deployment, Service, виртуальные адреса ClusterIP сервисов в реальности не существуют на сетевых интерфейсах. Трафик к ним перехватывается демоном kube-proxy.
Исторически kube-proxy работает в режиме iptables. Для каждого Service и каждого Pod Endpoint создаются правила трансляции сетевых адресов (DNAT). А чтобы ядро Linux знало, какому клиенту вернуть ответный пакет, оно использует системную таблицу состояний соединений — Conntrack (nf_conntrack).
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["Go Client"]:::entry -->|"SYN к VIP ClusterIP"| IPTables["iptables DNAT Rule"]
IPTables -->|"Запись в таблицу nf_conntrack"| CT[("Conntrack Table (RAM)\nЛимит: 262 144 записи")]
CT -->|"Подмена IP на реальный Pod"| TargetPod["Target Pod"]
CT -.->|"Экстремальный RPS / gRPC"| Overflow["💥 nf_conntrack: table full, dropping packet!"]
5. Специфика Go в Kubernetes: DNS и HTTP/2 Connection Pooling
Две самые распространенные сетевые проблемы Go-бэкендов в K8s скрываются внутри стандартной библиотеки Go.
5.1. Проклятие ndots:5 в DNS-резолвере Go
Взгляните на файл /etc/resolv.conf, который Kubernetes автоматически генерирует для каждого пода:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Что означает options ndots:5? Если доменное имя содержит меньше 5 точек, резолвер обязан сначала последовательно подставить все поисковые суффиксы из строки search.
По умолчанию в Go при сборке без Cgo (CGO_ENABLED=0) используется чистый Go DNS-резолвер. Когда ваш код обращается к сервису billing:
resp, err := http.Get("http://billing/api/status")
В имени billing — ноль точек (меньше 5). Go-резолвер начинает мучительный перебор через CoreDNS:
billing.default.svc.cluster.local.-> NXDOMAIN (если сервис в другом namespace).billing.svc.cluster.local.-> NXDOMAIN.billing.cluster.local.-> NXDOMAIN.- И лишь в самом конце —
billing.-> 200 OK.
Каждый исходящий HTTP-запрос порождает до 4–8 лишних UDP-пакетов к CoreDNS, добавляя сотни миллисекунд к первой задержке и создавая шторм запросов на CoreDNS.
Решения проблемы:
- Обращаться по полному FQDN-имени с завершающей точкой:
billing.prod.svc.cluster.local.. - Добавлять точку к одиночному имени:
billing.. - Переопределить
ndotsв манифесте пода через секциюdnsConfig:
spec:
dnsConfig:
options:
- name: ndots
value: "1" # Резолвить сразу внешние имена, если есть хотя бы 1 точка
5.2. HTTP/2 и вечные мертвые соединения в пуле
Клиент net/http в Go невероятно эффективен: он кэширует открытые TCP-сокеты в пуле соединений (Keep-Alive). Для протокола HTTP/2 все запросы к сервису мультиплексируются в одно-единственное долгоживущее TCP-соединение.
Но представьте, что целевой Deployment сервиса обновился: старый под был удален, и на его месте запустился новый под с совершенно новым IP-адресом.
Go-клиент ничего об этом не знает! Он продолжает держать открытый сокет к старому IP. Все запросы сотен горутин летят в умерший сокет и зависают, пока операционная система через десятки секунд не выдаст таймаут или ядро не пришлет TCP RST.
6. Network Policies: Безопасность в плоской сети
По умолчанию плоская сеть Kubernetes полностью открыта: любой под из тестового контура может отправить запрос в продакшен-базу PostgreSQL, если знает ее IP. При компрометации любого внешнего пода злоумышленник получает полную свободу горизонтального перемещения по кластеру (Lateral Movement).
Для изоляции сервисов на уровне сети применяется объект NetworkPolicy. Вы можете декларативно заявить: «Подам с лейблом app=api разрешено ходить на порт 5432 только к подам с лейблом app=postgres».
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-postgres
namespace: prod
spec:
podSelector:
matchLabels:
app: postgres
ingress:
- from:
- podSelector:
matchLabels:
app: api # Разрешаем доступ только подам с лейблом app=api
ports:
- protocol: TCP
port: 5432
Критически важный нюанс: NetworkPolicy — это чистая спецификация. Сам Kubernetes API Server не фильтрует пакеты. Исполнение правил NetworkPolicy целиком возложено на ваш CNI-плагин.
- Если в кластере установлен базовый Flannel, создание NetworkPolicy будет молча проигнорировано — сеть останется открытой!
- Calico и Cilium компилируют эти правила в правила файрвола (iptables, ipset или eBPF-карты), гарантируя изоляцию на уровнях L3/L4 (а Cilium — вплоть до L7 HTTP-методов и путей).
Резюме
- Три правила сети K8s: IP-per-Pod (через
pauseконтейнер), отсутствие NAT внутри ноды и между нодами кластера. - Локальный трафик: Пакеты бегают между подами через виртуальные пары
vethи сетевой мостcni0в памяти ядра Linux. - Эволюция CNI: Overlay (Flannel VXLAN) прост, но имеет оверхед по CPU и MTU. BGP (Calico) обеспечивает нативную скорость. eBPF (Cilium) — вершина производительности, обходящая классический Netfilter ядра.
- Опасность Conntrack: Нагруженный трафик через
kube-proxyв режиме iptables переполняет таблицы отслеживания соединений. Решение — переход на IPVS или Cilium eBPF. - DNS и ndots:5 в Go: Чистый резолвер Go перегружает CoreDNS лишними запросами из-за суффиксов поиска. Используйте FQDN, точку в конце или
ndots:1вdnsConfig. - NetworkPolicy: Необходимы для изоляции периметров, но требуют CNI с поддержкой сетевой безопасности (Calico, Cilium).
Мы завершили фундаментальное исследование базовых кирпичиков инфраструктуры: Linux, Nginx, Docker и Kubernetes. Впереди нас ждет финальный практический рубеж — объединение всего этого стека в единый процесс автоматизированной доставки кода: 1. CI/CD и деплой.