Раздел 3 · Kubernetes — глава 3.3
Сеть кластера вглубь: CNI, Cilium и Istio
В главе 3.1 сеть кластера была описана в объёме «у каждого пода свой IP, дальше дело CNI». Этого хватает для эксплуатации, но не хватает для разговора, где спрашивают «а как именно». Здесь разбираем по-настоящему: что физически происходит на узле, как устроен kube-proxy и почему на больших кластерах он становится проблемой, что делает Cilium с eBPF вместо него, и — самое подробное — как работает Istio, от iptables внутри пода до выдачи сертификатов.
§1Модель сети: три правила, из которых всё следует
Kubernetes сам не реализует сеть. Он формулирует требования и оставляет реализацию плагину. Требований всего три, но они жёсткие:
- У каждого пода свой IP-адрес, и под видит его сам. Внутри пода
ip aпоказывает ровно тот адрес, по которому к нему обращаются снаружи — никакой трансляции. - Любой под может достучаться до любого другого пода на любом узле по этому адресу напрямую, без NAT. Это самое сильное требование, и именно оно отличает модель Kubernetes от классической контейнерной сети Docker с пробросом портов.
- Процессы на узле могут достучаться до подов на этом узле и наоборот.
Стоит понять, зачем такая строгость. Если бы поды видели друг друга через NAT, то приложение, сообщающее свой адрес другому приложению (а так делают все кластерные системы — базы, брокеры, системы обнаружения сервисов), сообщало бы неправильный адрес. Плоское адресное пространство без трансляций устраняет целый класс проблем и позволяет переносить в кластер существующие распределённые приложения без переделки.
Обрати внимание, что в этих требованиях нет ни слова про Service. Service, балансировка и DNS — это отдельный слой поверх плоской сети, и занимается им не CNI, а kube-proxy (или тот, кто его заменяет). Разделение важное: CNI отвечает за связность подов, kube-proxy — за виртуальные адреса сервисов. Плагины вроде Cilium умеют и то и другое, но это две разные задачи.
§2Что такое CNI на самом деле
CNI (Container Network Interface) — не демон и не сервис, а спецификация вызова исполняемого файла. Она удивительно проста, и понимание этой простоты хорошо звучит на собеседовании.
Когда создаётся под, среда исполнения контейнеров (containerd или CRI-O — не kubelet напрямую, это частая неточность) сначала запускает служебный контейнер-«песочницу», который держит сетевое пространство имён пода. Затем она читает конфигурацию из /etc/cni/net.d/, находит указанный там плагин в /opt/cni/bin/ и запускает его как обычную программу, передавая параметры через переменные окружения, а конфигурацию — в стандартный ввод в виде JSON.
Команд всего несколько: ADD при создании, DEL при удалении, CHECK для проверки. Всё. Именно поэтому плагинов так много и написать свой несложно — контракт минимальный.
Плагины можно объединять в цепочку: сначала основной выдаёт адрес, потом дополнительные навешивают что-то сверху. Стандартные вспомогательные плагины — portmap (проброс портов для hostPort), bandwidth (ограничение полосы через аннотации), tuning (правка sysctl внутри пода). В файле конфигурации это выглядит как список plugins.
Отдельно от исполняемого файла у большинства плагинов есть агент-демон на каждом узле (обычно DaemonSet): он занимается тем, что нельзя сделать в момент одного вызова — раздаёт подсети узлам, распространяет маршруты между узлами, следит за NetworkPolicy, поддерживает туннели. Исполняемый файл при вызове часто просто обращается к этому агенту. Поэтому «CNI не работает» почти всегда означает «упал агент», а не «сломался бинарник».
В бою
Если поды застревают в ContainerCreating с ошибкой вида «failed to setup network for sandbox», смотреть надо именно в эту цепочку: жив ли DaemonSet сетевого плагина на этом узле, есть ли файл конфигурации в /etc/cni/net.d/, есть ли бинарник в /opt/cni/bin/. Классический случай после переустановки CNI — в каталоге остались два конфигурационных файла от старого и нового плагина, и containerd берёт первый по алфавиту.
§3Как под подключён к узлу физически
Теперь самое интересное — что реально создаётся на машине. Основа всего — veth-пара: виртуальный кабель с двумя концами. Один конец кладётся в сетевое пространство имён пода и становится там eth0, второй остаётся в пространстве узла и виден как интерфейс с именем вроде vethb2c4f1a@if3 или lxc1234.
Дальше есть два подхода к тому, что делать со вторым концом.
Через мост. Классический вариант (flannel, простые конфигурации): на узле создаётся мост cni0, все хостовые концы veth включаются в него, мосту назначается адрес шлюза подсети узла. Поды на одном узле общаются через мост как машины в одном коммутаторе — с настоящим ARP между ними. Просто и понятно.
Без моста, через маршруты. Вариант Calico и Cilium: моста нет, вместо этого на узле для каждого пода создаётся отдельный маршрут «этот /32-адрес — вот в этот veth». Внутри пода маршрут по умолчанию указывает на несуществующий адрес-заглушку, а узел отвечает на ARP-запросы за него сам (proxy ARP) или используется адрес link-local. Выглядит менее интуитивно, зато нет широковещательного домена, лучше масштабируется и каждый под изолирован по умолчанию.
Посмотреть это своими глазами на узле:
# все хостовые концы veth
$ ip -br link | grep -E 'veth|lxc|cali'
lxc4f2a1b@if12 UP ce:1a:2b:3c:4d:5e
lxc9b1c33@if14 UP 9a:7f:11:22:33:44
# маршруты до подов на этом узле и до подсетей соседних узлов
$ ip route | grep 10.244
10.244.3.17 dev lxc4f2a1b scope link
10.244.3.18 dev lxc9b1c33 scope link
10.244.4.0/24 via 192.168.1.21 dev eth0 # сосед, нативный роутинг
# зайти в сетевой namespace пода без установки утилит в образ
$ PID=$(crictl inspect --output go-template \
--template '{{.info.pid}}' <container-id>)
$ nsenter -t $PID -n ip a
$ nsenter -t $PID -n ss -tulpn
Последний приём стоит запомнить отдельно: nsenter -n позволяет выполнять сетевые команды в пространстве имён пода, пользуясь утилитами узла. Это спасает, когда в образе нет ни ip, ни ss, ни tcpdump — а в нормальных минимальных образах их и не должно быть.
§4Между узлами: маршрутизация против туннелей
Внутри узла всё просто. Вопрос в том, как пакет попадает на другой узел, где живёт под-получатель. Здесь два принципиально разных подхода, и разницу между ними спрашивают часто.
Нативная маршрутизация
Каждому узлу выделяется своя подсеть (скажем, 10.244.3.0/24), и все маршрутизаторы в сети между узлами знают, что эта подсеть живёт за таким-то узлом. Пакет идёт как обычный маршрутизируемый трафик, без всякой упаковки.
Откуда берётся это знание — вариантов несколько. В облаке маршруты прописываются в таблицу маршрутизации VPC через API провайдера. В своей инфраструктуре — через BGP: агент на каждом узле анонсирует свою подсеть соседям или маршрутизаторам стойки (так работают Calico и Cilium в режиме BGP). В простейшем случае, если все узлы в одном L2-сегменте, достаточно статических маршрутов на самих узлах.
Плюсы: нулевой оверхед, полный MTU 1500, пакет виден в сети как есть (что удобно и для отладки, и для сетевых политик на железе). Минус: требует, чтобы сеть между узлами умела маршрутизировать эти подсети — что не всегда возможно, особенно в чужой инфраструктуре или когда узлы в разных подсетях за оборудованием, которое ты не контролируешь.
Оверлей (туннели)
Пакет для пода на другом узле упаковывается целиком внутрь другого пакета, адресованного от узла к узлу. Внешняя сеть видит обычный UDP-трафик между узлами и ничего не знает про адреса подов. На принимающем узле пакет распаковывается и доставляется поду.
Плюсы: работает поверх любой сети, узлы могут быть где угодно, ничего настраивать во внешней инфраструктуре не надо. Минусы: расход процессора на упаковку и распаковку (сильно уменьшился с аппаратной разгрузкой в современных сетевых картах), невидимость реальных адресов для сетевого оборудования, и главное — уменьшенный MTU.
Вот про MTU надо сказать отдельно, потому что это самая частая продовая боль во всей главе. Оверлей откусывает свои байты от тех же 1500: VXLAN — около 50, Geneve — примерно столько же (заголовок переменной длины), IPIP — 20, WireGuard поверх этого — ещё около 60. Если MTU внутри подов настроен неправильно или где-то по пути между узлами он меньше, чем ожидает плагин, получаешь ровно ту картину из главы 1.1: рукопожатие проходит, мелкие запросы летают, а большие ответы молча исчезают. Проверять — ping -M do -s из пода в под на другом узле.
| Режим | MTU подов | Когда выбирают |
|---|---|---|
| Нативный роутинг | 1500 | Все узлы в одной маршрутизируемой сети, есть контроль над ней или BGP |
| VXLAN | ≈1450 | Универсальный вариант по умолчанию, работает везде |
| Geneve | ≈1450 | То же, но с расширяемыми метаданными — использует OVN и Cilium |
| IPIP | 1480 | Легче VXLAN, но только IPv4 и не везде пропускается файрволами |
| + WireGuard / IPsec | ≈1420 и ниже | Когда нужно шифрование трафика между узлами |
§5kube-proxy изнутри: что именно он пишет в ядро
Плоская сеть подов есть — теперь нужен слой сервисов. Этим занимается kube-proxy, DaemonSet на каждом узле. Он следит за объектами Service и EndpointSlice в apiserver и программирует ядро так, чтобы пакет на ClusterIP попал в один из подов. Сам он через себя трафик не пропускает — это важно проговорить, потому что название вводит в заблуждение. Он только настраивает правила, а работу делает ядро.
Режим iptables: цепочка вызовов
Разберём, что происходит с пакетом, отправленным на ClusterIP. Правила выстроены в три уровня, и структура эта логичная:
Про вероятности уже говорилось в главе 3.1: это случайный выбор с равными шансами, а не round robin. Читается цепочка так: с вероятностью 1/3 уходим в первый под; если нет — с вероятностью 1/2 из оставшихся двух во второй; если нет — в третий.
Есть ещё две цепочки, которые полезно узнавать в выводе. KUBE-MARK-MASQ ставит на пакет метку, по которой позже, в POSTROUTING, будет сделан SNAT — это нужно, когда пакет пришёл извне или когда под обращается сам к себе через сервис. И KUBE-NODEPORTS — вход для трафика на порты узла.
# посмотреть цепочку конкретного сервиса
$ iptables -t nat -L KUBE-SERVICES -n --line-numbers | head
$ iptables-save -t nat | grep myapp
# сколько всего правил накопилось — показательное число
$ iptables-save -t nat | wc -l
14237
Режим IPVS
IPVS — встроенный в ядро балансировщик четвёртого уровня, живущий не в списке правил, а в хеш-таблице. kube-proxy в этом режиме создаёт виртуальный сервер на каждый ClusterIP и добавляет к нему реальные серверы — поды. Поиск по хеш-таблице занимает постоянное время независимо от числа сервисов.
Плюс появляются настоящие алгоритмы балансировки: rr (round robin, по умолчанию), lc (least connections), sh (source hashing — липкость по клиенту), варианты с весами. Проверить, что и как балансируется, можно напрямую:
$ ipvsadm -Ln
TCP 10.96.45.12:80 rr
-> 10.244.3.17:8080 Masq 1 142 3 # вес, активные, ожидающие
-> 10.244.4.9:8080 Masq 1 139 1
-> 10.244.5.22:8080 Masq 1 145 2
Стоит знать, что IPVS не отменяет iptables полностью: правила всё равно нужны для маскарадинга, для NodePort и для фильтрации, просто их число не растёт линейно с количеством сервисов. Плюс в этом режиме на узле появляется фиктивный интерфейс kube-ipvs0, на который навешаны все ClusterIP — это нормально и иногда пугает при первом взгляде.
Режим nftables
Более новая альтернатива: nftables — идейный наследник iptables с тем же назначением, но принципиально лучшей структурой данных внутри (наборы и словари вместо линейных списков) и атомарным обновлением правил. Режим появился относительно недавно и в свежих версиях Kubernetes считается рекомендуемым для новых кластеров. Проверяй по своей версии, в каком он статусе.
§6Где обычная схема упирается
Всё описанное отлично работает на кластере из десятка узлов и сотни сервисов. Проблемы начинаются на масштабе, и назвать их — хороший способ показать, что понимаешь не только «как», но и «почему появились альтернативы».
Линейный проход по правилам. В режиме iptables ядро проверяет правила по порядку, пока не найдёт совпадение. Пять тысяч сервисов — это десятки тысяч правил, и первый пакет каждого нового соединения проходит через них последовательно. Растёт задержка установки соединения, растёт нагрузка на процессор.
Дорогое обновление. Хуже, чем сам проход: при изменении набора эндпоинтов kube-proxy должен перестроить правила. Исторически это означало выгрузку и загрузку большого набора целиком, и на крупных кластерах занимало секунды, а в тяжёлых случаях — десятки секунд. Всё это время новые поды не получают трафик, а удалённые продолжают его получать. Отсюда часть проблем с 502 при массовых выкатках. Механизм с тех пор ощутимо улучшили частичными обновлениями, но природа списка никуда не делась.
Балансировка соединений, а не запросов. Про это подробно в главе 3.1: решение принимается на первом пакете, дальше conntrack держит поток. Для HTTP/2 и gRPC это означает прилипание всей нагрузки к одному поду.
Политики по IP-адресам. NetworkPolicy в реализациях на iptables превращаются в правила по адресам подов. Адреса эфемерны, поды пересоздаются постоянно — значит, правила надо непрерывно переписывать. На большом кластере с активными политиками это отдельный источник нагрузки.
Ничего не видно. Когда пакет дропнут правилом, узнать об этом почти невозможно: счётчики есть, но связать их с конкретным соединением и понять причину — задача на полдня с tcpdump и iptables -L -v.
Все четыре проблемы Cilium решает одним ходом — заменой механизма.
§7eBPF: что это и почему это меняет дело
Прежде чем говорить про Cilium, надо понять инструмент, на котором он построен. Объяснить eBPF за минуту — полезный навык сам по себе.
eBPF — это возможность загружать в ядро свои маленькие программы и вешать их на определённые точки, где они будут исполняться при наступлении события. Не модуль ядра, который может уронить систему, и не патч ядра, требующий пересборки, — а программа в ограниченной виртуальной машине с гарантиями безопасности.
Гарантии обеспечивает верификатор: перед загрузкой ядро статически анализирует программу и отвергает всё, что может навредить — бесконечные циклы, обращения за границы памяти, неинициализированные указатели, слишком много инструкций. Если программа прошла проверку, она гарантированно завершится и не уронит ядро. После проверки она компилируется в машинный код (JIT) и работает со скоростью нативного кода ядра.
Хранить состояние программы могут в map — структурах данных ядра (хеш-таблицы, массивы, LRU-кеши), доступных и из программы, и из пользовательского пространства. Это ключевой момент для понимания Cilium: таблица «сервис → список подов» живёт именно в такой хеш-таблице, а не в списке правил.
Точек подключения много, для сети важны эти:
| Точка | Когда срабатывает | Зачем в сети |
|---|---|---|
| XDP | В драйвере сетевой карты, до создания структуры пакета в ядре | Самая ранняя и быстрая точка. Отбрасывание мусора, защита от флуда, балансировка на входе |
| tc (ingress/egress) | На входе и выходе конкретного интерфейса | Основная рабочая точка: тут делается подмена адресов, применяются политики, считается телеметрия |
| cgroup/connect | В момент вызова connect() приложением | Самое интересное: адрес можно подменить до того, как пакет вообще появился |
| sockmap / sockops | На уровне сокетов | Прямая передача данных между двумя локальными сокетами в обход всего стека |
Вот на этой третьей строчке и стоит задержаться, потому что она объясняет главное преимущество подхода.
§8Cilium: что меняется на практике
Балансировка на уровне сокета — без NAT вообще
В обычной схеме приложение подключается к ClusterIP, пакет уходит в стек, там правила делают DNAT на адрес пода, запись оседает в conntrack, а на обратном пути трансляцию надо развернуть. Работы много, и она делается для каждого соединения.
Cilium с включённой заменой kube-proxy поступает иначе. На хук cgroup/connect вешается программа, которая срабатывает в момент системного вызова connect() — то есть до того, как создан хоть один пакет. Она видит, что приложение подключается к ClusterIP, находит в eBPF-таблице список бэкендов, выбирает один и подменяет адрес назначения прямо в структуре сокета. Приложение об этом не знает: с его точки зрения оно подключилось к сервису.
Результат: для соединений от подов к сервисам трансляции адресов не происходит вообще. Меньше работы на каждый пакет, не расходуется таблица conntrack, нет разворота на обратном пути. Плюс поиск бэкенда — это обращение к хеш-таблице, то есть постоянное время независимо от того, десять в кластере сервисов или десять тысяч.
Идентичность вместо адресов
Второе принципиальное отличие касается сетевых политик и решает проблему эфемерности адресов.
Cilium присваивает каждому набору меток числовой идентификатор безопасности. Все поды с метками app=backend, env=prod получают один и тот же идентификатор — скажем, 4021. Политики формулируются в терминах этих идентификаторов: «идентификатору 4021 разрешено обращаться к идентификатору 5108 на порт 5432».
Что это даёт. Пересоздание пода не меняет ничего: адрес новый, а идентификатор тот же, правила переписывать не нужно. Число записей растёт не с количеством подов, а с количеством различных наборов меток — то есть в сотни раз медленнее. При оверлее идентификатор передаётся прямо в метаданных туннельного заголовка, поэтому принимающий узел знает, кто отправитель, не заглядывая ни в какие справочники.
$ cilium identity list
ID LABELS
4021 k8s:app=backend k8s:env=prod k8s:io.kubernetes.pod.namespace=shop
5108 k8s:app=postgres k8s:env=prod k8s:io.kubernetes.pod.namespace=shop
1 reserved:host
2 reserved:world # всё, что вне кластера
Политики седьмого уровня без sidecar
Обычные NetworkPolicy умеют только «этому поду можно на такой порт». CiliumNetworkPolicy расширяет это до содержимого протокола: конкретные HTTP-методы и пути, топики Kafka, а также правила по доменным именам, а не адресам.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
spec:
endpointSelector:
matchLabels: {app: backend}
ingress:
- fromEndpoints:
- matchLabels: {app: frontend}
toPorts:
- ports: [{port: "8080", protocol: TCP}]
rules:
http: # фронту можно только читать
- method: "GET"
path: "/api/v1/products.*"
egress:
- toFQDNs: # наружу — только на этот домен
- matchName: "api.stripe.com"
Здесь важна честность в деталях, потому что на собеседовании могут копнуть: чистым eBPF разобрать HTTP нельзя — верификатор не пропустит разбор произвольного текста. Для правил седьмого уровня Cilium заворачивает трафик во встроенный Envoy, работающий на узле, а не в каждом поде. То есть прокси есть, но он один на узел и включается только для тех потоков, где действительно нужны L7-правила. Это принципиально дешевле, чем sidecar в каждом поде, но и не «магия без прокси».
Что ещё умеет и о чём стоит знать
- Hubble — наблюдаемость на том же eBPF: поток событий обо всех соединениях с указанием источника, назначения, вердикта политики и причины отказа. Это прямой ответ на «ничего не видно» из §6.
- Egress Gateway — весь исходящий трафик выбранных подов выпускать наружу через конкретный узел с фиксированным адресом. Нужно, когда внешняя система пускает по белому списку адресов.
- Cluster Mesh — соединение нескольких кластеров так, что сервисы одного видны в другом, с failover между ними.
- Прозрачное шифрование между узлами через WireGuard или IPsec — включается флагом, не требует изменений в приложениях.
- Bandwidth Manager — честное ограничение полосы на под.
Ограничения тоже надо знать. Нужно достаточно свежее ядро — полная замена kube-proxy требует версий примерно от 4.19 и новее, а отдельные возможности — ещё новее. Порог входа в отладку выше: вместо привычного iptables -L надо осваивать cilium и Hubble. И на старых дистрибутивах с консервативными ядрами это просто не заведётся.
§9Отладка Cilium: что смотреть
# общее состояние агента на узле, включая режим и что заменено
$ kubectl -n kube-system exec ds/cilium -- cilium status --verbose
KubeProxyReplacement: True [eth0 192.168.1.20]
Routing: Network: Native Host: BPF
Masquerading: BPF [eth0]
# поды на этом узле и их идентификаторы, статус политик
$ cilium endpoint list
# таблица балансировки — аналог ipvsadm -Ln
$ cilium service list
ID Frontend Backends
12 10.96.45.12:80 10.244.3.17:8080, 10.244.4.9:8080
# живой поток событий: видно, что дропнуто и почему
$ cilium monitor --type drop
xx drop (Policy denied) flow 0x8f2 to endpoint 1832: \
10.244.3.17:41022 -> 10.244.4.9:5432 tcp SYN
# то же, но по всему кластеру и человеческим языком
$ hubble observe --namespace shop --verdict DROPPED --follow
shop/frontend-x4k2b:41022 -> shop/postgres-0:5432 \
Policy denied DROPPED (TCP Flags: SYN)
Последняя строчка — это то, ради чего половина людей и переходит на Cilium: вместо гадания «почему не коннектится» ты получаешь прямой ответ «политика запретила, вот такое соединение».
Спросят
«Чем Cilium лучше обычного CNI?» — Отвечай механизмом, а не маркетингом: «Он строит датаплейн на eBPF вместо iptables. Три практических следствия. Первое — поиск бэкенда сервиса идёт по хеш-таблице, а не проходом по списку правил, поэтому производительность не падает с ростом числа сервисов и обновление не требует перестройки большого набора. Второе — балансировка делается на хуке connect(), то есть адрес подменяется в сокете до создания пакета, и для трафика под-сервис вообще нет NAT и записей в conntrack. Третье — политики работают по идентификаторам, выведенным из меток, а не по адресам подов, поэтому пересоздание подов не требует переписывания правил. Плюс наблюдаемость через Hubble, где сразу видно, какая политика что дропнула.»
§10Зачем вообще нужен service mesh
Переходим к Istio. Но сначала — вопрос, на который надо уметь отвечать до всякой архитектуры: какую задачу mesh решает и почему её нельзя решить тем, что уже есть.
Представь двадцать микросервисов на четырёх языках, написанных пятью командами. Тебе нужно, чтобы:
- весь трафик между ними был зашифрован и каждый сервис достоверно знал, кто именно к нему обращается;
- при отказе одного сервиса остальные не сыпались лавиной, а корректно повторяли запросы, разрывали цепочку и деградировали;
- можно было пустить 5% трафика на новую версию, не трогая код;
- по всем вызовам была одинаковая телеметрия — задержки, доли ошибок, трассировка сквозь всю цепочку.
Всё это можно реализовать библиотеками внутри приложений. Собственно, так и делали: Hystrix, Ribbon, Finagle. Проблема в том, что библиотеку надо иметь для каждого языка, встроить в каждый сервис и обновлять во всех одновременно. На четырёх языках и двадцати сервисах это отдельный проект с бесконечной поддержкой.
Идея mesh: вынести всё это из приложения в сетевой прокси рядом с ним. Приложение продолжает делать обычный HTTP-запрос к соседнему сервису, ничего не зная ни про TLS, ни про ретраи, ни про метрики. Всем этим занимается прокси, а управляется он централизованно и одинаково для всех языков.
И сразу честная оговорка, которую стоит произнести самому: это не бесплатно. Появляются два дополнительных прокси на пути каждого запроса, потребление памяти, задержка, и заметный рост сложности отладки. Для трёх сервисов оно того не стоит. Об этом отдельно в §17.
§11Istio: из чего состоит
Архитектура делится на две части, и это разделение стоит называть первым.
Плоскость данных — это прокси Envoy, через которые реально течёт трафик. В классическом режиме такой прокси добавляется контейнером в каждый под (sidecar) и перехватывает весь его трафик, входящий и исходящий. Envoy здесь не случайный выбор: он умеет динамически перенастраиваться на лету, не разрывая соединений, и именно на этом строится вся управляемость.
Плоскость управления — istiod, единый компонент (раньше это были три отдельных — Pilot, Citadel, Galley — их слили). Он делает три вещи: следит за объектами Kubernetes и своими CRD и раздаёт прокси конфигурацию; работает удостоверяющим центром, выдавая каждому сервису сертификат; проверяет корректность конфигурации.
§12Как трафик вообще попадает в Envoy
Самый интересный технический вопрос: приложение делает обычный connect() к адресу сервиса — почему запрос оказывается в прокси? Ответ прямо связан с второй и четвёртой главами, и объяснение производит хорошее впечатление.
При создании пода Istio добавляет в него init-контейнер, который запускается раньше остальных и выполняется в том же сетевом пространстве имён. Всё, что он делает, — прописывает в этом пространстве правила iptables, перенаправляющие весь трафик на локальные порты Envoy. Потом он завершается, а правила остаются жить в netns пода.
Порты Envoy стоит знать, они постоянно встречаются в логах и при отладке:
| Порт | Что это |
|---|---|
15001 | Приём перехваченного исходящего трафика |
15006 | Приём перехваченного входящего трафика |
15000 | Административный интерфейс Envoy — вся его текущая конфигурация и статистика |
15020 | Агент: сводные метрики и проверки готовности |
15021 | Проверка здоровья самого sidecar |
15090 | Метрики Envoy в формате Prometheus |
15012 | Порт istiod, куда прокси ходят за конфигурацией и сертификатами |
Важная деталь для тех, кто работает с ограниченными правами: init-контейнеру нужна возможность NET_ADMIN, чтобы править iptables. В строгих окружениях — а в OpenShift это ровно та история про SCC — это проблема. Поэтому существует альтернатива: CNI-плагин Istio, который настраивает те же правила снаружи, на этапе создания сети пода, и тогда привилегированный init-контейнер не нужен вовсе.
§13Полный путь запроса внутри mesh
Соберём всё вместе. Сервис frontend вызывает http://backend:8080/api/orders.
Из этой схемы надо вынести два вывода, которые обычно и проверяют вопросами.
Первый: балансировка перестаёт быть делом Service. На шаге 4 прокси уже знает адреса всех подов и выбирает бэкенд сам, для каждого запроса. ClusterIP и правила kube-proxy в этом пути фактически не участвуют — сервис используется только как способ узнать, кто вообще относится к backend. Поэтому в mesh пропадает проблема прилипания gRPC-трафика к одному поду: балансировка стала L7.
Второй: на пути появились два дополнительных прокси. Каждый — это разбор HTTP, применение правил, запись метрик, шифрование. Отсюда и накладные расходы, о которых в §17.
§14Откуда прокси знает, что делать: xDS и объекты конфигурации
Envoy не читает Kubernetes и не имеет никакой встроенной логики про сервисы. Всю конфигурацию он получает от istiod по протоколу xDS — постоянному gRPC-соединению, по которому istiod присылает обновления при любых изменениях. Никаких перезапусков: конфигурация меняется на лету, соединения не рвутся.
| Часть xDS | Что описывает |
|---|---|
| LDS — listeners | На каких портах слушать и что делать с принятым соединением |
| RDS — routes | Правила маршрутизации HTTP: по хосту, пути, заголовкам — в какой кластер отправить |
| CDS — clusters | Группы бэкендов: алгоритм балансировки, таймауты, настройки TLS, выбивание сбойных |
| EDS — endpoints | Конкретные адреса подов внутри каждого кластера. Обновляется чаще всего |
| SDS — secrets | Сертификаты и ключи. Отдельный канал, чтобы ротация не требовала перезапуска |
А формируется всё это из объектов, которые пишешь ты. Их надо знать по назначению — это самый частый предметный вопрос по Istio.
VirtualService — куда и как направить запрос
Правила маршрутизации: разделение по весам, выбор по заголовкам, переписывание пути, таймауты, повторы, намеренное внесение сбоев для тестирования.
apiVersion: networking.istio.io/v1
kind: VirtualService
spec:
hosts: [backend]
http:
- match: # у кого в заголовке beta — тем v2
- headers:
x-user-group: {exact: beta}
route:
- destination: {host: backend, subset: v2}
- route: # всем остальным — 95 на 5
- destination: {host: backend, subset: v1}
weight: 95
- destination: {host: backend, subset: v2}
weight: 5
timeout: 3s
retries:
attempts: 3
perTryTimeout: 1s
retryOn: 5xx,reset,connect-failure
DestinationRule — что делать с трафиком после выбора направления
Здесь определяются subsets — те самые «версии», на которые ссылается VirtualService, — а также политика балансировки, ограничения на соединения и выбивание сбойных подов.
apiVersion: networking.istio.io/v1
kind: DestinationRule
spec:
host: backend
subsets:
- name: v1
labels: {version: v1} # subset — это просто выборка по меткам
- name: v2
labels: {version: v2}
trafficPolicy:
loadBalancer: {simple: LEAST_REQUEST}
connectionPool:
tcp: {maxConnections: 100}
http: {http2MaxRequests: 1000, maxRequestsPerConnection: 10}
outlierDetection: # автоматическое выбивание сбойных
consecutive5xxErrors: 5
interval: 10s
baseEjectionTime: 30s
maxEjectionPercent: 50
Связка этих двух объектов — самая частая точка ошибки у новичков: VirtualService ссылается на subset, а объявлен subset в DestinationRule. Забыл второй — маршрутизация молча не работает.
Остальные объекты
| Объект | Зачем |
|---|---|
Gateway | Точка входа трафика извне в mesh. Работает в паре с VirtualService и заменяет собой Ingress, давая больше контроля |
ServiceEntry | Добавляет внешнюю систему (базу, чужой API) в реестр mesh — после этого к ней применимы таймауты, ретраи, метрики и политики |
Sidecar | Ограничивает, о каких сервисах прокси вообще знает. Важно для производительности — см. §17 |
PeerAuthentication | Режим mTLS: STRICT, PERMISSIVE или выключено — на весь mesh, namespace или отдельную нагрузку |
AuthorizationPolicy | Кто к кому имеет право обращаться, вплоть до методов и путей |
Telemetry | Настройка метрик, логов доступа и доли трассируемых запросов |
§15mTLS и идентичность сервиса
Самая ценная часть Istio с точки зрения безопасности, и объяснить её механику полезно даже вне контекста mesh.
Обычный TLS проверяет только сервер: клиент убеждается, что говорит с тем, с кем собирался. Взаимный TLS означает, что сертификат предъявляют обе стороны, и сервер тоже достоверно знает, кто к нему пришёл. В mesh это делается автоматически для всего трафика.
Как сервис получает сертификат
Проблема первичного доверия здесь та же, что с секретами из Vault: чтобы получить сертификат, нужно как-то доказать, кто ты. Решается это через токен сервисного аккаунта, который kubelet и так монтирует в под.
Формат идентичности — это стандарт SPIFFE, и он даёт важное свойство: идентичность привязана к сервисному аккаунту, а не к адресу или имени пода. Поэтому политики доступа формулируются осмысленно и переживают любые пересоздания подов.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
spec:
selector:
matchLabels: {app: postgres}
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/shop/sa/backend"]
↑ только сервис backend, и это криптографически доказано
to:
- operation:
ports: ["5432"]
Режим PERMISSIVE и почему он важен
Включить строгий mTLS сразу во всём кластере — верный способ уронить всё, что ещё не под mesh. Поэтому существует промежуточный режим PERMISSIVE: прокси принимает и зашифрованные, и обычные соединения, но сам всегда инициирует зашифрованные.
Это делает миграцию безболезненной: включаешь PERMISSIVE, постепенно добавляешь sidecar сервисам, смотришь по метрикам, что доля незашифрованного трафика упала до нуля, и только тогда переключаешь на STRICT. Понимание этой последовательности — хороший признак практического опыта.
§16Устойчивость: ретраи, таймауты, выбивание сбойных
Вторая по значимости причина ставить mesh — и здесь важно понимать не только как включить, но и как этим навредить.
Таймауты. Задаются в VirtualService и применяются прокси, а не приложением. Ценность в том, что таймаут появляется даже у сервиса, автор которого о нём не подумал, — а именно бесконечное ожидание чаще всего и превращает частичный отказ в общий.
Повторы. Настраиваются там же. Ключевые параметры: сколько попыток, таймаут на каждую попытку отдельно, и при каких условиях повторять. Последнее критично: повторять можно только идемпотентные запросы. Автоматический повтор POST /payments при таймауте — прямой путь к двойному списанию, потому что таймаут не означает, что запрос не выполнился.
Грабли
Повторы перемножаются по цепочке. Если A вызывает B, B вызывает C, и у каждого настроено по три попытки, то один запрос от пользователя может превратиться в девять запросов к C. При деградации C это добивает его окончательно — классический шторм повторов, когда система сама себя убивает попытками восстановиться. Защита: небольшое число попыток, обязательный perTryTimeout, повторы только на верхнем уровне цепочки, и бюджет повторов, ограничивающий их долю от общего трафика.
Выбивание сбойных (outlier detection). Это реализация размыкателя цепи в Istio, и работает она проще, чем принято думать. Envoy считает подряд идущие ошибки от каждого конкретного пода. Набралось указанное количество — под временно исключается из балансировки на заданное время. Каждое следующее исключение того же пода удлиняет паузу. Параметр maxEjectionPercent не даёт выбить сразу всех: если проблема общая, а не в конкретном экземпляре, лучше отправлять трафик в деградировавший сервис, чем никуда.
Отличие от обычной readiness-пробы стоит подчеркнуть, потому что это хороший уточняющий вопрос: проба — это опрос со стороны Kubernetes раз в несколько секунд, и она проверяет, что под говорит о себе. Выбивание — реакция на реальные ошибки в настоящем трафике, мгновенная и с точки зрения конкретного клиента. Они дополняют друг друга.
§17Сколько это стоит и обо что спотыкаются
Раздел, который отличает человека, работавшего с mesh, от прочитавшего документацию. Проговаривать эти вещи стоит самому, не дожидаясь вопроса.
Задержка. Каждый запрос проходит через два дополнительных прокси. Это обычно единицы миллисекунд суммарно, что несущественно для запроса, идущего в базу на 50 мс, и очень существенно для внутреннего вызова, который сам укладывался в 2 мс.
Память и процессор. Sidecar занимает десятки мегабайт памяти в каждом поде. На тысяче подов это уже десятки гигабайт по кластеру — только на прокси. Плюс процессор на разбор HTTP и шифрование.
Взрывной рост конфигурации. По умолчанию каждый прокси получает информацию обо всех сервисах кластера. При тысяче сервисов это огромная конфигурация в каждом из тысячи подов, она долго рассылается и много весит. Лекарство — объект Sidecar, ограничивающий видимость: «прокси этого namespace знает только про свой namespace и вот эти три». На больших кластерах это не тонкая настройка, а обязательное условие работоспособности.
Порядок запуска. Если приложение стартует раньше прокси и сразу лезет в сеть, первые запросы упираются в ещё не готовые правила. Лечится настройкой, заставляющей контейнер приложения ждать готовности sidecar. В свежих версиях Kubernetes это решается штатными init-контейнерами с признаком боковой службы, что заметно чище.
Завершение и Job. Зеркальная проблема: приложение в Job отработало и вышло, а sidecar продолжает работать — и Job никогда не завершается. Историческое лекарство — дёрнуть административный порт прокси при выходе; современное — те же init-контейнеры-sidecar, которые Kubernetes умеет останавливать сам.
Отладка усложняется. Ошибка 503 теперь может прийти от четырёх разных мест: приложения, своего sidecar, чужого sidecar или istiod. Ответы Envoy имеют характерные признаки — заголовок x-envoy-*, флаги в логах доступа вроде UH (нет здоровых бэкендов) или UF (не удалось подключиться), — и читать их приходится учиться.
Обновление самого mesh. Обновление istiod требует перезапуска всех подов, чтобы обновились sidecar. Штатный способ — канареечная установка новой версии control plane рядом и постепенный перевод namespace на неё.
Спросят
«Когда mesh не нужен?» — Честный ответ ценится: «Когда сервисов мало и они на одном языке — там всё то же самое дешевле сделать библиотекой или вообще ingress-контроллером. Когда нет команды, готовой это эксплуатировать: mesh добавляет целый слой, который сам может ломаться. И когда задача одна — например, нужен только mTLS между узлами: это решается прозрачным шифрованием в CNI без всякого mesh. Mesh оправдан, когда нужно сразу несколько вещей — mTLS с идентичностью, L7-маршрутизация, единая телеметрия — на разнородном зоопарке сервисов.»
§18Ambient mode: mesh без sidecar
Ответ Istio на претензии из предыдущего раздела, и про него полезно знать хотя бы на уровне идеи — вопрос «слышал ли ты про ambient» стал появляться.
Идея в том, чтобы разделить mesh на два уровня и не платить за то, чем не пользуешься.
ztunnel — один агент на узел (не на под), который берёт на себя только четвёртый уровень: mTLS, идентичность, базовую телеметрию и политики по адресам и портам. Трафик между узлами он передаёт через собственный туннель поверх взаимно аутентифицированного соединения. Sidecar в подах при этом нет вообще — перехват настраивается на уровне узла.
waypoint proxy — отдельный Envoy, разворачиваемый по необходимости для namespace или конкретной нагрузки, когда нужны возможности седьмого уровня: маршрутизация по путям и заголовкам, повторы, разделение по весам, авторизация по HTTP-методам.
Что это даёт: базовое шифрование и идентичность включаются почти бесплатно и без перезапуска подов, а тяжёлый L7-прокси появляется только там, где реально требуется. Что теряется: часть тонких возможностей sidecar-режима и меньшая зрелость. Режим постепенно доводили до стабильного состояния в последних версиях — проверяй статус в своей.
§19Отладка Istio
# все ли прокси получили актуальную конфигурацию — команда №1
$ istioctl proxy-status
NAME CDS LDS EDS RDS
frontend-7d9f8-x4k2b SYNCED SYNCED SYNCED SYNCED
backend-6c8b7-mn7qp SYNCED SYNCED STALE SYNCED ← не доехало
# что конкретный прокси реально знает про бэкенды
$ istioctl proxy-config endpoints frontend-7d9f8-x4k2b --cluster 'outbound|8080||backend.shop.svc.cluster.local'
ENDPOINT STATUS OUTLIER CHECK
10.244.3.17:8080 HEALTHY OK
10.244.4.9:8080 HEALTHY FAILED # выбит outlier detection
# маршруты, слушатели, кластеры — вся конфигурация Envoy
$ istioctl proxy-config routes frontend-7d9f8-x4k2b
$ istioctl proxy-config listener frontend-7d9f8-x4k2b
$ istioctl proxy-config cluster frontend-7d9f8-x4k2b
# проверить конфигурацию на ошибки и конфликты до применения
$ istioctl analyze -n shop
# лог доступа прокси — там флаги причин отказа
$ kubectl logs frontend-7d9f8-x4k2b -c istio-proxy
"GET /api/orders HTTP/1.1" 503 UH ... # UH = нет здоровых бэкендов
"GET /api/orders HTTP/1.1" 503 UF ... # UF = не смог подключиться
"GET /api/orders HTTP/1.1" 403 - ... # запретила AuthorizationPolicy
Порядок разбора почти всегда один: сначала proxy-status — доехала ли вообще конфигурация; потом proxy-config endpoints — знает ли прокси живые адреса; потом лог доступа с его флагами. Три шага закрывают большинство случаев.
§20Сравнение и как отвечать
| kube-proxy iptables | kube-proxy IPVS | Cilium eBPF | Istio | |
|---|---|---|---|---|
| Уровень | L3/L4 | L3/L4 | L3/L4 (+L7 точечно) | L7 |
| Структура данных | Список правил | Хеш-таблица | eBPF map | Конфигурация Envoy |
| Балансирует | Соединения, случайно | Соединения, rr и др. | Соединения, в сокете | Запросы |
| Рост со числом сервисов | Линейный | Постоянный | Постоянный | Конфигурация растёт |
| Шифрование | Нет | Нет | WireGuard/IPsec между узлами | mTLS между сервисами |
| Политики | По адресам | По адресам | По идентичности из меток | По идентичности сервиса, до путей и методов |
| Ретраи, таймауты | Нет | Нет | Нет | Есть |
| Цена | Ноль | Ноль | Требует свежего ядра | Прокси на каждый под, задержка, сложность |
Про соотношение Cilium и Istio: они не взаимоисключающие. Cilium — это CNI, он занимается связностью и четвёртым уровнем; Istio — надстройка седьмого уровня. Istio прекрасно работает поверх Cilium, и это распространённая комбинация. Пересекаются они частично — L7-политики умеют оба, — и у Cilium есть собственный вариант mesh без sidecar. Выбирать между ними как между альтернативами неправильно; правильно спрашивать, нужен ли вообще седьмой уровень.
Что произнести вслух
«Сеть в Kubernetes состоит из двух независимых слоёв. Первый — связность подов, за неё отвечает CNI. Технически CNI — это спецификация вызова бинарника: среда исполнения создаёт сетевой namespace и запускает плагин, тот создаёт veth-пару, берёт адрес у IPAM и настраивает маршруты. Между узлами связность делается либо нативной маршрутизацией, когда сеть знает подсети узлов через BGP или маршруты облака, либо туннелем — VXLAN или Geneve, — который работает где угодно ценой уменьшенного MTU. И вот с этим MTU связана половина странных сетевых проблем.
Второй слой — сервисы, и это уже kube-proxy. В режиме iptables он строит трёхуровневую цепочку: общий вход, цепочка на сервис с выбором бэкенда по вероятностям, и цепочка на конкретный под с DNAT. Выбор там случайный, а не round robin, и делается один раз на соединение, дальше поток держит conntrack. На больших кластерах это упирается в линейный проход по правилам и дорогое обновление. IPVS решает первое хеш-таблицей, nftables — более современная замена.
Cilium заменяет весь этот датаплейн программами eBPF. Главное отличие — балансировка на хуке connect: адрес сервиса подменяется прямо в сокете, до создания пакета, поэтому для трафика под-сервис нет ни NAT, ни записей в conntrack. Плюс политики работают по идентификаторам, выведенным из меток, а не по адресам подов, так что пересоздание подов не требует переписывать правила. И есть Hubble, который прямо показывает, какая политика что дропнула.
Istio — это уже седьмой уровень. Плоскость данных — Envoy рядом с каждым приложением, плоскость управления — istiod, который раздаёт прокси конфигурацию по xDS и работает удостоверяющим центром. Трафик попадает в прокси через iptables-правила, которые init-контейнер прописывает в netns пода: исходящий заворачивается на 15001, входящий на 15006. Дальше прокси сам решает, куда направить запрос, и балансирует по конкретным подам — то есть по запросам, а не по соединениям, поэтому проблема прилипания gRPC исчезает. Между прокси всегда mTLS, сертификаты выдаёт istiod после проверки токена сервисного аккаунта, а идентичность в них — это SPIFFE-имя вида namespace плюс сервисный аккаунт, на которое и опираются политики доступа. Из практического: повторы перемножаются по цепочке вызовов и могут устроить шторм; конфигурация по умолчанию рассылается всем про всех, поэтому на больших кластерах обязателен объект Sidecar, ограничивающий видимость; и есть ambient-режим, где вместо sidecar в каждом поде работает ztunnel на узле для четвёртого уровня, а L7-прокси поднимается только там, где реально нужен.»