Это перевод статьи Хомаюна (Хюэ) Алимохаммади, опубликованной в блоге ITNEXT на Medium. В ней детально разобран механизм работы CNI в Kubernetes-кластере, а также показан пример простого CNI-плагина.
Один из ключевых аспектов работы с Kubernetes — правильная настройка сетевой конфигурации кластера, чтобы поды могли связываться друг с другом и глобальной сетью. В этой статье мы познакомимся с Container Network Interface (CNI) и плагинами CNI, поговорим о том, что и как они должны делать. А ещё мы напишем простую реализацию CNI на Go и Bash и проверим её в деле в кластере Canonical Kubernetes.
Статья вдохновлена множеством отличных материалов, ссылки на которые приведены в разделах «Благодарности» и «Полезные ссылки» в конце статьи. Обратите на них внимание, если хотите лучше разобраться в деталях.
Определения и зоны ответственности
CNI (где «I» — это интерфейс) — это стандарт, по которому среда исполнения контейнеров (container runtime) взаимодействует с сетевыми инструментами (реализациями) для настройки сети в подах и обеспечения их связности. Подробности этого взаимодействия закреплены в спецификации CNI.
CNI-плагин, который и является реализацией этого интерфейса, обычно состоит из двух компонентов:
Исполняемый файл — отвечает за сетевую конфигурацию конкретного пода. Именно к нему обращается среда исполнения контейнеров.
Демон — настраивает маршрутизацию трафика между узлами кластера.
Если просто, CNI-плагин следит за тем, чтобы поды могли свободно «общаться» друг с другом и с глобальной сетью. Если чуть подробнее, он отвечает за:
управление IP-адресами (IPAM) — назначение IP-адресов новым подам и их освобождение при удалении подов;
настройку маршрутизации — доступ подов к интернету или другим подам на других узлах;
конфигурацию сети в поде — создание сетевых интерфейсов в сетевом неймспейсе (
network namespace) пода и обеспечение локальной связности (то есть коммуникация подов на одном узле).
Пока выглядит так, будто CNI настраивает сеть для подов, а не для контейнеров… Давайте проясним этот момент.
Под или контейнер?
Название говорит про «Container Network Interface», но сеть мы настраиваем для подов. Почему так?
Дело в том, что под может состоять из нескольких контейнеров. И все они живут в одном сетевом неймспейсе. Так что «IP пода» — общий адрес для всех его контейнеров.

Настраивая сетевой неймспейс, CNI-плагин по факту настраивает сеть для всего пода целиком.
Хорошо, с тем, что CNI «настраивает сеть пода», разобрались. Но как это работает на практике? В какой момент вообще плагин вступает в игру?
Как работает CNI-плагин
Взглянем на диаграмму, чтобы понять, как именно и в какой момент запускается CNI-плагин.

Если совсем просто: когда мы создаём под, применяя манифест, он ещё не привязан ни к одному узлу — поле nodeName пусто (если только мы не задали его вручную). Планировщик kube-scheduler видит новый под без nodeName, подбирает подходящий узел и прописывает nodeName: selected-node в манифесте пода. Как только это происходит, kubelet на выбранном узле видит новый под. Так как тот ещё не запущен, kubelet через CRI-интерфейс обращается к среде исполнения контейнеров и инициирует процесс создания пода и его контейнеров.
Первым делом среда исполнения контейнеров создаёт так называемую песочницу (pod sandbox). Что это такое? В манифесте пода мы описываем один или несколько контейнеров, и создание этих контейнеров — задача container runtime. Но перед тем как создать основные контейнеры, среда исполнения контейнеров запускает вспомогательный — его в списке контейнеров пода нет. Я бы назвал его «заглушка» (placeholder). Этот легковесный контейнер запускает образ «pause», поэтому его часто называют «pause-контейнер» или «песочница». Его роль — удерживать сетевой неймспейс и помогать управлять жизненным циклом пода.
Упражнение: попробуйте найти этот контейнер в работающем кластере с помощью инструментов ctr или crictl. Это будет не так просто.
Чтобы создать pause-контейнер, среда исполнения контейнеров также должна создать сетевой неймспейс — чтобы изолировать сеть пода (и его контейнеров) от хоста (узла). Этот сетевой неймспейсбудет общим для всех контейнеров в поде. Поэтому достаточно настроить подключение только для этого сетевого неймспейса, и сеть будет настроена для всего пода целиком.
Теперь, когда среда исполнения контейнеров создала «песочницу», она запускает CNI-плагин, передавая ему всю необходимую информацию для настройки сети. После того как CNI-плагин завершит работу, он отправит обратно в среду исполнения нужную информацию (например, IP-адрес, который должен получить под).
Давайте углубимся и посмотрим, как именно вызывается CNI-плагин и что он возвращает в ответе.
Запуск CNI-плагина
Схема ниже поможет понять, как среда исполнения контейнеров запускает CNI-плагин:

Тут важны два пути:
директория с конфигурацией CNI-плагинов (по умолчанию это
/etc/cni/net.d);директория с исполняемыми файлами CNI-плагинов (по умолчанию это
/opt/cni/bin).
Сначала среда исполнения контейнеров заглядывает в директорию конфигурации и загружает содержимое конфиг-файлов. Стоит отметить, что containerd требует, чтобы у файлов были определённые расширения, а их JSON-содержимое должно иметь определённую структуру. Предполагается, что эти файлы были заранее размещены там сторонним процессом (например, демоном или администратором).
Количество загружаемых конфигов зависит от настроек самого container runtime. Например, containerd загружает все найденные в директории файлы. Для простоты мы будем рассматривать сценарий, где используется только один файл конфигурации CNI-плагина.
В этой статье мы сфокусируемся на двух полях: cniVersion и type. Поле cniVersion подсказывает среде исполнения контейнеров, как взаимодействовать с плагином в соответствии со спецификацией CNI. Поле type — это имя исполняемого файла CNI-плагина, расположенного в соответствующей директории (/opt/cni/bin по умолчанию). Его среда исполнения и будет запускать.
Теперь, когда среда исполнения знает, какой конкретно файл нужно запустить, необходимо понять механизм его работы. Исполняемому файлу доступны два источника информации:
переменные окружения — эти переменные определены в спецификации CNI, например
CNI_COMMAND,CNI_IFNAMEи другие;стандартный поток ввода — сериализованный в JSON объект конфигурации. В основном он базируется на содержимом файла конфигурации, найденного в
/etc/cni/net.d.
После выполнения своей задачи CNI-плагин возвращает результат в стандартном формате.
Теперь, когда мы разобрались с механизмом запуска, давайте посмотрим, какие именно действия выполняет плагин для настройки сетевой связности пода.
Последовательность действий плагина CNI
Для начала давайте в общих чертах разберём, что требуется для того, чтобы поды могли «общаться» друг с другом.

Поды используют изолированные сетевые неймспейсы. Чтобы поды на одном узле могли связываться друг с другом, для каждого пода создаётся «виртуальная Ethernet-пара» (veth-pair). Эту пару можно представить как портал: всё, что входит с одной её стороны, немедленно выходит из другой. Один конец veth-пары мы пробрасываем внутрь сетевого неймспейса пода, а второй — подключаем к «мосту» (bridge), который работает как обычный аппаратный свитч. Если подключить концы всех veth-пар к одному мосту, пакеты смогут спокойно бегать между неймспейсами разных подов на одном узле.
Но что делать, если поды находятся на разных узлах? Тут всё решается правильной настройкой iptablesи маршрутов: тогда пакеты из сетевого неймспейса на первом узле смогут добраться до неймспейса на втором.
Рассмотрим этот процесс пошагово. Примерно так и действует CNI-плагин, когда настраивает сетевое окружение и связность подов.
Создание виртуальной Ethernet-пары

Допустим, мы только что создали под. Сразу после того, как среда исполнения подготовит «песочницу», вызывается CNI-плагин с параметром CNI_COMMAND=ADD в переменных окружения.
Получив этот сигнал, исполняемый файл плагина приступает к настройке сетевого окружения. Первый этап — создание пары виртуальных интерфейсов (veth-pair). Имя сетевого интерфейса, который будет находиться внутри «песочницы», передаётся средой исполнения через переменную CNI_IFNAME. Для создания veth-пары используется команда:
ip link add veth_host type veth peer name $CNI_IFNAME
Сетевой неймспейс нужен, чтобы изолировать сеть пода от рутовой сети узла. Изоляция — это круто и полезно, но ведь ещё нужно как-то достучаться до процессов внутри этого сетевого неймспейса (то есть до контейнеров). Поэтому мы берём один конец veth-пары и прокидываем его в «песочницу» пода.

