Раздел 3 · Kubernetes — глава 3.3

Сеть кластера вглубь: CNI, Cilium и Istio

В главе 3.1 сеть кластера была описана в объёме «у каждого пода свой IP, дальше дело CNI». Этого хватает для эксплуатации, но не хватает для разговора, где спрашивают «а как именно». Здесь разбираем по-настоящему: что физически происходит на узле, как устроен kube-proxy и почему на больших кластерах он становится проблемой, что делает Cilium с eBPF вместо него, и — самое подробное — как работает Istio, от iptables внутри пода до выдачи сертификатов.

§1Модель сети: три правила, из которых всё следует

Kubernetes сам не реализует сеть. Он формулирует требования и оставляет реализацию плагину. Требований всего три, но они жёсткие:

  1. У каждого пода свой IP-адрес, и под видит его сам. Внутри пода ip a показывает ровно тот адрес, по которому к нему обращаются снаружи — никакой трансляции.
  2. Любой под может достучаться до любого другого пода на любом узле по этому адресу напрямую, без NAT. Это самое сильное требование, и именно оно отличает модель Kubernetes от классической контейнерной сети Docker с пробросом портов.
  3. Процессы на узле могут достучаться до подов на этом узле и наоборот.

Стоит понять, зачем такая строгость. Если бы поды видели друг друга через 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.

containerd создаёт под │ ├─ создаёт network namespace для пода │ ├─ читает /etc/cni/net.d/10-cilium.conflist │ └─ запускает /opt/cni/bin/cilium-cni CNI_COMMAND=ADD CNI_CONTAINERID=abc123... CNI_NETNS=/proc/4242/ns/net CNI_IFNAME=eth0 stdin: { "type": "cilium-cni", ... } │ ↓ плагин делает всю работу: создаёт veth-пару, кладёт один конец в netns пода, берёт свободный IP у IPAM, вешает адрес и маршруты │ ↓ печатает в stdout результат: { "ips": [{"address": "10.244.3.17/32"}], ... }

Команд всего несколько: 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. Выглядит менее интуитивно, зато нет широковещательного домена, лучше масштабируется и каждый под изолирован по умолчанию.

УЗЕЛ под A ┌──────────────────────────────────────────┐ ┌──────────────────┐ │ │ │ netns пода │ │ eth0 (192.168.1.20) │ │ │ │ │ │ │ eth0 │ │ таблица маршрутов: │ │ 10.244.3.17/32 │ │ 10.244.3.17 dev lxc4f2a ──────────────┼════════┤ default via │ │ 10.244.3.18 dev lxc9b1c ─────┐ │ veth │ 169.254.1.1 │ │ 10.244.4.0/24 via 192.168.1.21│ │ └──────────────────┘ │ │ │ │ ┌────┴───┐ │ под B │ │ lxc9b1c├════╪════════┤ 10.244.3.18/32 │ └────────┘ │ └──────────────────────────────────────────┘ Пакет из пода A в под B на том же узле: выходит через veth → попадает в стек узла → узел смотрит маршрут → 10.244.3.18 живёт в lxc9b1c → отправляет туда → под B. Узел здесь работает как маршрутизатор, а не как коммутатор.

Посмотреть это своими глазами на узле:

# все хостовые концы 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-трафик между узлами и ничего не знает про адреса подов. На принимающем узле пакет распаковывается и доставляется поду.

был пакет: [IP: 10.244.3.17 → 10.244.4.9][TCP][данные] VXLAN упаковал: [IP: 192.168.1.20 → 192.168.1.21] ← адреса УЗЛОВ [UDP порт 8472] [VXLAN-заголовок, VNI] [Eth] [IP: 10.244.3.17 → 10.244.4.9][TCP][данные] ← исходный накладные расходы ≈ 50 байт → MTU внутри подов 1450, а не 1500

