Это перевод статьи Хомаюна (Хюэ) Алимохаммади, опубликованной в блоге 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 пода» — общий адрес для всех его контейнеров. 

Рисунок 1: Поды на узле
Рисунок 1: Поды на узле

Настраивая сетевой неймспейс, CNI-плагин по факту настраивает сеть для всего пода целиком. 

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

Как работает CNI-плагин

Взглянем на диаграмму, чтобы понять, как именно и в какой момент запускается CNI-плагин.

Рисунок 2. Работа CNI-плагина
Рисунок 2. Работа 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-плагин:

Рисунок 3. Запуск  CNI-плагина средой исполнения контейнеров
Рисунок 3. Запуск  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_COMMANDCNI_IFNAME и другие;

  • стандартный поток ввода — сериализованный в JSON объект конфигурации. В основном он базируется на содержимом файла конфигурации, найденного в /etc/cni/net.d.

После выполнения своей задачи CNI-плагин возвращает результат в стандартном формате.

Теперь, когда мы разобрались с механизмом запуска, давайте посмотрим, какие именно действия выполняет плагин для настройки сетевой связности пода.

Последовательность действий плагина CNI

Для начала давайте в общих чертах разберём, что требуется для того, чтобы поды могли «общаться» друг с другом.

Рисунок 4. Высокоуровневая схема коммуникации подов
Рисунок 4. Высокоуровневая схема коммуникации подов

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

Но что делать, если поды находятся на разных узлах? Тут всё решается правильной настройкой iptablesи маршрутов: тогда пакеты из сетевого неймспейса на первом узле смогут добраться до неймспейса на втором.

Рассмотрим этот процесс пошагово. Примерно так и действует CNI-плагин, когда настраивает сетевое окружение и связность подов.

Создание виртуальной Ethernet-пары

Рисунок 5. Создание виртуальной Ethernet-пары
Рисунок 5. Создание виртуальной Ethernet-пары

 Допустим, мы только что создали под. Сразу после того, как среда исполнения подготовит «песочницу», вызывается CNI-плагин с параметром CNI_COMMAND=ADD в переменных окружения.

Получив этот сигнал, исполняемый файл плагина приступает к настройке сетевого окружения. Первый этап — создание пары виртуальных интерфейсов (veth-pair). Имя сетевого интерфейса, который будет находиться внутри «песочницы», передаётся средой исполнения через переменную CNI_IFNAME. Для создания veth-пары используется команда:

ip link add veth_host type veth peer name $CNI_IFNAME

Сетевой неймспейс нужен, чтобы изолировать сеть пода от рутовой сети узла. Изоляция — это круто и полезно, но ведь ещё нужно как-то достучаться до процессов внутри этого сетевого неймспейса (то есть до контейнеров). Поэтому мы берём один конец veth-пары и прокидываем его в «песочницу» пода.

Рисунок 6. Проброс одного конца veth-пары в сетевой неймспейс
Рисунок 6. Проброс одного конца veth-пары в сетевой неймспейс
NETNS=$(basename $CNI_NETNS)
ip link set $CNI_IFNAME netns $NETNS

Назначение IP-адреса поду

Рисунок 7. Назначение IP-адреса концу veth в сетевом неймспейсе
Рисунок 7. Назначение IP-адреса концу veth в сетевом неймспейсе

Теперь необходимо назначить 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-пары, который остался в рутовом неймспейсе узла. Так наши поды на одном узле смогут «общаться» друг с другом.

Рисунок 8. Создание моста
Рисунок 8. Создание моста
BR_NAME=cni0
brctl addbdr $BR_NAME

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

Рисунок 9. Назначение IP-адреса мосту
Рисунок 9. Назначение IP-адреса мосту
BR_NAME=cni0
BR_IP=get_br_ip()
ip addr add $BR_IP/24 dev $BR_NAME

Кроме того, необходимо подключить интерфейс veth-пары, находящийся в рутовом сетевом неймспейсе узла, к мосту.  

Рисунок 10. Подключение veth-пары к мосту
Рисунок 10. Подключение veth-пары к мосту
BR_NAME=cni0
ip link set veth_host master $BR_NAME

Наконец, нужно назначить IP-адрес моста шлюзом по умолчанию в сетевом неймспейсе пода.

Рисунок 11. Установка IP-адреса моста в качестве шлюза по умолчанию для «песочницы» пода
Рисунок 11. Установка IP-адреса моста в качестве шлюза по умолчанию для «песочницы» пода
NETNS=$(basename $CNI_NETNS)
BR_IP=get_br_ip()
ip -n $NETNS route add default via $BR_IP dev $CNI_IFNAME

Наконец, необходимо разрешить входящие соединения для IP-адресов подов, чтобы трафик не блокировался.

Рисунок 12. Разрешение входящих и исходящих соединений для IP подов
Рисунок 12. Разрешение входящих и исходящих соединений для IP подов
iptables -A FORWARD -s $POD_CIDR -j ACCEPT
iptables -A FORWARD -d $POD_CIDR -j ACCEPT

Связь между узлами

Чтобы поды на разных узлах могли «общаться», необходимо организовать передачу пакетов от одного узла к другому. Вариантов много: статическая маршрутизация, VXLAN и прочее. Мы не будем усложнять и остановимся на статической маршрутизации.

Рисунок 13. Соединение узлов с помощью статической маршрутизации
Рисунок 13. Соединение узлов с помощью статической маршрутизации
# На узле 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 пакеты, предназначенные для локального моста.

Рисунок 14. Настройка Source NAT
Рисунок 14. Настройка Source 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.

Благодарности

Статья вряд ли бы появилась без этих крутых ресурсов. Рекомендую изучить их, чтобы глубже погрузиться в тему: 

Полезные ссылки