
Привет, Хабр! Меня зовут Глеб Когтев, я руководитель команды VPC Host Components, которая занимается разработкой виртуальной облачной сети в облаке MWS (IaaS). C этой статьёй мне помогал Юрий Кондратов — SRE в команде Kubernetes Operations, Research & Engineering (KORE).
Сегодня поговорим об устройстве сети в Kubernetes-кластере, немного о нашем подходе к обеспечению связности сервисов и чем нам был полезен Multus CNI. Статья будет полезна тем, кто использует Kubernetes в своих задачах и хочет разобраться в устройстве сети, а ещё во взаимодействии компонентов K8s с контейнерами.
Основные компоненты Kubernetes-кластера
Прежде чем мы погрузимся в детали сетевого взаимодействия в Kubernetes, хотелось бы начать с самого верхнего уровня и поговорить об основных компонентах K8s-кластера.
Если вы мастер в настройке Kubernetes-кластеров, переходите напрямую к разделу про наш опыт применения Multus CNI в разработке новой облачной платформы MWS.

Каждый кластер Kubernetes состоит из Control Plane и рабочих узлов (Node), каждый из которых в свою очередь включает в себя несколько компонентов:
Control Plane. Главный управляющий компонент Kubernetes, отвечающий за управление и контроль всего кластера. Он может состоять из нескольких узлов для обеспечения высокой доступности.
Kube-apiserver. Предоставляет API-интерфейс для взаимодействия с кластером. Он действует как основной коммутирующий узел, через который все компоненты взаимодействуют друг с другом и получают информацию о состоянии кластера.
Etcd. Децентрализованное хранилище типа ключ-значение, служащее для сохранения всех данных конфигурации кластера и состояния системы.
Kube-scheduler. Компонент, отвечающий за принятие решений о распределении подов на узлах кластера в зависимости от их ресурсных потребностей и доступных ресурсов.
Kube-controller-manager. Отвечает за выполнение различных процессов управления, таких как поддержание желаемого состояния подов и других ресурсов кластера.
Cloud-controller-manager. Если кластер работает в облаке, этот компонент взаимодействует с API облачного провайдера для управления облачными ресурсами, такими как балансировщики нагрузки.
Node. Узлы выполняют запущенные поды и содержат контейнеры приложений.
Kubelet. Агент, который отвечает за создание, обновление и удаление контейнеров в подах, а также сообщает о состоянии узла в Control Plane.
Kube-proxy. Сетевой прокси, который обеспечивает сетевое взаимодействие и маршрутизацию трафика до подов в кластере, поддерживается сетевыми правилами и балансировкой нагрузки.
CRI (Container Runtime). Платформа для запуска контейнеров, например Docker, containerd или CRI-O. Она поддерживает запуск контейнеров и управление ими.
Pod. Базовая единица развёртывания в Kubernetes. Pod представляет собой один или несколько контейнеров, которые работают вместе на одном узле и имеют общие ресурсы, такие как сетевые интерфейсы и файловая система.
В рамках этой статьи сфокусируемся на узлах (node) кластера Kubernetes.
Kubelet
На каждом узле Kubernetes расположен kubelet — специальный агент, запущенный внутри операционной системы, который взаимодействует с kube-api-server и поддерживает контейнеры, запущенные на узле, в консистентном состоянии согласно PodSpec.
Здесь важно отметить, что Pod — абстракция Kubernetes; физически на уровне узлов Pod представляет собой набор контейнеров с общим хранилищем и сетевыми ресурсами, а kubelet, в свою очередь, управляет жизненным циклом контейнеров и ресурсов через Container Runtime, удовлетворяющий CRI (Container Runtime Interface).
Container Runtime
Для управления жизненным циклом контейнеров на каждом узле Kubernetes нужно установить компонент Container Runtime. Мы в MWS в качестве такого решения используем containerd как наиболее популярный CR. Вы можете использовать другую реализацию, например cri-o, или даже написать свою собственную. Важное условие — реализация должна соответствовать CRI (Container Runtime Interface) для того, чтобы kubelet мог интегрироваться с вашим решением без необходимости его дорабатывать.
CRI API описано в формате Protobuf и выглядит следующим образом
// Runtime service defines the public APIs for remote container runtimes
service RuntimeService {
// Version returns the runtime name, runtime version, and runtime API version.
rpc Version(VersionRequest) returns (VersionResponse) {}
// RunPodSandbox creates and starts a pod-level sandbox. Runtimes must ensure
// the sandbox is in the ready state on success.
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {}
// StopPodSandbox stops any running process that is part of the sandbox and
// reclaims network resources (e.g., IP addresses) allocated to the sandbox.
// If there are any running containers in the sandbox, they must be forcibly
// terminated.
// This call is idempotent, and must not return an error if all relevant
// resources have already been reclaimed. kubelet will call StopPodSandbox
// at least once before calling RemovePodSandbox. It will also attempt to
// reclaim resources eagerly, as soon as a sandbox is not needed. Hence,
// multiple StopPodSandbox calls are expected.
rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {}
// RemovePodSandbox removes the sandbox. If there are any running containers
// in the sandbox, they must be forcibly terminated and removed.
// This call is idempotent, and must not return an error if the sandbox has
// already been removed.
rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {}
// PodSandboxStatus returns the status of the PodSandbox. If the PodSandbox is not
// present, returns an error.
rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {}
// ListPodSandbox returns a list of PodSandboxes.
rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {}
// CreateContainer creates a new container in specified PodSandbox
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {}
// StartContainer starts the container.
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {}
// StopContainer stops a running container with a grace period (i.e., timeout).
// This call is idempotent, and must not return an error if the container has
// already been stopped.
// The runtime must forcibly kill the container after the grace period is
// reached.
rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {}
// RemoveContainer removes the container. If the container is running, the
// container must be forcibly removed.
// This call is idempotent, and must not return an error if the container has
// already been removed.
rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {}
// ListContainers lists all containers by filters.
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {}
// ContainerStatus returns status of the container. If the container is not
// present, returns an error.
rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {}
// UpdateContainerResources updates ContainerConfig of the container.
rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {}
// ReopenContainerLog asks runtime to reopen the stdout/stderr log file
// for the container. This is often called after the log file has been
// rotated. If the container is not running, container runtime can choose
// to either create a new log file and return nil, or return an error.
// Once it returns error, new container log file MUST NOT be created.
rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {}
// ExecSync runs a command in a container synchronously.
rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
// Exec prepares a streaming endpoint to execute a command in the container.
rpc Exec(ExecRequest) returns (ExecResponse) {}
// Attach prepares a streaming endpoint to attach to a running container.
rpc Attach(AttachRequest) returns (AttachResponse) {}
// PortForward prepares a streaming endpoint to forward ports from a PodSandbox.
rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
// ContainerStats returns stats of the container. If the container does not
// exist, the call returns an error.
rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {}
// ListContainerStats returns stats of all running containers.
rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {}
// PodSandboxStats returns stats of the pod. If the pod sandbox does not
// exist, the call returns an error.
rpc PodSandboxStats(PodSandboxStatsRequest) returns (PodSandboxStatsResponse) {}
// ListPodSandboxStats returns stats of the pods matching a filter.
rpc ListPodSandboxStats(ListPodSandboxStatsRequest) returns (ListPodSandboxStatsResponse) {}
// UpdateRuntimeConfig updates the runtime configuration based on the given request.
rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {}
// Status returns the status of the runtime.
rpc Status(StatusRequest) returns (StatusResponse) {}
}
// ImageService defines the public APIs for managing images.
service ImageService {
// ListImages lists existing images.
rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {}
// ImageStatus returns the status of the image. If the image is not
// present, returns a response with ImageStatusResponse.Image set to
// nil.
rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {}
// PullImage pulls an image with authentication config.
rpc PullImage(PullImageRequest) returns (PullImageResponse) {}
// RemoveImage removes the image.
// This call is idempotent, and must not return an error if the image has
// already been removed.
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {}
// ImageFSInfo returns information of the filesystem that is used to store images.
rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {}
}
На уровне kubelet используется Runtime Client для взаимодействия с Container Runtime. Входная точка для синхронизации состояния контейнеров в Pod — метод pkg/kubelet/container.Runtime.SyncPod. Если посмотреть на комментарии к методу, которые любезно оставили разработчики Kubernetes, то можно увидеть следующее:
// SyncPod syncs the running pod into the desired pod by executing following steps:
//
// 1. Compute sandbox and container changes.
// 2. Kill pod sandbox if necessary.
// 3. Kill any containers that should not be running.
// 4. Create sandbox if necessary.
// 5. Create ephemeral containers.
// 6. Create init containers.
// 7. Resize running containers (if InPlacePodVerticalScaling==true)
// 8. Create normal containers.
func (m *kubeGenericRuntimeManager) SyncPod(ctx context.Context, pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, backOff *flowcontrol.Backoff) (result kubecontainer.PodSyncResult) {
// ...
}
Входная точка в kubelet для синхронизации состояния пода — создание/изменение/удаление ресурсов пода
Интереснее всего здесь пункт 4 — Create sandbox if necessary. Возможно, многие из вас сталкивались хотя бы раз в своей жизни с ошибкой при деплое приложений вида:
Failed to create pod sandbox: rpc error:...
Так кто же такой этот ваш PodSandbox? PodSandbox — это общая среда для запуска всех контейнеров пода, изолированная от других контейнеров на узле. Технически представляет собой специальный pause-контейнер — «родительский» для всех контейнеров пода. Он создаётся и запускается один раз при создании пода и позволяет использовать общую сеть и ресурсы в течение всего жизненного цикла пода для всех контейнеров внутри него. Вы наверняка обращали внимание на то, что IP-адрес назначается поду, а не контейнерам внутри, и этот адрес не меняется, пока жив под, даже если наш контейнер перезапускается.

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

