Привет, Хабр! Меня зовут Глеб Когтев, я руководитель команды 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-кластера. Источник
Компоненты Kubernetes-кластера. Источник

Каждый кластер 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_A, Container_B — некоторые пользовательские контейнеры (приложения), а также общий Pause container, который всегда создаётся по умолчанию и позволяет организовать общую сеть между всеми контейнерами внутри пода. Источник
Пример контейнеров внутри пода: Container_A, Container_B — некоторые пользовательские контейнеры (приложения), а также общий Pause container, который всегда создаётся по умолчанию и позволяет организовать общую сеть между всеми контейнерами внутри пода. Источник

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

Схема взаимодействия kubelet и containerd при создании пода в k8s
Схема взаимодействия kubelet и containerd при создании пода в k8s

Давайте теперь сконцентрируемся на Container Runtime и, в частности, containerd. 

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

Схема компонентов containerd и их взаимодействия с узлом Kubernetes. Источник
Схема компонентов containerd и их взаимодействия с узлом Kubernetes. Источник

На самом деле 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). Наша схема становится немного сложнее:

Полный процесс создания контейнеров и конфигурации сети при создании пода в Kubernetes
Полный процесс создания контейнеров и конфигурации сети при создании пода в Kubernetes

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 + iptables вместо Cilium eBPF с ростом числа сервисов. Источник
Наглядная демонстрация деградации производительности при использовании kube-proxy + iptables вместо Cilium eBPF с ростом числа сервисов. Источник

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

Маршрутизация на базе eBPF в Cilium позволяет избежать накладных расходов при использовании iptables, а также лишнего переключения контекста при доставке пакета из операционной системы узла в подовую сеть. Источник
Маршрутизация на базе eBPF в Cilium позволяет избежать накладных расходов при использовании iptables, а также лишнего переключения контекста при доставке пакета из операционной системы узла в подовую сеть. Источник

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

Граф зависимостей сервисов внутри кластера (и даже между несколькими кластерами — Cluster Mesh) позволяет отследить движение трафика на L3/L4- и даже L-7-уровнях. Источник
Граф зависимостей сервисов внутри кластера (и даже между несколькими кластерами — Cluster Mesh) позволяет отследить движение трафика на L3/L4- и даже L-7-уровнях. Источник

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

Схема развёртывания Cilium-компонентов и их взаимодействие с другими компонентами кластера Kubernetes. Источник
Схема развёртывания Cilium-компонентов и их взаимодействие с другими компонентами кластера Kubernetes. Источник

Предлагаю подробнее рассмотреть спецификацию 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 с помощью бриджа, но это создаёт ряд неудобств:

  1. Для получения доступа к специальному namespace нам бы потребовалось запускать наши приложения в хостовой сети узла (pod.spec.hostNetwork=true), что создаёт дополнительные риски с точки зрения безопасности.

  2. Нам бы пришлось занимать хостовые порты — это означает, что нам пришлось бы позаботиться о том, чтобы не пересечься по портам с другими сервисами на узле, к тому же злоумышленник, попав на узел, сможет простучать наши торчащие порты.

  3. Дополнительные сложности для коллектора метрик с наших приложений, так как приложение запускается внутри изолированного 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-сети, а пользовательский трафик обрабатывать на дополнительных интерфейсах.

Схема конфигурации multi-homed pod. Источник
Схема конфигурации multi-homed pod. Источник

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.

Схема взаимодействия компонентов Multus с CRI и другими CNI. Источник
Схема взаимодействия компонентов Multus с CRI и другими CNI. Источник

В 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 выглядит следующим образом:

Схема взаимодействия multi-homed-подов с VPP
Схема взаимодействия multi-homed-подов с VPP

Использование 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: