- PVSM.RU - https://www.pvsm.ru -
Все, кто работал c k8s, знают: что такое CNI, разбираются как его подключить и даже поверхностно могут понимать какой из предлагаемых на «рынке» интерфейсов лучше подходит под определенный кейс. Но между поверхностными абстракциями и пониманием того, что фактически происходит с пакетом пропасть. Пока кластер функционирует стабильно эта пропасть не мешает. Она стреляет, когда начинается полтергейст: пакеты теряются между нодами, latency скачет через раз, NetworkPolicy «отказывается работать», но по всем кажущимся метрикам все зеленое.
В этой статье я постараюсь пройти путь пакета руками:
Бонус-загадка [7]
Вся практическая часть выполнялась на 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
Бинари на месте, но перед 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 [9]). А вот 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 не установлен.
Если у вас zsh с oh-my-zsh, то при вставке команд url-quote-magic экранирует «опасные» символы и скобки превращаются в {. На время эксперимента я отключил DISABLE_MAGIC_FUNCTIONS=true в ~/.zshrc, мне так привычнее 🤷.
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
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 “скорость обработки пакетов” как раз и заключается в этой структуре.
Как пакет с 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
Hubble — это observability тулза, встроенная в датаплейн. Достает каждый пакет событий, обогащая его k8s контекстом.
Каноничный путь 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 и никакого мошенничества)
Автор: WrongName
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/devops/457074
Ссылки в тексте:
[1] Минимальная теория перед стартом: #minimalnaya-teoriya-pered-startom
[2] Как собрать кластер за пару минут: #kak-sobrat-klaster-za-paru-minut
[3] Настройка Cilium CNI: #nastroyka-cilium-cni
[4] Что такое бридж и eBPF-программы: #chto-takoe-bridzh-i-ebpf-programmy
[5] Межнодовый хоп — VXLAN: #mezhnodovyy-hop---vxlan
[6] Observability: поймать свой пакет в Hubble: #observability-poymat-svoy-paket-v-hubble
[7] Бонус-загадка: #bonus-zagadka
[8] Зачем это и куда дальше: #zachem-eto-i-kuda-dalshe
[9] CO-RE: https://docs.ebpf.io/concepts/core/
[10] Источник: https://habr.com/ru/articles/1073254/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1073254
Нажмите здесь для печати.