Давайте теперь сконцентрируемся на Container Runtime и, в частности, containerd.
Containerd представляет собой демон, запущенный на узле, который отвечает за жизненный цикл контейнеров — загрузку и хранение образов, исполнение и мониторинг контейнеров, конфигурацию сети и хранилища.

На самом деле containerd использует под капотом ещё один инструмент для запуска контейнеров — runc. Этот инструмент на самом низком уровне отвечает за запуск контейнеров в Linux и соответствует спецификации OCI runtime-spec.
OCI (Open Containers Initiative) — стандарт описания и работы с контейнерами, состоит из 3 основных частей:
image-spec — формат описания образов;
runtime-spec — конфигурация контейнеров, lifecycle;
distribution-spec — API для распространения контейнеров (pull, push image и пр.).
Предлагаю не погружаться в детали работы runc, мы ведь в первую очередь пришли поговорить про сети, поэтому рассмотрим, как containerd настраивает сеть для наших контейнеров.
При создании PodSandbox containerd конфигурирует изолированный Linux Network Namespace, кроме случаев, когда мы явно хотим поселить наш под в сеть узла (pod.spec.hostNetwork=true):
internal/cri/server/sandbox_run.go#L170
// Setup the network namespace if host networking wasn't requested.
if !hostNetwork(config) {
// …
}
При этом containerd не знает, как устроена сеть в нашем кластере, какие IP-адреса можно резервировать для подов. И здесь ему на помощь приходят network-плагины, соответствующие спецификации CNI (Container Network Interface). Наша схема становится немного сложнее:

CNI
CNI-плагин призван имплементировать сетевую модель Kubernetes. Основные положения этой модели:
— каждый под в кластере получает уникальный в пределах этого кластера IP-адрес;
— поды кластера могут взаимодействовать по сети друг с другом напрямую без NAT и/или прокси;
— агенты на хосте кластера (например, kubelet) могут взаимодействовать по сети со всеми подами на данном хосте.
Подробнее об этом можно почитать, например, тут: https://kubernetes.io/docs/concepts/services-networking/#the-kubernetes-network-model
Наиболее распространённые плагины:
Мы в MWS решили использовать Cilium по ряду причин:
— производительность eBPF;
— L7-политики безопасности для подов;
— поддержка LoadBalancer-сервисов из коробки без необходимости использовать внешние компоненты (например, MetalLB).

Основные различия между kube-proxy и реализацией k8s-сервисов в Cilium:

Плюсом «из коробки» идёт hubble, который обеспечивает наблюдаемость сети нашего кластера:

Cilium разворачивается на узлах Kubernetes-кластера в виде DaemonSet — это гарантирует по одному экземпляру на каждом узле. При этом на каждом узле устанавливается cilium-agent, а также cilium-cni binary, с которым взаимодействует containerd. Агент cilium знает о том, какая конфигурация сети в Kubernetes-кластере, так как он взаимодействует с kube-api-server:

Предлагаю подробнее рассмотреть спецификацию CNI. Наиболее интересными тут будут формат конфигурации сети, а также протокол взаимодействия.
Начнём с формата конфигурации:
{
"cniVersion": "1.1.0",
"cniVersions": ["0.3.1", "0.4.0", "1.0.0", "1.1.0"],
"name": "dbnet",
"plugins": [
{
"type": "bridge",
// plugin specific parameters
"bridge": "cni0",
"keyA": ["some more", "plugin specific", "configuration"],
"ipam": {
"type": "host-local",
// ipam specific
"subnet": "10.1.0.0/16",
"gateway": "10.1.0.1",
"routes": [
{"dst": "0.0.0.0/0"}
]
},
"dns": {
"nameservers": [ "10.1.0.1" ]
}
},
{
"type": "tuning",
"capabilities": {
"mac": true
},
"sysctl": {
"net.core.somaxconn": "500"
}
},
{
"type": "portmap",
"capabilities": {"portMappings": true}
}
]
}
Формат CNI-спецификации. Источник
Формат конфигурации сети представляет собой JSON-файл. Этот пример взят из официальной документации CNI. Рассмотрим некоторые поля:
cniVersion — версия спецификации CNI, которой удовлетворяет плагин;
name — уникальное имя в рамках узла (node) Kubernetes;
plugins — список CNI-плагинов, которые будут использоваться при конфигурации сети, в данном случае это bridge и tuning.
Ещё важно отметить поле type — на самом деле type — это не какой-то «тип», а имя исполняемого файла в директории с CNI-плагинами (как правило, /opt/cni/bin/).
Протокол взаимодействия с CNI выглядит непривычно — он предполагает вызов исполняемого файла плагина с передачей JSON-файла конфигурации, а также указания дополнительных ENV-переменных для конфигурации сети:
CNI_COMMAND — ADD (создать сетевой интерфейс для контейнера), DEL (удалить сетевой интерфейс), CHECK (проверить наличие сетевого интерфейса), GC (освободить занятые ресурсы, например IP-адреса в IPAM, если они более не используются), VERSION (проверить версию плагина).
CNI_CONTAINERID — идентификатор контейнера, для которого конфигурируем сеть.
CNI_NETNS — сетевой namespace Linux для контейнера.
CNI_IFNAME — имя сетевого интерфейса внутри контейнера, который необходимо создать/удалить.
CNI_ARGS — дополнительные параметры в формате "FOO=BAR;ABC=123".
CNI_PATH — путь к исполняемым файлам CNI-плагинов.
cat <<EOF | CNI_COMMAND=ADD CNI_CONTAINERID=c68aa42881048c77aa52534404865d146fadcb0031b1dd54929e3b94a572bc32 CNI_NETNS=/var/run/netns/cni-3f68e050-b20d-168a-7326-63b052a06920 CNI_IFNAME=eth0 CNI_PATH=/opt/cni/bin cilium-cni
{
"cniVersion": "0.3.1",
"name": "cilium",
"plugins": [
{
"type": "cilium-cni",
"enable-debug": false,
"log-file": "/var/run/cilium/cilium-cni.log"
}
]
}
EOF`
Пример вызова CNI-плагина
При этом есть набор референсных CNI-плагинов, которые реализуют CNI-спецификацию и позволяют настраивать сеть на узлах Kubernetes.
А при чём тут вообще облака
Underlay-сеть у нас использует только IPv6-адресацию, за исключением пограничных участков. Но для пользовательских ВМ необходима IPv4-адресация и связность. Передачу IPv4-трафика поверх IPv6 мы реализуем с помощью overlay SRv6-сетей. В частности, для dataplane мы решили использовать сетевой стек на базе VPP + DPDK, который и обрабатывает трафик пользовательских ВМ. Более подробно об этом рассказал мой коллега Яков.
Наши сервисы мы разворачиваем в Kubernetes-кластерах, которые используют IPv6- адресацию. Но при этом у нас есть ряд сервисов, к которым должны иметь доступ ВМ по IPv4, например сервис метаданных, DHCP, DNS-прокси. Важно отметить, что эти сервисы являются локальными для каждого гипервизора, то есть доступ к ним имеют только ВМ, запущенные на данном гипервизоре. Вот тут-то и кроется проблема — наши сервисы запущены в IPv6-сети Kubernetes, которая управляется Cilium, как нам обеспечить доступ до сервисов из overlay-сети виртуальных машин?
Мы могли бы поселить наши сервисы внутри выделенного Linux Network Namespace и связать его с VPP с помощью бриджа, но это создаёт ряд неудобств:
Для получения доступа к специальному namespace нам бы потребовалось запускать наши приложения в хостовой сети узла (pod.spec.hostNetwork=true), что создаёт дополнительные риски с точки зрения безопасности.
Нам бы пришлось занимать хостовые порты — это означает, что нам пришлось бы позаботиться о том, чтобы не пересечься по портам с другими сервисами на узле, к тому же злоумышленник, попав на узел, сможет простучать наши торчащие порты.
Дополнительные сложности для коллектора метрик с наших приложений, так как приложение запускается внутри изолированного Linux Namespace, к которому нет доступа у коллектора.
И здесь нам на помощь приходит дополнительный CNI-плагин — Multus.
Multus CNI
Multus выступает в роли сетевого плагина Kubernetes, который действует как meta-cni-плагин — вместо прямой настройки сетевых интерфейсов наших контейнеров на узле, он делегирует это другим плагинам согласно спецификации Custom Resource — k8s.cni.cncf.io/v1/NetworkAttachmentDefinition. Основная функция Multus заключается в создании нескольких сетевых интерфейсов для контейнеров в поде, что позволяет создавать multi-homed pod. Таким образом, мы можем расширить сетевое взаимодействие и гибкость в Kubernetes, предоставляя каждому поду возможность подключения через несколько сетевых интерфейсов в зависимости от задач, например, мы можем отдавать наши метрики, а также Kubernetes-пробы через стандартный интерфейс K8s-сети, а пользовательский трафик обрабатывать на дополнительных интерфейсах.

Multus доступен в двух вариантах поставки — Multus Thick Plugin и Multus Thin Plugin. Thick plugin подразумевает клиент-серверную модель взаимодействия и разделяет Multus на два отдельных рантайма:
multus-shim — непосредственно сам исполняемый файл CNI-плагина;
multus-daemon — представляет собой сервер, который отвечает на CNI-запросы от multus-shim (клиента) и взаимодействует с kube-api-server для получения Network Attachment Definition CR.

В MWS мы используем Thick-версию плагина и устанавливаем Multus с помощью helm. Пример DaemonSet-манифеста, который мы используем:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: kube-multus-ds
namespace: kube-system
labels:
tier: node
app: multus
name: multus
spec:
selector:
matchLabels:
name: multus
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
tier: node
app: multus
name: multus
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists
effect: NoSchedule
- operator: Exists
effect: NoExecute
serviceAccountName: multus
containers:
- name: kube-multus
image: {{ .Values.multus.image }}
command: [ "/usr/src/multus-cni/bin/multus-daemon" ]
resources:
requests:
cpu: {{ .Values.multus.resources.requests.cpu | quote }}
memory: {{ .Values.multus.resources.requests.memory | quote }}
limits:
cpu: {{ .Values.multus.resources.limits.cpu | quote }}
memory: {{ .Values.multus.resources.limits.memory | quote }}
securityContext:
privileged: true
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- name: cni
mountPath: /host/etc/cni/net.d
# multus-daemon expects that cnibin path must be identical between pod and container host.
# e.g. if the cni bin is in '/opt/cni/bin' on the container host side, then it should be mount to '/opt/cni/bin' in multus-daemon,
# not to any other directory, like '/opt/bin' or '/usr/bin'.
- name: cnibin
mountPath: /opt/cni/bin
- name: host-run
mountPath: /host/run
- name: host-var-lib-cni-multus
mountPath: /var/lib/cni/multus
- name: host-var-lib-kubelet
mountPath: /var/lib/kubelet
mountPropagation: HostToContainer
- name: host-run-k8s-cni-cncf-io
mountPath: /run/k8s.cni.cncf.io
- name: host-run-netns
mountPath: /run/netns
mountPropagation: HostToContainer
- name: multus-daemon-config
mountPath: /etc/cni/net.d/multus.d
readOnly: true
- name: hostroot
mountPath: /hostroot
mountPropagation: HostToContainer
env:
- name: MULTUS_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
initContainers:
- name: install-multus-binary
image: {{ .Values.multusInit.image }}
command:
- "cp"
- "-f"
- "/usr/src/multus-cni/bin/multus-shim"
- "/host/opt/cni/bin/multus-shim"
resources:
requests:
cpu: {{ .Values.multusInit.resources.requests.cpu | quote }}
memory: {{ .Values.multusInit.resources.requests.memory | quote }}
securityContext:
privileged: true
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- name: cnibin
mountPath: /host/opt/cni/bin
mountPropagation: Bidirectional
terminationGracePeriodSeconds: 10
volumes:
- name: cni
hostPath:
path: /etc/cni/net.d
- name: cnibin
hostPath:
path: /opt/cni/bin
- name: hostroot
hostPath:
path: /
- name: multus-daemon-config
configMap:
name: multus-daemon-config
items:
- key: daemon-config.json
path: daemon-config.json
- name: host-run
hostPath:
path: /run
- name: host-var-lib-cni-multus
hostPath:
path: /var/lib/cni/multus
- name: host-var-lib-kubelet
hostPath:
path: /var/lib/kubelet
- name: host-run-k8s-cni-cncf-io
hostPath:
path: /run/k8s.cni.cncf.io
- name: host-run-netns
hostPath:
path: /run/netns/
Cпецификация Multus DaemonSet типовая и по большей части взята из официального репозитория — https://github.com/k8snetworkplumbingwg/multus-cni/blob/master/deployments/multus-daemonset-thick.yml
Здесь из интересного можно обратить внимание на initContainer, который копирует исполняемый файл multus-shim в директорию с CNI-плагинами на узле. В нашем случае директория вместе с установленными Cilium и Multus выглядит следующим образом:
$ ls /opt/cni/bin/
bandwidth bridge cilium-cni dhcp dummy firewall host-device host-local ipvlan loopback macvlan multus-shim portmap ptp sbr static tap tuning vlan vrf
Cilium-cni, multus-shim, а также набор референсных CNI-плагинов, которые может использовать containerd для конфигурации сети контейнеров
Но как containerd понимает, какие плагины ему необходимо использовать? Тут ему на помощь приходит описанная ранее конфигурация CNI в JSON-формате, которая располагается в специальной директории (обычно /etc/cni/net.d/):
$ ls /etc/cni/net.d/
00-multus.conf 05-cilium.conflist
$ cat /etc/cni/net.d/00-multus.conf
{
"cniVersion": "0.3.1",
"name": "multus-cni-network",
"clusterNetwork": "/host/etc/cni/net.d/05-cilium.conflist",
"type": "multus-shim",
"logToStderr": true
}
$ cat /etc/cni/net.d/05-cilium.conflist
{
"cniVersion": "0.3.1",
"name": "cilium",
"plugins": [
{
"type": "cilium-cni",
"enable-debug": false,
"log-file": "/var/run/cilium/cilium-cni.log"
}
]
}
Конфигурации cilium- и multus-плагинов, которые использует containerd
Данные файлы конфигураций генерируются на стороне Cilium и Multus при старте приложения и помещаются в директорию. Также есть возможность заранее создать файл с нужной конфигурацией и указать на этот файл в настройках Cilium/Multus. В случае Multus конфигурация задаётся через деплоймент ConfigMap:
kind: ConfigMap
apiVersion: v1
metadata:
name: multus-daemon-config
namespace: kube-system
labels:
tier: node
app: multus
data:
daemon-config.json: |
{
"chrootDir": "/hostroot",
"cniVersion": "0.3.1",
"logLevel": "verbose",
"logToStderr": true,
"cniConfigDir": "/host/etc/cni/net.d",
"readinessindicatorfile": "/host/etc/cni/net.d/05-cilium.conflist",
"multusAutoconfigDir": "/host/etc/cni/net.d",
"multusConfigFile": "auto",
"socketDir": "/host/run/multus/"
}
СonfigMap с конфигурацией Multus Daemon. На базе этой конфигурации будет сгенерирован /etc/cni/net.d/00-multus.conf файл конфигурации Multus CNI
Опция multusConfigFile: auto указывает на то, что Multus автоматически сгенерирует файл конфигурации.
На стороне containerd при старте вычитываются все файлы конфигураций из директории /etc/cni/net.d, выполняется сортировка по имени и выбирается первый файл из списка, который containerd будет использовать для CNI-вызовов.
Если посмотреть на конфигурацию Multus, то можно увидеть, что Multus использует в качестве CNI-плагина по умолчанию Cilium:
"clusterNetwork": "/host/etc/cni/net.d/05-cilium.conflist"
Таким образом, Multus выступает в роли «прокладки» между containerd и основным CNI, использующимся в K8s для конфигурации сети кластера. В том случае, если для пода задана аннотация NetworkAttachmentDefinition, Multus, помимо вызова cilium-cni, выполнит вызов других CNI-плагинов согласно спецификации NetworkAttachmentDefinition.
Пример NetworkAttachmentDefinition, который используем мы для конфигурации дополнительного интерфейса dhcp-server:
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: nad-dhcp
spec:
config: '{
"cniVersion":"0.3.1",
"name":"nad-dhcp",
"plugins":[
{
"type":"bridge",
"bridge":"svc-bridge",
"isGateway":true,
"ipam":{
"type":"static",
"addresses":[
{
"address":"169.254.169.103/24",
"gateway":"169.254.169.1"
}
],
"routes":[
{
"dst":"169.252.252.0/24",
"gw":"169.254.169.1"
}
]
}
}
]
}'
Можно заметить, что конфигурация соответствует CNI-формату. В нашем примере используется CNI-плагин bridge, который создаёт бридж в хостовой сети узла, и в качестве ipam используется static-плагин, который назначает статический адрес 169.254.169.103 для нашего второго интерфейса приложения.
Для использования данной конфигурации достаточно добавить соответствующую аннотацию в спецификацию пода:
apiVersion: v1
kind: Pod
metadata:
annotations:
k8s.v1.cni.cncf.io/networks: nad-dhcp
Таким образом, наша схема с Multus выглядит следующим образом:

Использование Multus позволяет нам маршрутизировать пакеты от виртуальных машин в DHCP, DNS, Metadata без необходимости размещать данные сервисы в хостовой сети.
Предлагаю теперь подытожить и посмотреть на полный flow конфигурации контейнеров с точки зрения сети:

Данная схема на практике отлично себя показала, однако в какой-то момент мы столкнулись с проблемой — наши сервисы DHCP/DNS/Metadata теряли дополнительный сетевой интерфейс и происходило это при рестарте узла Kubernetes, а также в редких случаях мы наблюдали такое поведение просто при обновлении наших сервисов. В результате отладки выяснили интересную особенность Multus: при старте в /etc/cni/net.d/ кладётся конфигурация CNI (00-multus.conf), а при завершении работы multus-daemon данная конфигурация удаляется:
// Start generates an updated Multus config, writes it, and begins watching
// the config directory and readiness indicator files for changes
func (m *Manager) Start(ctx context.Context, wg *sync.WaitGroup) error {
generatedMultusConfig, err := m.GenerateConfig()
if err != nil {
return logging.Errorf("failed to generated the multus configuration: %v", err)
}
logging.Verbosef("Generated MultusCNI config: %s", generatedMultusConfig)
multusConfigFile, err := m.PersistMultusConfig(generatedMultusConfig)
if err != nil {
return logging.Errorf("failed to persist the multus configuration: %v", err)
}
wg.Add(1)
go func() {
defer wg.Done()
if err := m.monitorPluginConfiguration(ctx); err != nil {
_ = logging.Errorf("error watching file: %v", err)
}
logging.Verbosef("ConfigWatcher done")
logging.Verbosef("Delete old config @ %v", multusConfigFile)
os.Remove(multusConfigFile)
}()
return nil
}
При обновлении наших сервисов проблема проявлялась в тот момент, когда одновременно с обновлением происходил рестарт Multus. Конфигурация Multus CNI удаляется, и containerd не остаётся ничего другого, кроме как использовать /etc/cni/net.d/05-cilium.conflist для конфигурации сети наших приложений.
Нам очень не хочется, чтобы наши пользователи внезапно начинали терять пакеты, мы немного переработали Multus, добавив новую опцию, чтобы сохранять файл конфигурации при рестартах:
// Start generates an updated Multus config, writes it, and begins watching
// the config directory and readiness indicator files for changes
func (m *Manager) Start(ctx context.Context, wg *sync.WaitGroup) error {
generatedMultusConfig, err := m.GenerateConfig()
if err != nil {
return logging.Errorf("failed to generated the multus configuration: %v", err)
}
logging.Verbosef("Generated MultusCNI config: %s", generatedMultusConfig)
multusConfigFile, err := m.PersistMultusConfig(generatedMultusConfig)
if err != nil {
return logging.Errorf("failed to persist the multus configuration: %v", err)
}
wg.Add(1)
go func() {
defer wg.Done()
if err := m.monitorPluginConfiguration(ctx); err != nil {
_ = logging.Errorf("error watching file: %v", err)
}
logging.Verbosef("ConfigWatcher done")
if !m.multusConfig.MultusKeepConfig {
logging.Verbosef("Delete old config @ %v", multusConfigFile)
os.Remove(multusConfigFile)
}
}()
return nil
}
Таким образом, containerd не переключается на Cilium, а продолжает слать CNI-запросы в multus-shim. Мы не увидели какой-либо деградации в скорости старта контейнеров, при рестартах узлов или самого Multus сеть оперативно восстанавливается.
Заключение
В Kubernetes сетевая модель изначально предполагает наличие одного сетевого интерфейса у пода. Однако в нашем случае этого оказалось недостаточно и мы нашли решение в виде использования Multus CNI.
Multus позволяет динамически добавлять дополнительные сетевые интерфейсы в поды, сохраняя при этом совместимость с основным CNI-плагином (Cilium в нашем случае). Практика показала, что, несмотря на некоторые проблемы, нам это решение отлично подошло. Кроме того, мы получили более глубокое понимание устройства Kubernetes и, в частности, механизмов сетевого подключения подов.
Мы были рады поделиться этими знаниями в этой статье и благодарим вас за внимание!
Читайте и смотрите другие материалы про создание нового публичного облака MWS:
Зачем мы строим собственное публичное облако? Рассказывает CTO MWS Данила Дюгуров.
Реалити-проект для инженеров про разработку облака. Рассказываем про архитектуру сервисов в серии видео ещё до релиза.
Подкаст "Расскажите про MWS". Рассказываем про команду новой облачной платформы MWS.