Плюсы: работает поверх любой сети, узлы могут быть где угодно, ничего настраивать во внешней инфраструктуре не надо. Минусы: расход процессора на упаковку и распаковку (сильно уменьшился с аппаратной разгрузкой в современных сетевых картах), невидимость реальных адресов для сетевого оборудования, и главное — уменьшенный 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
IPIP1480Легче VXLAN, но только IPv4 и не везде пропускается файрволами
+ WireGuard / IPsec≈1420 и нижеКогда нужно шифрование трафика между узлами

§5kube-proxy изнутри: что именно он пишет в ядро

Плоская сеть подов есть — теперь нужен слой сервисов. Этим занимается kube-proxy, DaemonSet на каждом узле. Он следит за объектами Service и EndpointSlice в apiserver и программирует ядро так, чтобы пакет на ClusterIP попал в один из подов. Сам он через себя трафик не пропускает — это важно проговорить, потому что название вводит в заблуждение. Он только настраивает правила, а работу делает ядро.

Режим iptables: цепочка вызовов

Разберём, что происходит с пакетом, отправленным на ClusterIP. Правила выстроены в три уровня, и структура эта логичная:

пакет на 10.96.45.12:80 (ClusterIP сервиса myapp) │ ▼ KUBE-SERVICES ← общая точка входа, здесь ВСЕ сервисы ├─ dst 10.96.0.10:53 → KUBE-SVC-DNS ├─ dst 10.96.45.12:80 → KUBE-SVC-XZY7 ✓ совпало ├─ dst 10.96.88.4:443 → KUBE-SVC-QWE2 └─ ... по правилу на каждый порт каждого сервиса │ ▼ KUBE-SVC-XZY7 ← выбор бэкенда для ЭТОГО сервиса ├─ statistic random probability 0.33333 → KUBE-SEP-A ├─ statistic random probability 0.50000 → KUBE-SEP-B └─ (без условия) → KUBE-SEP-C │ ▼ KUBE-SEP-A ← конкретный под └─ DNAT --to-destination 10.244.3.17:8080 │ ▼ запись в conntrack: соединение закреплено за этим подом, следующие пакеты пойдут туда напрямую, минуя все правила выше

Про вероятности уже говорилось в главе 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-таблице список бэкендов, выбирает один и подменяет адрес назначения прямо в структуре сокета. Приложение об этом не знает: с его точки зрения оно подключилось к сервису.

обычный путь (iptables) путь Cilium (socket LB) ───────────────────────── ──────────────────────── connect(10.96.45.12:80) connect(10.96.45.12:80) │ │ создан пакет eBPF на cgroup/connect: │ берёт бэкенд из hash map netfilter PREROUTING и переписывает адрес │ проход по правилам в самом сокете DNAT → 10.244.3.17 │ │ сокет сразу подключается запись в conntrack к 10.244.3.17:8080 │ │ ...и разворот на обратном никакого NAT, никакого пути для каждого пакета conntrack для этого потока

Результат: для соединений от подов к сервисам трансляции адресов не происходит вообще. Меньше работы на каждый пакет, не расходуется таблица 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 и раздаёт прокси конфигурацию; работает удостоверяющим центром, выдавая каждому сервису сертификат; проверяет корректность конфигурации.

┌──────────────── istiod ────────────────┐ │ • следит за Service, Endpoint, CRD │ │ • раздаёт конфигурацию прокси (xDS) │ │ • удостоверяющий центр (выдаёт certs) │ └───┬────────────────────────────┬────────┘ │ xDS, постоянный gRPC │ ┌───────────┴─────────┐ ┌───────────┴─────────┐ │ под frontend │ │ под backend │ │ ┌────────┐ ┌──────┐ │ │ ┌──────┐ ┌────────┐ │ │ │ прило- │ │Envoy │ │ mTLS │ │Envoy │ │ прило- │ │ │ │ жение ├─┤sidecar├─┼══════┼─┤sidecar├─┤ жение │ │ │ └────────┘ └──────┘ │ │ └──────┘ └────────┘ │ └─────────────────────┘ └─────────────────────┘ трафик идёт через прокси, приложения об этом не знают

