Все, кто работал c k8s, знают: что такое CNI, разбираются как его подключить и даже поверхностно могут понимать какой из предлагаемых на «рынке» интерфейсов лучше подходит под определенный кейс. Но между поверхностными абстракциями и пониманием того, что фактически происходит с пакетом пропасть. Пока кластер функционирует стабильно эта пропасть не мешает. Она стреляет, когда начинается полтергейст: пакеты теряются между нодами, latency скачет через раз, NetworkPolicy «отказывается работать», но по всем кажущимся метрикам все зеленое.
В этой статье я постараюсь пройти путь пакета руками:
Вся практическая часть выполнялась на WSL2. Как известно в рамках WSL используется собственное ядро от Microsoft (в моем случае 6.6.87.2-microsoft-standard-WSL2). Поэтому имеется ряд уникальных моментов с ним, которые будут раскрыты по ходу экспериментов.
Минимальная теория перед стартом
Контейнер — это не виртуальная машина, это обычный linux процесс с урезанным «зрением» через неймспейсы: mount namespace прячет чужую файловую систему, pid namespace - чужие процессы и тд и тп. Для темы текущего поста нужен только один network namespace.
Из этого следует главный факт, без которого k8s сеть не читается. Под — это набор контейнеров, которые делят один network space (технически его держит служебный pause-контейнер, а остальные подключаются к нему. Поэтому контейнеры одного пода видят друг дргуа по localhost, а у пода один IP на всех).
Но nets изолирован. Поэтому возникает следующий вопрос: как из него выбраться? Через veth-пару (виртуальный патчкорд с двумя концами). Один конец — eth0, втыкается в nets пода, второй в nets ноды. Кто и как обрабатывает трафик на нодовом конце — это и есть зона ответственности CNI плагина.
Как собрать кластер за пару минут
Необходим мультинодовый кластер, ведь самое интересное, что нам предстоит выясним именно межнодовый трафик. Kind для такого эксперимента подходит идеально.
curl -Lo ./kind https://github.com/kubernetes-sigs/kind/releases/latest/download/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind kind version curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl sudo mv kubectl /usr/local/bin/kubectl kubectl version --client
грабли №1. проверьте ядро, прежде чем создавать кластер
Бинари на месте, но перед kind create стоит потратить 10 сек на проверку ядра. Cilium использует eBPF (что это мы выясним далее), а eBPF предъявляет требования к ядру, поэтому важно обновиться до необходимой стабильной версии для его поддержки.
uname -r
Если у вас не 4.x смело можно идти дальше.
ls -la /sys/kernel/btf/vmlinux mount | grep bpf -r--r--r-- 1 root root 6050732 Jun 9 11:17 /sys/kernel/btf/vmlinux
Файл BPF на месте(это типовая информация ядра, нужна для CO-RE). А вот bpffs у меня оказался не примонтирован. С одной стороны не сильно значимо, тк Cilium сам монтирует bpffs внутри kind нод самостоятельно. Но для игр с bpftool стоит примонтировать сразу в fstab, чтобы пережить перезапуск WSL.
sudo mount -t bpf bpf /sys/fs/bpf echo 'bpf /sys/fs/bpf bpf defaults 0 0' | sudo tee -a /etc/fstab
Сам кластер
Нам необходим конфиг с тремя нодами и отключенным дефолтным CNI:
# kind-cluster.yaml kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane - role: worker - role: worker networking: disableDefaultCNI: true
kind create cluster --name lab --config kind-cluster.yaml kubectl get nodes
После запуска ноды будут в состоянии NotReady - это нормально, тк пока CNI не установлен.
Настройка Cilium CNI
грабли №2. zsh съедает фигурные скобки?
Если у вас zsh с oh-my-zsh, то при вставке команд url-quote-magic экранирует «опасные» символы и скобки превращаются в \{. На время эксперимента я отключил DISABLE_MAGIC_FUNCTIONS=true в ~/.zshrc, мне так привычнее ?.
Установка Cilium
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt) echo "$CILIUM_CLI_VERSION" # не должно быть пусто curl -L --fail -O https://github.com/cilium/cilium-cli/releases/download/$CILIUM_CLI_VERSION/cilium-linux-amd64.tar.gz curl -L --fail -O https://github.com/cilium/cilium-cli/releases/download/$CILIUM_CLI_VERSION/cilium-linux-amd64.tar.gz.sha256sum sha256sum --check cilium-linux-amd64.tar.gz.sha256sum sudo tar xzvfC cilium-linux-amd64.tar.gz /usr/local/bin rm cilium-linux-amd64.tar.gz cilium-linux-amd64.tar.gz.sha256sum
CLI сам определяет kind и подбирает опции
cilium install cilium status --wait cilium hubble enable
Через 2-3 минуты видим заветный интерфейс:
/¯¯\ /¯¯\__/¯¯\ Cilium: OK \__/¯¯\__/ Operator: OK /¯¯\__/¯¯\ Envoy DaemonSet: OK \__/¯¯\__/ Hubble Relay: OK \__/ ClusterMesh: disabled DaemonSet cilium Desired: 3, Ready: 3/3, Available: 3/3 ... Cluster Pods: 4/4 managed by Cilium Helm chart version: 1.19.3
Проверим повторно все ноды и убедимся, что после установки CNI они все Ready.
kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP ... lab-control-plane Ready control-plane 13m v1.36.1 172.21.0.3 lab-worker Ready <none> 13m v1.36.1 172.21.0.4 lab-worker2 Ready <none> 13m v1.36.1 172.21.0.2
Запомните INTERNAL-IP узлов 172.21.0.x. Это адреса kind-контейнеров, мы еще увидим их в роли внешнего транспорта для наших пакетов.
Важно! Мы берем Cilium в стандартном режиме, оставляя kube-proxy. Cilium берет на себя CNI, NetworkPolicies и Observability, а балансировку Service пока делает kube-proxy через iptables. Полный реплейсмент kube-proxy - тема для отдельной статьи (напишите в комментариях, если желаете расширенную статью).
Подопытные
Ставим два пода на разные worker ноды. Ставим image: netshoot — наш швейцарский нож в данном эксперименте.
# trace-pods.yaml apiVersion: v1 kind: Pod metadata: { name: pod-a, labels: { app: trace } } spec: nodeName: lab-worker containers: [{ name: net, image: nicolaka/netshoot, command: ["sleep","infinity"] }] --- apiVersion: v1 kind: Pod metadata: { name: pod-b, labels: { app: trace } } spec: nodeName: lab-worker2 containers: [{ name: net, image: nicolaka/netshoot, command: ["sleep","infinity"] }]
kubectl apply -f trace-pods.yaml kubectl get pods -o wide
Что такое бридж и eBPF-программы
Взгляд изнутри пода
kubectl exec pod-a -- ip -d addr show eth0 kubectl exec pod-a -- cat /sys/class/net/eth0/iflink
В первом выводе мы видим eth0@if15 (число может быть любым, в данном случае у нас 15). Это подсказка ядра: я veth, а мой второй конец имеет индекс 15 в соседнем неймспейсе. Iflink возвращает тоже число, теперь становится понятно за какую нитку дергать интерфейс.
Второй конец и отсутствующий бридж
«Хост» для пода — это не ваш WSL-шелл, а kind контейнеры ноды. Поэтому:
docker exec -it lab-worker bash # внутри ноды (подставьте свой ifindex): ip link | grep '^15:'
В ответе будет что-то вроде 15: lxc4f2a1b3c@if12. Это так Cilium называет хостовые концы veth. А теперь сюрприз: в классических CNI (flannel, kindset и тп) все veth-концы «воткнуты» в бридж cni0 (это виртуальный L2-коммутатор внутри узла, он коммутирует кадры между хостами, а фильтрацию и NAT выполняют цепочки iptables). Так вот у нас то этого бриджа нет и iptables не используются от слова совсем:
ip link show type bridge # пусто
Вместо бриджа:
ip -br link
… у нас набор интерфейсов Cilium cilium_hostи cilium_net (наша veth-пара), cilium-vxlan(о нем чуть позже), lxc_health и по одному lxc* на каждый под. И кто же тогда между ними гоняет пакеты? eBPF-программы — код, исполняющийся на ядре, без бриджа, без долгого обхода цепочек iptables.
Кратко что делает eBPF, сильно не вдаваясь в подробности:
Проставляет identity источника
Проверяет egress-политику (отправитель -> получатель - allow/deny)
Ведет conntrack
Маршрутизирует трафик
Датаплейн - это мапы
Раз форвардингом занимается eBPF, где же он хранит состояние? В eBPF-мапах, те хеш-таблицах в ядре. Cilium-агент на каждой ноде умеет их показывать:
CILIUM_POD=$(kubectl -n kube-system get pods -l k8s-app=cilium --field-selector spec.nodeName=lab-worker -o name | head -1) kubectl -n kube-system exec $CILIUM_POD -c cilium-agent -- cilium-dbg endpoint list kubectl -n kube-system exec $CILIUM_POD -c cilium-agent -- cilium-dbg bpf ipcache list kubectl -n kube-system exec $CILIUM_POD -c cilium-agent -- cilium-dbg bpf lb list
exndpoint list— каждый под как eBPF энпоинт со своим identiry;bpf ipcache list— мапаIP-подв -> (IP-ноды, identity). Тут eBPF решает стоит ли ему ехать на другую ноду или можно работать локально;bpf lb list— балансировка Services как lookup по хеш-мапе. В нашем случае она пока пустая, тк этим пока занимается kube-proxy.
Что мы доказали: конфигурация сети в Cilium — это не правила iptables, а мапинги ядра. Самый главный плюс Cilium “скорость обработки пакетов” как раз и заключается в этой структуре.
Межнодовый хоп - VXLAN
Как пакет с pod-IP (10.244.x.x) едет по сети докера, которая знает только 172.21.0.x? Ответ:
kubectl -n kube-system exec $CILIUM_POD -c cilium-agent -- cilium-dbg status | grep -i routing
Routing: Network: Tunnel [vxlan] ...
Это режим VXLAN-оверлей. Исходный пакет (10.244.1.x -> 10.244.2.x) заворачивается в UDP-датаграму, у которой внешние адреса — это адреса узлов (172.21.0.4 -> 172.21.0.2). Транспорт везет «конверт» и не заглядывает внутрь. Туда же в заголовок кладется identity источника, чтобы принимающий узел не вычислял ее лишний раз. На той стороне cilium_vxlan распечатывает конверт и отдает исходный пакет в eBPF, а тот в veth принимающего пода. Краткий путь ниже:
pod-a eth0 → lxc* (eBPF: identity, policy, routing) lab-worker1 → cilium_vxlan (инкапсуляция) → eth0 ноды 172.21.0.4 ────── провод (docker network) ────── → eth0 ноды 172.21.0.2 lab-worker2 → cilium_vxlan (декапсуляция) → lxc* (eBPF: ingress policy) → pod-b eth0
Observability: поймать свой пакет в Hubble
Hubble — это observability тулза, встроенная в датаплейн. Достает каждый пакет событий, обогащая его k8s контекстом.
грабли №3. port-forward под WSL2
Каноничный путь cilium hubble port-forward и Hubble CLI у меня умирал:
E0609 ... "Unhandled Error" err="an error occurred forwarding 4245 -> 4245: ... read: connection reset by peer"
… и hubble status отвечал rpc error: ... transport: Error while dialing. Если словили такуюже проблему, не воюйте с ней. Hubble встроен в каждый агент Cilium, поэтому спокойно читается через локальный unix-сокет:
kubectl -n kube-system exec $CILIUM_POD -c cilium-agent -- hubble observe --to-pod default/pod-b --follow
Пинг и разбор строки
Во втором терминале вызовите:
POD_B_IP=$(kubectl get pod pod-b -o jsonpath='{.status.podIP}') kubectl exec pod-a -- ping -c 3 $POD_B_IP PING 10.244.1.87 (10.244.1.87) 56(84) bytes of data. 64 bytes from 10.244.1.87: icmp_seq=1 ttl=63 time=0.261 ms ... 3 packets transmitted, 3 received, 0% packet loss
Тогда в первом терминале появится самая долгожданная строка статьи:
Jun 9 09:56:40.520: default/pod-a (ID:5259) -> default/pod-b (ID:5259) to-overlay FORWARDED (ICMPv4 EchoRequest)
Разберем ее досконально, здесь каждая строка соответствует схеме описанной выше:
default/pod-a -> default/pod-b— имена подов вместо голых IP. eBPF на egress-veth смог опознать обе стороны;to-overlay— вердикт: пакет ушел в VXLAN тоннель. Это подтверждение предыдущего раздела на реальном трафике.FORWARDED— eBPF политика пропустила пакет.ID:5259— security identity. Интересно, что у обоих он одинаковый, тк identity вычисляется не по IP, а по лейблам (у нас оба пода несутapp: trace)
Бонус-загадка
Посмотрите на вывод ping: ttl=63. Изначально netshoot отправляет пакет с TTL=64. Дошло 63, ровно один декремент. При этом чисто технически пакет прошел 8 “этапов” (по схеме описанной выше по 4 с каждой стороны) за один хоп.
Разгадка: VXLAN инкапсуляция прозрачна для внутреннего пакета, условно его никто не маршрутизирует, его просто везут как груз. Единственный L3 переход, который выполняет декремент — это шлюз между pod CIDR-ами. Хорошая картинка для понимания того, сколько хопов видит пакет и сколько реально железа он прошел. Кстати, полезный диагностический факт: неожиданный TTL в кластере — быстрый способ понять, каким путем реально идет трафик.
Зачем это и куда дальше
Главная польза понимания работы CNI достаточно очевидна в проде. Разница между «сеть глючит» и честным быстрым диагнозом в том, есть ли у вас глубокое четкое понимание архитектуры. Зная CNI вам не надо гадать, достаточно планомерно проанализировать CNI сверху-вниз. Каждый слой планомерно отсечет как минимум половину догадок. Как и фишка с ttl, дешевый диагностический маркер — минус головная боль.
Если кластер остался, не спешите сносить, еще осталось два сюжета:
kube-proxy replacement
NetworkPolicy с живыми DROP
Veth, eBPF, VXLAN и никакого мошенничества)