NETNS=$(basename $CNI_NETNS) ip link set $CNI_IFNAME netns $NETNS
Назначение IP-адреса поду

Теперь необходимо назначить IP-адрес тому концу veth-пары, который находится внутри сетевого неймспейса пода. Именно этот адрес в дальнейшем будет называться «IP пода»; его можно увидеть при выполнении команды kubectl get pods -o wide:
NETNS=$(basename $CNI_NETNS) IP=allocate_ip() ip -n $NETNS addr add $IP/24 dev $CNI_IFNAME
Адрес нужно взять из диапазона, выделенного конкретному узлу. В этой статье мы не будем глубоко погружаться в тему управления адресами, но вообще-то это одна из ключевых задач CNI-плагина.
Создание моста
Теперь нужно создать мост. К нему мы подключим тот конец veth-пары, который остался в рутовом неймспейсе узла. Так наши поды на одном узле смогут «общаться» друг с другом.

BR_NAME=cni0 brctl addbdr $BR_NAME
Мосту также нужно присвоить IP-адрес и настроить его как основной шлюз (default gateway) для сетевого неймспейса пода.

BR_NAME=cni0 BR_IP=get_br_ip() ip addr add $BR_IP/24 dev $BR_NAME
Кроме того, необходимо подключить интерфейс veth-пары, находящийся в рутовом сетевом неймспейсе узла, к мосту.

BR_NAME=cni0 ip link set veth_host master $BR_NAME
Наконец, нужно назначить IP-адрес моста шлюзом по умолчанию в сетевом неймспейсе пода.

NETNS=$(basename $CNI_NETNS) BR_IP=get_br_ip() ip -n $NETNS route add default via $BR_IP dev $CNI_IFNAME
Наконец, необходимо разрешить входящие соединения для IP-адресов подов, чтобы трафик не блокировался.

iptables -A FORWARD -s $POD_CIDR -j ACCEPT iptables -A FORWARD -d $POD_CIDR -j ACCEPT
Связь между узлами
Чтобы поды на разных узлах могли «общаться», необходимо организовать передачу пакетов от одного узла к другому. Вариантов много: статическая маршрутизация, VXLAN и прочее. Мы не будем усложнять и остановимся на статической маршрутизации.

# На узле A. ip route add $NODE_B_POD_CIDR via $NODE_B_IP # На узле B. ip route add $NODE_A_POD_CIDR via $NODE_A_IP
Доступ к внешней сети
Последний шаг — настройка доступа в интернет. Сейчас у пакетов, выходящих из пода, указан его внутренний IP. Внешние серверы в интернете не смогут ответить на такой адрес напрямую. Чтобы это исправить, используется Source NAT: он заменяет исходный IP пода на публичный IP узла. При этом нужно исключить из NAT пакеты, предназначенные для локального моста.

