8. Kubernetes networking
Анатомия сети: От горутины до дата-центра
«Вся магия распределенных систем исчезает, как только вы понимаете, как ядро операционной системы перекладывает байты из одного сетевого сокета в другой».
— Принцип демистификации инфраструктуры
В предыдущей лекции 7. Observability в Kubernetes мы сделали наш кластер прозрачным для телеметрии: научились собирать метрики, структурированные логи и сквозные трейсы. Но под всем этим слоем абстракций скрывается фундаментальная основа любой распределенной системы — компьютерная сеть.
Для подавляющего большинства разработчиков сеть в Kubernetes кажется непостижимой магией. Мы применяем декларативные YAML-манифесты, и внезапно поды, физически разнесенные по разным серверам и стойкам дата-центра, начинают прозрачно общаться друг с другом по прямым IP-адресам, а клиентский трафик из глобального интернета безошибочно находит нужную горутину в конкретном поде.
Однако в компьютерных системах чудес не бывает. Вся «магия» Kubernetes — это строгая и виртуозная оркестрация низкоуровневых механизмов ядра Linux:
- Сетевых пространств имен (Network Namespaces);
- Виртуальных Ethernet-пар (
veth); - Программных мостов (Linux Bridges);
- Оверлейных туннелей (VXLAN, Geneve) и протокола маршрутизации BGP;
- Подсистем фильтрации пакетов (
iptables, IPVS) и eBPF.
В этой финальной лекции раздела «Kubernetes и деплой» мы разберем внутреннее устройство Container Network Interface (CNI), вскроем печально известную проблему резолвинга DNS в Go (ndots:5), разберем физику переполнения таблицы nf_conntrack ядра Linux и сконфигурируем образцовый сетевой транспорт Go для работы в высоконагруженном кластере.
1. Плоская сеть и CNI (Container Network Interface)
Архитектура сети Kubernetes построена на концепции «плоской сети» (Flat Network), которая формулируется четырьмя фундаментальными инвариантами:
- Каждый Pod получает собственный уникальный IP-адрес в рамках всего кластера.
- Любой Pod может обращаться к любому другому Pod без применения NAT (Network Address Translation).
- Системные демоны на ноде (
kubelet, рантайм контейнеров) могут связываться со всеми подами на этой же ноде. - IP-адрес, который Pod видит у себя локально, совпадает с IP-адресом, который видят все остальные участники сети.
Это фундаментальное упрощение: разработчику Go-сервиса не нужно думать о динамическом сопоставлении портов (Port Mapping) на хосте, как это было в раннем Docker. Для приложения каждый Pod выглядит как независимый физический сервер.
За реализацию этих правил на уровне ядра Linux отвечает CNI-плагин (Container Network Interface) — такие сетевые драйверы, как Calico, Cilium, Flannel или облачные AWS-VPC CNI.
Mechanical Sympathy: Как под получает сеть?
Когда агент kubelet дает команду рантайму контейнеров (containerd) развернуть новый Pod с Go-приложением, разворачивается следующая цепочка системных вызовов:
sequenceDiagram
autonumber
participant KL as kubelet
participant CRI as containerd
participant Kernel as Ядро Linux
participant CNI as CNI Плагин (Calico / Cilium)
KL->>CRI: Создать Pod Sandbox
CRI->>Kernel: unshare(CLONE_NEWNET) -> Создание NetNS
Note over Kernel: В новом NetNS есть только интерфейс lo
CRI->>CNI: Вызов CNI ADD (передача NetNS path)
CNI->>Kernel: ip link add veth_pod type veth peer name veth_host
CNI->>Kernel: Перенос veth_pod внутрь NetNS -> eth0
CNI->>Kernel: Назначение IP 10.244.1.5 на eth0
CNI->>Kernel: Подключение veth_host к bridge (cni0) на хосте
CNI->>Kernel: Прописка шлюза по умолчанию (default gateway)
CNI-->>CRI: Сеть настроена!
CRI->>KL: Pod готов к запуску Go-контейнера
Разберем эту анатомию по шагам:
- Изоляция: Ядро Linux создает изолированное сетевое пространство имен (
Network Namespace). Изначально внутри него поднят лишь пустой псевдоинтерфейс петли обратной связиlo(localhost). - Создание виртуального патч-корда: Драйвер CNI создает виртуальную пару интерфейсов —
veth pair(Virtual Ethernet Pair). Это прямой программный аналог сетевого кабеля, соединяющего два порта. - Разделение концов:
- Один конец пары помещается внутрь сетевого пространства имен пода и переименовывается в
eth0. Ему присваивается выделенный IP-адрес (например,10.244.1.5). - Второй конец (
vethX) остается в корневом пространстве имен хоста (Worker Node).
- Один конец пары помещается внутрь сетевого пространства имен пода и переименовывается в
- Коммутация на ноде: На хосте второй конец подключается к виртуальному программному коммутатору — Linux Bridge (обычно именуемому
cni0илиbridge). - Маршрутизация: В таблице маршрутизации пода прописывается шлюз по умолчанию (
0.0.0.0/0), направляющий все исходящие пакеты черезeth0в мостcni0.
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 PodNetNS["Сетевой Namespace Пода (10.244.1.5)"]
GoProcess["Go-приложение (net.Listen :8080)"]
Eth0["Интерфейс eth0 (10.244.1.5)"]
GoProcess --> Eth0
end
subgraph HostNetNS["Сетевой Namespace Хоста (Worker Node 1: 192.168.1.10)"]
VethHost["vethX (Виртуальный патч-корд)"]
Bridge["Мост cni0 (10.244.1.1)"]
HostPhys["Физическая сетевая карта eth0 (192.168.1.10)"]
Eth0 == "veth-pair" === VethHost
VethHost --> Bridge
Bridge --> HostPhys
end
subgraph Fabric["Сетевая фабрика дата-центра (L2 / L3)"]
HostPhys --> Wire["Туннель VXLAN (UDP 4789) / Маршрутизация BGP"]
end
subgraph RemoteHost["Удаленный Worker Node 2 (192.168.1.20)"]
Wire --> RemotePhys["Физическая сетевая карта eth0"]
RemotePhys --> RemotePod["Удаленный Pod (10.244.2.8)"]
end
Межхостовое взаимодействие: VXLAN vs BGP
Когда пакет летит между подами на разных физических серверах:
- VXLAN (Оверлейная сеть, Flannel / Calico): Ядро берет оригинальный Ethernet-кадр пода, запаковывает его в стандартный UDP-пакет (порт 4789) хоста и отправляет через обычную сеть дата-центра. На принимающей ноде ядро распаковывает UDP и передает внутренний пакет в целевой под. Это универсально, но создает небольшой оверхед на инкапсуляцию (снижая MTU на 50 байт).
- BGP / Прямая маршрутизация (Calico / Cilium): Ноды кластера сами выступают BGP-маршрутизаторами и объявляют сетевым коммутаторам стойки маршруты к своим подсетям подов. Пакеты летят без дополнительной инкапсуляции с максимальной скоростью сетевой карты.
- eBPF (Cilium): Сетевые пакеты перехватываются на уровне сокетов с помощью программ ядра eBPF, полностью обходя традиционные тяжелые стеки
iptablesи подсистемы Linux TC (Traffic Control).
2. Внешний трафик: Ingress и обход kube-proxy
В лекции 3. Pod, Deployment, Service мы подробно разобрали, как виртуальный IP-адрес Service (ClusterIP) преобразуется демоном kube-proxy через цепочки iptables или таблицы IPVS. Но как входящий интернет-трафик попадает внутрь кластера?
Для этого используется Ingress Controller (наиболее распространенные решения: Ingress-NGINX, Envoy, Traefik).
Ingress Controller — это специализированный Pod (или группа подов), развернутый на пограничных нодах кластера и смотрящий наружу через облачный балансировщик (Cloud Load Balancer). Но у Ingress Controller есть колоссальное преимущество перед стандартным Service: он полностью обходит kube-proxy!
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["Внешний клиент (Интернет)"]:::entry --> CloudLB["Cloud Load Balancer / DNS"]
CloudLB --> Ingress["Ingress Controller (NGINX / Envoy)"]
subgraph StandardWay["Стандартный путь через Service (Медленно)"]
Ingress -.->|"1. Обращение к ClusterIP"| Service["Service Virtual IP"]
Service -.->|"2. NAT в ядре (iptables/IPVS)"| KubeProxy["kube-proxy DNAT"]
KubeProxy -.->|"3. Случайный Pod"| PodA["Pod A"]
end
subgraph IngressDirect["Прямой путь Ingress (Быстро! Zero-DNAT)"]
API["kube-apiserver (Endpoints API)"] -->|"Watch Endpoints"| Ingress
Ingress ==>|"Прямой TCP/HTTP сокет к Pod IP"| Pod1["Pod 1 (10.244.1.5)"]
Ingress ==>|"Прямой TCP/HTTP сокет к Pod IP"| Pod2["Pod 2 (10.244.2.8)"]
end
Как работает Ingress в рантайме:
- Ingress Controller непрерывно слушает API-сервер Kubernetes (
EndpointsиEndpointSlice). - Он хранит в своей оперативной памяти актуальный список реальных IP-адресов всех работающих подов.
- Когда приходит входящий HTTP-запрос от клиента, NGINX/Envoy разбирает HTTP-заголовки (
Host,Path) и открывает TCP-соединение напрямую с IP-адресом конкретного пода (10.244.1.5:8080). - Пакет не проходит через цепочки
iptablesClusterIP, избавляя ядро от лишней операции DNAT и сокращая сетевую задержку до абсолютного минимума.
3. Проклятие DNS: Проблема ndots:5 в Go
Среди архитекторов высоконагруженных систем на Go проблема ndots:5 стала легендарной. Этот скрытый нюанс работы сетевого стека породил сотни сбоев и падений боевых кластеров под пиковыми нагрузками.
Механика /etc/resolv.conf
В кластере за разрешение доменных имен отвечает распределенный DNS-сервер CoreDNS. При создании любого контейнера агент kubelet монтирует в него файл конфигурации DNS-клиента /etc/resolv.conf:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Директива ndots:5 означает следующее:
«Если в доменном имени, которое пытается разрезолвить приложение, содержится меньше 5 точек, резолвер обязан сначала последовательно подставить к нему все суффиксы из списка search, и лишь в самом конце запросить имя как есть».
А теперь проанализируем, что происходит в коде Go. Предположим, ваш микросервис отправляет платеж через внешний шлюз:
resp, err := http.Get("https://api.stripe.com")
В доменном имени api.stripe.com всего две точки ($2 < 5$).
sequenceDiagram
autonumber
participant App as Go Приложение (net.Resolver)
participant DNS as CoreDNS (Кластерный DNS)
participant Ext as Публичные DNS интернета (8.8.8.8)
Note over App: http.Get("https://api.stripe.com") [2 точки < 5]
App->>DNS: 1. A? api.stripe.com.default.svc.cluster.local
DNS-->>App: NXDOMAIN (Мусорный запрос!)
App->>DNS: 2. A? api.stripe.com.svc.cluster.local
DNS-->>App: NXDOMAIN (Мусорный запрос!)
App->>DNS: 3. A? api.stripe.com.cluster.local
DNS-->>App: NXDOMAIN (Мусорный запрос!)
App->>DNS: 4. A? api.stripe.com.
DNS->>Ext: Рекурсивный запрос к Stripe
Ext-->>DNS: Ответ IP
DNS-->>App: 200 OK (Наконец-то!)
Масштаб бедствия:
На один безобидный исходящий HTTP-запрос генерируется четыре сетевых UDP-пакета!
Если ваш микросервис держит нагрузку 10 000 RPS, вы обрушиваете на CoreDNS цунами из 40 000 мусорных DNS-запросов каждую секунду.
Под такой нагрузкой происходят два катастрофических события:
- CoreDNS утилизирует 100% CPU, переполняет очереди сетевых сокетов и начинает терять UDP-пакеты.
- Go-резолвер сталкивается с сетевыми таймаутами (дефолтный таймаут UDP-ответа — 5 секунд!). Вся latency бизнес-транзакций взлетает с миллисекунд до десятков секунд, сервис парализуется.
Как победить проблему ndots:5:
1. Использование FQDN с замыкающей точкой в Go-коде
В коде на Go при обращении к внешним интернет-ресурсам всегда используйте полностью определенные доменные имена (Fully Qualified Domain Name, FQDN) с замыкающей точкой на конце:
// ✅ ПРАВИЛЬНО: Замыкающая точка указывает резолверу, что имя абсолютное!
resp, err := http.Get("https://api.stripe.com.")
Встретив точку на конце (api.stripe.com.), резолвер Go мгновенно понимает, что домен уже является абсолютным корнем, и отправляет ровно один DNS-запрос сразу во внешний мир, полностью минуя мусорные суффиксы svc.cluster.local.
2. Тюнинг манифеста Pod Spec (dnsConfig)
В спецификации Deployment можно переопределить параметр ndots для конкретного приложения:
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "1" # Искать в локальных доменах только имена БЕЗ точек (например, 'postgres')
При значении ndots: "1" любое составное имя (api.stripe.com) сразу будет резолвиться без подстановки суффиксов кластера.
3. Внедрение NodeLocal DNSCache
В зрелых кластерах Kubernetes на каждом узле разворачивают легковесный кэширующий DNS-демон (NodeLocal DNSCache как DaemonSet). Он перехватывает все UDP-запросы на локальном loopback-интерфейсе ноды, исключая сетевые задержки при обращениях к центральному CoreDNS.
4. Egress и переполнение таблицы Conntrack
Когда ваше Go-приложение обращается к внешним сервисам за пределами кластера (например, к базе данных AWS RDS, брокеру Kafka или сторонним API), сетевой пакет покидает Worker Node.
В этот момент ядро Linux выполняет операцию SNAT (Source NAT): оно подменяет частный IP-адрес пода (10.244.x.x) на публичный или маршрутизируемый IP-адрес самого физического сервера ноды. Иначе внешний сервер ответит на адрес пода, о котором глобальная сеть дата-центра ничего не знает.
Чтобы вернуть ответный пакет из внешней сети ровно в тот сокет пода, который его запросил, подсистема Netfilter ядра Linux ведет специальную хэш-таблицу учета всех активных сетевых соединений — nf_conntrack (Connection Tracking).
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
Pod["Pod (10.244.1.5)\nТысячи коротких TCP"]:::network -->|"Пакет: 10.244.1.5:45231 -> 54.21.0.1:443"| Kernel["Ядро Linux (Netfilter SNAT)"]
Kernel -->|"Запись в таблицу nf_conntrack"| Table["Таблица nf_conntrack\n[max: 262144]"]
Kernel -->|"Пакет: 192.168.1.10:61002 -> 54.21.0.1:443"| RDS["Внешний AWS RDS"]
Table -.->|"Если переполнена!"| Drop["💥 DROP всех новых пакетов!\ndial tcp: i/o timeout"]
5. Идиоматичный HTTP Transport для K8s
С учетом всех тонкостей сетевого взаимодействия в кластере, таймаутов (см. 3. Timeout), защиты таблицы conntrack и предотвращения утечек горутин, вот как выглядит эталонная конфигурация сетевого клиента Go для работы в Kubernetes:
package network
import (
"net"
"net/http"
"time"
)
// NewK8sHTTPClient конструирует оптимизированный HTTP-клиент для распределенной среды Kubernetes
func NewK8sHTTPClient() *http.Client {
dialer := &net.Dialer{
// Быстрый отказ при проблемах на уровне CNI / veth маршрутизации
Timeout: 3 * time.Second,
// TCP Keep-Alive зонды защищают соединения от разрыва файрволами и экономят nf_conntrack
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
// Мультиплексирование сотен запросов в один TCP-сокет по HTTP/2
ForceAttemptHTTP2: true,
// Размер пула простаивающих Keep-Alive соединений:
// Предотвращает постоянное открытие новых TCP-сокетов (защита conntrack)
MaxIdleConns: 1000,
// Ограничение свободных коннектов к конкретному микросервису
MaxIdleConnsPerHost: 100,
// Время жизни простаивающего соединения в пуле
IdleConnTimeout: 90 * time.Second,
// Таймаут согласования TLS шифрования
TLSHandshakeTimeout: 5 * time.Second,
// Защита от зависания при отправке больших тел запросов (HTTP 100 Continue)
ExpectContinueTimeout: 1 * time.Second,
// Буферы сокетов на уровне ядра Linux (опциональный тюнинг)
WriteBufferSize: 32 * 1024, // 32 KB
ReadBufferSize: 32 * 1024, // 32 KB
}
return &http.Client{
// Глобальный жесткий таймаут на всю транзакцию (включая редиректы и чтение body).
// Защищает приложение от бесконечного раздувания числа горутин.
Timeout: 10 * time.Second,
Transport: transport,
}
}
Вопросы для собеседования
Итог раздела "Kubernetes и деплой"
- Изоляция и Namespaces: Сеть пода изолирована сетевым пространством имен ядра Linux. CNI-плагин соединяет его с физической сетью хоста через пару виртуальных интерфейсов
veth pair. - Ingress Controller: Интеллектуальный L7-маршрутизатор, оптимизирующий путь пакета и обходящий накладные расходы
kube-proxyиiptablesза счет прямого соединения с подами. - Осторожно с DNS (
ndots:5): По умолчанию резолвер Go генерирует волны паразитных DNS-запросов к CoreDNS. Применяйте абсолютные имена с замыкающей точкой (.) или оптимизируйте параметрыdnsConfig. - Тюнинг HTTP клиента: Пул соединений (Keep-Alive) в
http.Clientиsql.DB— это инструмент выживания сетевого стека ОС, защищающий таблицуnf_conntrackот исчерпания и потери пакетов.
Мы завершили фундаментальное исследование оркестрации, контейнеризации и деплоя: от устройства кремниевого процессора и системных вызовов ядра ОС до тонкостей распределенного планирования в Kubernetes.
Теперь мы обладаем полным арсеналом знаний для перехода к следующему разделу. Мы объединим все изученные принципы и спроектируем архитектуру зрелого, надежного микросервиса на Go, готового к высоким нагрузкам: 1. Структура микросервисного проекта на Go.