
Привет, Хабр! Меня зовут Глеб Когтев, я руководитель команды VPC Host Components, которая занимается разработкой виртуальной облачной сети для MWS Cloud Platform. 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 vrfCilium-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 Cloud Platform:
Зачем мы строим собственное публичное облако? Рассказывает CTO MWS Данила Дюгуров.
Реалити-проект для инженеров про разработку облака. Рассказываем про архитектуру сервисов в серии видео ещё до релиза.
Подкаст "Расскажите про MWS". Рассказываем про команду новой облачной платформы MWS.