iptables -t nat -A POSTROUTING -s $POD_CIDR ! -o $BR_NAME -j MASQUERADE
Собираем всё вместе
Полный код этого CNI-плагина вместе с конфигами и полезными скриптами лежит в репозитории на GitHub.
Для демонстрации я использовал кластер Canonical Kubernetes из двух узлов, развёрнутый в контейнерах LXD.
lxc launch -p default -p k8s ubuntu:22.04 node1 lxc launch -p default -p k8s ubuntu:22.04 node2 lxc exec node1 -- snap install k8s --classic lxc exec node2 -- snap install k8s --classic
Сначала необходимо инициализировать кластер с кастомной конфигурацией, чтобы предотвратить автоматическую установку CNI-плагина:
$lxc shell node1 node1$ cat config.yaml cluster-config: network: enabled: false metrics-server: enabled: false node1$ k8s bootstrap --file config.yaml
Примечание: файл конфигурации для деплоя можно найти в директории
demoрепозитория.
Теперь подключаем второй узел к кластеру.
node1$ k8s get-join-token node2 <JOIN_TOKEN> node2$ k8s join-cluster <JOIN_TOKEN>
Проверьте, что кластер работает (правда, пока не на полную, так как CNI-плагин отсутствует).
node1$ k8s status cluster status: ready control plane nodes: <node1-ip>:6400 (voter), <node2-ip>:6400 (spare) high availability: no datastore: k8s-dqlite network: disabled dns: disabled ingress: disabled load-balancer: disabled local-storage: disabled gateway: disabled node1$ k8s kubectl get nodes NAME STATUS ROLES AGE VERSION INTERNAL-IP node NotReady control-plane,worker 3m v1.32.2 <node1-ip> node2 NotReady control-plane,worker 2m v1.32.2 <node2-ip>
Теперь необходимо раскидать необходимые файлы по соответствующим директориям на узлах. В репозитории доступны реализации на Go и Bash. При выборе Go-версии соберите исполняемый файл, выполнив команду make в директории с модулем (go).
Независимо от выбора (Bash или Go) на следующем этапе необходимо скопировать бинарный файл плагина в директорию /opt/cni/bin. Убедитесь, что файл является исполняемым и на каждом узлеустановлена утилита jq.
node1$ /opt/cni/bin# ls -al | grep microcni -rwxr-xr-x 1 root root 2581 Mar 17 21:08 microcni node1$ which jq /usr/bin/jq
Также скопируйте файл microcni.conf в etc/cni/net.d. Убедитесь, что поле type в конфиге совпадает с именем исполняемого файла (например, microcni).
node1$ /etc/cni/net.d# cat microcni.conf { "cniVersion": "0.3.1", "name": "microcni", "type": "microcni", "podcidr": "<node1-pod-cidr>" }
Теперь нужно настроить маршруты IP и правила iptables. Для этого запустите скрипт init.sh на каждом узле, указав соответствующие CIDR пода и IP-адреса для каждого узла.
node1$ chmod +x init.sh node1$ ./init.sh node1$ cat init.sh #!/bin/bash pod_cidr=<node1-pod-cidr> # Разрешаем связь между подами iptables -A FORWARD -s $pod_cidr -j ACCEPT iptables -A FORWARD -d $pod_cidr -j ACCEPT # Разрешаем связь между хостами # Раскомментируйте следующие строки и замените <other-node-pod-cidr> и # <other-node-ip> на CIDR пода IP-адрес другого узла (узлов)) # ip route add <other-node-pod-cidr>/24 via <other-node-ip> dev eth0 # ... # Разрешаем исходящий доступ в интернет iptables -t nat -A POSTROUTING -s $pod_cidr ! -o cni0 -j MASQUERADE
Теперь, когда всё настроено, создадим несколько подов и проверим, как они работают и есть ли связь между ними и интернетом:
k8s kubectl run nginx-1 --image=nginx --restart=Never --overrides='{"spec": {"nodeName": "node1"}}' k8s kubectl run nginx-2 --image=nginx --restart=Never --overrides='{"spec": {"nodeName": "node1"}}' k8s kubectl run nginx-3 --image=nginx --restart=Never --overrides='{"spec": {"nodeName": "node2"}}'
Сначала убедимся, что поды успешно запустились:
node1$ k8s kubectl get pods -A NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE default nginx-1 1/1 Running 0 10m <nginx-1-ip> node1 default nginx-2 1/1 Running 0 11m <nginx-2-ip> node1 default nginx-3 1/1 Running 0 9m <nginx-3-ip> node2
Теперь, чтобы проверить, что поды могут взаимодействовать друг с другом, зайдём в nginx-1 на первом узле (node1) и выполним ping остальных подов с nginx:
k8s kubectl exec -it nginx-1 -- /bin/bash nginx-1$ apt install iputils-ping nginx-1$ ping 8.8.8.8 # should be successful nginx-1$ ping <nginx-2-ip> # should be successful nginx-1$ ping <nginx-3-ip> # should be successful
Готово! Поды могут связываться друг с другом как на одном узле, так и между разными узлами, а также имеют доступ к интернету.
Примечание: если что-то пошло не так, изучите логи CNI-плагина (вероятно,
/var/log/cni.log, уточните в реализации на GitHub) или логи containerd.
journalctl -u snap.k8s.containerd | less
Заключительные мысли
Спасибо, что дочитали до конца! Надеюсь, вам понравилось.
Связаться со мной можно в Twitter (X) или LinkedIn.
Благодарности
Статья вряд ли бы появилась без этих крутых ресурсов. Рекомендую изучить их, чтобы глубже погрузиться в тему:
Demystifying CNI: Writing a CNI from scratch — Filip Nikolic
Kubernetes Networking: How to Write a CNI Plugin From Scratch — Eran Yanay, Twistlock