§12Как трафик вообще попадает в Envoy

Самый интересный технический вопрос: приложение делает обычный connect() к адресу сервиса — почему запрос оказывается в прокси? Ответ прямо связан с второй и четвёртой главами, и объяснение производит хорошее впечатление.

При создании пода Istio добавляет в него init-контейнер, который запускается раньше остальных и выполняется в том же сетевом пространстве имён. Всё, что он делает, — прописывает в этом пространстве правила iptables, перенаправляющие весь трафик на локальные порты Envoy. Потом он завершается, а правила остаются жить в netns пода.

внутри сетевого namespace пода: ИСХОДЯЩИЙ трафик приложения приложение → connect(backend:8080) → iptables OUTPUT: REDIRECT на порт 15001 → Envoy принимает, смотрит исходный адрес назначения, решает куда и как отправить, устанавливает своё соединение → уходит из пода ВХОДЯЩИЙ трафик пришёл на под → iptables PREROUTING: REDIRECT на порт 15006 → Envoy: проверяет mTLS, применяет политики доступа, пишет телеметрию → передаёт приложению на 127.0.0.1:8080 исключения из перехвата (не заворачиваются): • трафик от самого Envoy (по UID процесса — иначе петля) • порты проб и телеметрии • адреса и порты, исключённые аннотациями

Порты 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.

1. приложение frontend делает обычный HTTP-запрос на backend:8080 (DNS-имя резолвится в ClusterIP сервиса — как обычно) 2. iptables в netns пода: REDIRECT → 127.0.0.1:15001 3. Envoy frontend'а принимает и определяет исходное назначение. Ищет подходящий listener → route → cluster: • есть ли VirtualService с правилами для backend? • match по заголовку, пути, весам версий • какие таймауты и ретраи заданы 4. выбран cluster "backend, версия v2". Из EDS-данных Envoy знает ВСЕ адреса подов backend и балансирует между ними САМ, по конкретным запросам, а не по соединениям. ClusterIP сервиса здесь уже не используется. 5. Envoy устанавливает mTLS-соединение напрямую с IP пода backend. Предъявляет свой сертификат, проверяет чужой. 6. пакет летит по сети кластера (обычный CNI, §3–4) на под backend 7. iptables в netns backend: REDIRECT → 127.0.0.1:15006 8. Envoy backend'а: завершает TLS, достаёт из сертификата, КТО клиент, проверяет AuthorizationPolicy, пишет метрики и трассировку 9. передаёт приложению на 127.0.0.1:8080 обычным HTTP 10. ответ идёт тем же путём в обратном порядке

Из этой схемы надо вынести два вывода, которые обычно и проверяют вопросами.

Первый: балансировка перестаёт быть делом 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 и так монтирует в под.

1. агент внутри пода генерирует пару ключей 2. формирует запрос на сертификат (CSR) 3. отправляет его в istiod, прикладывая токен своего сервисного аккаунта 4. istiod проверяет токен через apiserver кластера: «этот токен настоящий? какому сервисному аккаунту в каком namespace он принадлежит?» 5. получив подтверждение, istiod подписывает сертификат, в котором записана идентичность: spiffe://cluster.local/ns/shop/sa/backend │ │ namespace сервисный аккаунт 6. сертификат передаётся Envoy по каналу SDS — в памяти, не через Secret в кластере 7. срок жизни короткий (по умолчанию около суток), ротация автоматическая и без перезапуска пода

Формат идентичности — это стандарт 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-методам.

классический sidecar-режим ambient ────────────────────────── ─────────────────────────── под = приложение + Envoy под = только приложение на каждом поде │ │ ztunnel на узле → L4, mTLS всё сразу, для всех │ waypoint (Envoy) → L7, только там, где нужен

Что это даёт: базовое шифрование и идентичность включаются почти бесплатно и без перезапуска подов, а тяжёлый 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 iptableskube-proxy IPVSCilium eBPFIstio
УровеньL3/L4L3/L4L3/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-прокси поднимается только там, где реально нужен.»