Привет! Меня зовут Даниил, я DevOps-инженер в KTS.

Сегодня расскажу о Cilium – опенсорсном CNI-плагине для Kubernetes с технологией eBPF под капотом.

Помимо CNI, Cilium предоставляет множество фич, которые также используют eBPF и в совокупности покрывают почти весь нетворкинг в Kubernetes. Их я и рассмотрю в этой статье, попутно описав свои впечатления и трудности, с которыми пришлось столкнуться.

Тестировать я буду два кластера: первый – обычный, развернутый через kubeadm, а второй – managed k8s в Yandex Cloud. Для проведения испытаний я буду использовать последнюю на момент написания статьи версию Cilium – 1.15.1.

Оглавление

В чем фишка eBPF?

Технологию eBPF можно смело назвать киллер-фичей Cilium. Какую же задачу она выполняет?

Чтобы ответить на этот вопрос, вспомним, что все действия и события в операционной системе так или иначе проходят через ядро. Следовательно, это наилучшее место для реализации функционала, связанного с сетями, безопасностью и наблюдаемостью. Однако разработка такого проекта может занять много времени или вовсе быть отклонена сообществом, а поддержка собственной версии ядра будет слишком дорогостоящей и неудобной для многих компаний.

Эту проблему и решает технология eBPF. Она предоставляет платформу для простого и безопасного запуска программ в ядре. По словам Томаса Графа, одного из основателей Cilium, «eBPF для ядра Linux – как JavaScript для браузера».

Установка

Перед тем, как перейти к описанию фич и тестов, поговорим о том, как установить Cilium.

  • Если вы не используете управляемый облачным провайдером Kubernetes-сервис, где нужный плагин идет из коробки, то вам придется накатить его вручную.

    Установить Cilium можно с помощью CLI-утилиты cilium, которая установит Helm-чарт (либо просто установить чарт напрямую через Helm):

      $ cilium install --version 1.15.1
    ℹ  Using Cilium version 1.15.1
    🔮 Auto-detected cluster name: kubernetes
    🔮 Auto-detected kube-proxy has been installed

    С помощью helm list -n kube-system можно убедиться, что чарт установлен:

    $ helm list -n kube-system
    NAME    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
    cilium  kube-system     1               2024-02-19 14:24:38.564620737 +0000 UTC deployed        cilium-1.15.1   1.15.1
    

    Через cilium status можно посмотреть состояние Cilium. Если вы видите что-то подобное, это значит, что Cilium работает исправно:

        /¯¯\\
     /¯¯\\__/¯¯\\    Cilium:             OK
     \\__/¯¯\\__/    Operator:           OK
     /¯¯\\__/¯¯\\    Envoy DaemonSet:    disabled (using embedded mode)
     \\__/¯¯\\__/    Hubble Relay:       disabled
        \\__/       ClusterMesh:        disabled
    
    Deployment             cilium-operator    Desired: 1, Ready: 1/1, Available: 1/1
    DaemonSet              cilium             Desired: 3, Ready: 3/3, Available: 3/3
    Containers:            cilium             Running: 3
                           cilium-operator    Running: 1
    Cluster Pods:          2/2 managed by Cilium
    Helm chart version:    1.15.1
    Image versions         cilium             quay.io/cilium/cilium:v1.15.1@sha256:351d6685dc6f6ffbcd5451043167cfa8842c6decf80d8c8e426a417c73fb56d4: 3
                           cilium-operator    quay.io/cilium/operator-generic:v1.15.1@sha256:819c7281f5a4f25ee1ce2ec4c76b6fbc69a660c68b7825e9580b1813833fa743: 1
  • Если же вы управляете кластером через Yandex Cloud, то вам достаточно просто отметить чекбокс «Включить туннельный режим» при создании кластера:

Поговорим о производительности

После установки Cilium можно переходить описанию фишек и их тестам. В первую очередь я расскажу о том, какими способами и насколько значительно плагин повышает производительность нагруженных сервисами кластеров.

Пока, kube-proxy

Одна из уникальных фич Cilium – возможность полностью заменить kube-proxy. Зачем это может понадобиться? Затем, чтобы повысить производительность больших кластеров с большим количеством сервисов. Для этого при инициализации кластера нужно пропустить установку kube-proxy:

$ kubeadm init --skip-phases=addon/kube-proxy
  • Если устанавливать Cilium через Helm, то в values чарта необходимо указать следующие параметры:

    kubeProxyReplacement: true
    k8sServiceHost: <адрес kube-apiserver>
    k8sServicePort: <порт kube-apiserver>
  • Если же выполнять установку с помощью утилиты cilium, то CLI автоматически определит отсутствие kube-proxy и подставит нужные параметры:

    $ cilium install --version 1.15.1
    ℹ  Using Cilium version 1.15.1
    🔮 Auto-detected cluster name: kubernetes
    🔮 Auto-detected kube-proxy has not been installed
    ℹ  Cilium will fully replace all functionalities of kube-proxy

Статус замены можно проверить в поде Cilium-агента:

$ kubectl exec -n kube-system cilium-pnjxh -- cilium status --verbose
<...>
KubeProxyReplacement Details:
  Status:                 Strict
  Socket LB:              Enabled
  Socket LB Tracing:      Enabled
  Socket LB Coverage:     Full
  Devices:                eth0   10.12.12.5 fe80::d20d:10ff:fe08:9a52 (Direct Routing)
  Mode:                   SNAT
  Backend Selection:      Random
  Session Affinity:       Enabled
  Graceful Termination:   Enabled
  NAT46/64 Support:       Disabled
  XDP Acceleration:       Disabled
  Services:
  - ClusterIP:      Enabled
  - NodePort:       Enabled (Range: 30000-32767)
  - LoadBalancer:   Enabled
  - externalIPs:    Enabled
  - HostPort:       Enabled
<...>

Если параметру «Status» соответствует значение «Strict» или «True», это значит, что агент запущен в режиме замены kube-proxy.

Тесты производительности

На выступлении «Liberating Kubernetes From Kube-proxy and Iptables» Мартинас Пумпутис, один из разработчиков Cilium, показал результаты бенчмарков, где сравнивается производительность Cilium (eBPF) и kube-proxy (ipvs и iptables).

Ось Y на слайде – время задержки в микросекундах, ось X – количество сервисов в кластере.

Как видно, при увеличении числа сервисов растет задержка kube-proxy в режиме iptables. Это связано с тем, что для каждого сервиса kube-proxy создает правила в цепи iptables, который обрабатывает их последовательно (алгоритм сложности O(n)). Соответственно, чем больше правил в цепи, тем больше задержка сетевого пакета.

Задержка Cilium и kube-proxy в режиме ipvs почти не меняется, так как оба плагина используют алгоритм сложности O(1) (в Cilium это lookup hash-таблицы), при этом Cilium остается чуть быстрее. Для тестирования разработчики использовали netperf (конкретно – тест TCP_CRR), который замеряет следующую последовательность:

  1. открывается TCP-соединение;

  2. отправляется единичный запрос;

  3. приходит ответ;

  4. соединение закрывается.

Проведем похожий тест и сравним результаты. Для простоты исключим ipvs, будем сравнивать только eBPF и iptables. В качестве тестового стенда я использовал кластер из трех нод (одна мастер-нода и два воркера) на Ubuntu 22.04 в Yandex Cloud.

netperf состоит из клиентской и серверной частей, поэтому сначала деплоим netserver в кластер (для теста будем использовать NodePort сервис, как и разработчики):

Скрытый текст
apiVersion: apps/v1
kind: Deployment
metadata:
  name: netserver
  labels:
    app: netserver
spec:
  replicas: 1
  selector:
    matchLabels:
      app: netserver
  template:
    metadata:
      labels:
        app: netserver
    spec:
      containers:
      - name: netserver
        image: networkstatic/netserver:latest
        args:
          - -D
        ports:
        - containerPort: 12865
        - containerPort: 30002
---
apiVersion: v1
kind: Service
metadata:
  name: netserver
spec:
  type: NodePort
  selector:
    app: netserver
  ports:
    # netperf использует 2 соединения - одно для передачи информации о тесте, другое для самого теста
    - protocol: TCP
      port: 12865
      targetPort: 12865
      nodePort: 30001
      name: control
    - protocol: TCP
      port: 30002
      targetPort: 30002
      nodePort: 30002
      name: data

Проверяем по логам, что сервер запущен и работает:

$ kubectl logs <под netserver>
Starting netserver with host 'IN(6)ADDR_ANY' port '12865' and family AF_UNSPEC

Теперь можно начать запускать тесты. Для запуска используем внекластерный хост в той же подсети, в которой находится кластер. В качестве целевого хоста выступит нода, на которой запущен под с netserver.

$ netperf -t TCP_CRR -H <адрес ноды кластера> -p 30001 -- -P 30002 -o rt_latency

Подробнее о флагах:

  • t TCP_CRR – тип теста, который нужно запустить;

  • H – хост, с которым нужно протестировать соединение (там должен быть запущен netserver);

  • p 30001 – порт, который прослушивает netserver.

После «--» указываем параметры для испытания. Разработчики в своем выступлении параметров не раскрывали, поэтому мы укажем только порт для проведения теста (-P 30002) и параметр, который следует выводить в результатах. Скорее всего, под «µseq per tx» на слайде разработчики имели в виду rt_latency, поэтому указываем его.

Далее запускаем скрипт для быстрого создания множества сервисов:

for i in {1..2767}; do
cat <<EOF | kubectl delete -f -
apiVersion: v1
kind: Service
metadata:
  name: netserver-$i
spec:
  selector:
    app: netserver
  ports:
    - protocol: TCP
      port: 12865
      targetPort: 12865
      name: control
    - protocol: TCP
      port: 30002
      targetPort: 30002
      name: data
EOF
done

У меня получился следующий результат:

Конечно, тест не претендует на предельную точность, но даже грубая оценка дает понять разницу между решениями – при использовании kube-proxy задержка растет с количеством сервисов, в то время как Cilium позволяет держать ее примерно на одном уровне.

Ключевые фичи Cilium

Итак, какой дополнительный функционал предоставляет плагин? Первое испытание уже показало нам, что eBPF позволяет сохранять скорость передачи пакетов данных даже при большом количестве развернутых сервисов. Однако на этом преимущества Cilium не заканчиваются, ведь благодаря нему можно значительно упростить работу и с большим количеством кластеров. Давайте разбираться, каким образом.

Сетевая безопасность и политики

Cilium позволяет гибко настраивать ограничения на сетевое взаимодействие в кластере с помощью сетевых политик. Их применение в Cilium похоже на применение стандартных сетевых политик Kubernetes: по умолчанию у пода нет никаких ограничений на ingress- и egress-трафик, но если он попадает под селектор хотя бы одной политики, то переходит в режим «default deny». Таким образом, весь трафик, кроме того, что разрешен политиками, блокируется. Ответный трафик имплицитно разрешен. Cilium также предоставляет еще два режима:

  • always – в этом режиме правило «default deny» распространяется на все поды независимо от того, попадают они под селектор сетевых политик или нет;

  • never – в этом режиме применение сетевых политик отключено, трафик в кластере передается без ограничений.

Указать режим применения сетевых политик можно в values.yaml следующим образом:

extraEnv:
- name: CILIUM_ENABLE_POLICY
  value: <default,always,never>

Чтобы понять, как использовать сетевые политики Cilium, рассмотрим следующий пример. Допустим, у нас есть проект, состоящий из двух сервисов (backend1 и backend2) и базы данных. backend1 в процессе своей работы обращается backend2, а backend2 обращается к БД. Тогда манифест сетевой политики CiliumNetworkPolicy для сервиса backend2 может выглядеть следующим образом:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: example
spec:
  endpointSelector:
    matchLabels:
      app: backend2
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: backend1
    toPorts:
    - ports:
      - port: "5000"
        protocol: TCP
  egress:
  - toEndpoints:
    - matchLabels:
        app: database
    toPorts:
    - ports:
      - port: "5432"
        protocol: TCP
  description: Allow backend1 ingress and DB egress

Здесь можно выделить основные поля:

  • endpointSelector – селектор для подов, на которые будет распространяться политика;

  • ingress – правила для входящего трафика;

  • egress – правила для исходящего трафика;

  • description – описание политики (полезно, когда забудете, для чего вы вообще ее добавляли).

Как видно, в данном манифесте политика распространяется на поды с лейблом app: backend2 и разрешает входящий трафик из подов с лейблом app: backend1 на порт 5000 и исходящий трафик в поды с лейблом app: database на порт 5432. 

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

Правила ingress и egress

Фильтрация трафика в Cilium основана на «идентичностях» (identity). Идентичность представляет один или несколько эндпоинтов в сети и технически является числом, связанным с набором лейблов. В случае с подами, лейблы создаются на основе метаданных подов.

Через Cilium-агента можно посмотреть список идентичностей в кластере:

Скрытый текст
$ kubectl exec -n kube-system ds/cilium -- cilium identity list

ID      LABELS
1       reserved:host
2       reserved:world
3       reserved:unmanaged
4       reserved:health
5       reserved:init
6       reserved:remote-node
7       reserved:kube-apiserver
        reserved:remote-node
8       reserved:ingress
9       reserved:world-ipv4
10      reserved:world-ipv6
6488    k8s:app=backend1
        k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default
        k8s:io.cilium.k8s.policy.cluster=kubernetes
        k8s:io.cilium.k8s.policy.serviceaccount=default
        k8s:io.kubernetes.pod.namespace=default
13657   k8s:app=database
        k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default
        k8s:io.cilium.k8s.policy.cluster=kubernetes
        k8s:io.cilium.k8s.policy.serviceaccount=default
        k8s:io.kubernetes.pod.namespace=default
24168   k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system
        k8s:io.cilium.k8s.policy.cluster=kubernetes
        k8s:io.cilium.k8s.policy.serviceaccount=coredns
        k8s:io.kubernetes.pod.namespace=kube-system
        k8s:k8s-app=kube-dns
64056   k8s:app=backend2
        k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default
        k8s:io.cilium.k8s.policy.cluster=kubernetes
        k8s:io.cilium.k8s.policy.serviceaccount=default
        k8s:io.kubernetes.pod.namespace=default

Сетевая политика в примере выше разрешает трафик из идентичности 6488 (k8s:app=backend1) в идентичность 64056 (k8s:app=backend2) и из идентичности 64056 в идентичность 13657 (k8s:app=database). Идентичности записываются в каждый сетевой пакет, который перемещается между нодами кластера, что позволяет Cilium эффективно фильтровать трафик по правилам сетевых политик.

В качестве целей правил ingress и egress можно также использовать:

  • CIDR:

    spec:
      egress:
      - toCIDR:
        - 192.168.3.1/32
  • сервисы:

    spec:
      egress:
      - toServices:
        - k8sService:
            serviceName: some-service
            namespace: default
  • DNS-имена:

    spec:
      egress:
      - toFQDNs:
        - matchName: "example.com"
  • ICMP:

    spec:
      egress:
      - icmps:
        - fields:
          - type: 0
            family: IPv4
  • «сущности» (entity):

    spec:
      egress:
      - toEntities:
        - host

Под сущностями подразумеваются следующие категории эндпоинтов:

  • host – хост, на котором запущен под;

  • remote-node – все ноды в кластере, кроме той, на которой запущен под;

  • kube-apiserver – внутренний и внешний эндпоинты kube-apiserver;

  • ingress – инстанс Envoy, который Cilium использует для ingress-трафика;

  • cluster – все эндпоинты кластера, включая ноды и поды, которые не управляются Cilium;

  • init – эндпоинты, для которых Cilium пока не установил идентичность;

  • health – health-эндпоинты нод;

  • unmanaged – поды, которые не управляются Cilium;

  • world – внекластерные эндпоинты;

  • all – думаю, и так понятно.

Внимательный читатель заметит, что этот список совпадает с первыми десятью идентичностями, зарезервированными Cilium.

L7

Еще одна фича сетевых политик Cilium – возможность применять сетевые политики для нескольких протоколов на уровне L7 (HTTP и Kafka). Для этого он использует инстанс Envoy, развернутый в качестве DaemonSet или вшитый в Cilium-агент.

Чтобы понять, как этим пользоваться, вернемся еще раз к примеру с двумя сервисами и БД. Допустим, нам нужно, чтобы backend1 мог отправлять только GET запросы на путь /api/users в backend2. Тогда наш манифест будет выглядеть так:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: example
spec:
  endpointSelector:
    matchLabels:
      app: backend2
  ingress:
  - fromEndpoints:
    - matchLabels:
        env: backend1
    toPorts:
    - ports:
      - port: "5000"
        protocol: TCP
      rules:
        http:
        - method: "GET"
          path: "/api/users"

Сетевые политики нод

Мы также можем использовать сетевые политики в качестве фаервола для хостов, которые находятся под управлением Cilium. Для этого в values.yaml нужно добавить hostFirewall.enabled: true. В поле devices: можно указать нужные сетевые интерфейсы (если не указывать, то Cilium определит их автоматически).

Обратите внимание: с хостовыми политиками нужно быть осторожным, так как можно нарушить работу нод, заблокировав, например, сетевой доступ к kube-apiserver. Эти политики не влияют на коммуникацию между подами (если они не используют hostNetwork: true) и их нельзя использовать для правил уровня L7.

Например, для того, чтобы разрешить доступ к нодам кластера по SSH, можно использовать следующий манифест:

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: example
spec:
  nodeSelector:
    matchLabels:
      node: ssh
  ingress:
  - fromEntities:
    - cluster
  - toPorts:
    - ports:
      - port: "22"
        protocol: TCP

Как видно, здесь используется тип CiliumClusterwideNetworkPolicy, правила которого распространяются на весь кластер, а не на отдельный неймспейс.

Далее вместо endpointSelector нужно указать nodeSelector с лейблами нод, на которые будет распространяться политика (node: ssh). В правилах мы разрешаем доступ из сущности cluster, чтобы не нарушить коммуникации с другими нодами кластера, и доступ к порту 22.

Визуальный редактор политик

Еще стоит упомянуть про редактор от разработчиков Cilium, в котором с помощью визуальных блоков можно получить манифест NetworkPolicy или CiliumNetworkPolicy. Попробовать можно здесь.

Observability и Hubble

Hubble – это решение для мониторинга сетевого трафика, которое поставляется вместе с Cilium. Благодаря eBPF мониторинг происходит прямо в ядре Linux, без необходимости в sidecar-контейнерах и т.п. Hubble состоит из двух компонентов:

  1. Hubble-сервер, вшитый в Cilium-агент. Он отвечает за мониторинг трафика ноды, на которой он развернут.

  2. Hubble Relay, который объединяет события всех Hubble-серверов и предоставляет общекластерный API.

Для того, чтобы включить фичи Hubble, в values.yaml необходимо добавить следующее:

Скрытый текст
---
# Source: cilium/templates/hubble-ui-serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: "hubble-ui"
  namespace: kube-system
---
# Source: cilium/templates/hubble-ui-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: hubble-ui-envoy
  namespace: kube-system
data:
  envoy.yaml: |
    static_resources:
      listeners:
        - name: listener_hubble_ui
          address:
            socket_address:
              address: 0.0.0.0
              port_value: 8081
          filter_chains:
            - filters:
                - name: envoy.filters.network.http_connection_manager
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                    codec_type: auto
                    stat_prefix: ingress_http
                    route_config:
                      name: local_route
                      virtual_hosts:
                        - name: local_service
                          domains: ["*"]
                          routes:
                            - match:
                                prefix: "/api/"
                              route:
                                cluster: backend
                                prefix_rewrite: "/"
                                timeout: 0s
                                max_stream_duration:
                                  grpc_timeout_header_max: 0s
                            - match:
                                prefix: "/"
                              route:
                                cluster: frontend
                          cors:
                            allow_origin_string_match:
                              - prefix: "*"
                            allow_methods: GET, PUT, DELETE, POST, OPTIONS
                            allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                            max_age: "1728000"
                            expose_headers: grpc-status,grpc-message
                    http_filters:
                      - name: envoy.filters.http.grpc_web
                      - name: envoy.filters.http.cors
                      - name: envoy.filters.http.router
      clusters:
        - name: frontend
          connect_timeout: 0.25s
          type: strict_dns
          lb_policy: round_robin
          load_assignment:
            cluster_name: frontend
            endpoints:
              - lb_endpoints:
                  - endpoint:
                      address:
                        socket_address:
                          address: 127.0.0.1
                          port_value: 8080
        - name: backend
          connect_timeout: 0.25s
          type: logical_dns
          lb_policy: round_robin
          http2_protocol_options: {}
          load_assignment:
            cluster_name: backend
            endpoints:
              - lb_endpoints:
                  - endpoint:
                      address:
                        socket_address:
                          address: 127.0.0.1
                          port_value: 8090
---
# Source: cilium/templates/hubble-ui-clusterrole.yaml
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: hubble-ui
rules:
  - apiGroups:
      - networking.k8s.io
    resources:
      - networkpolicies
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - ""
    resources:
      - componentstatuses
      - endpoints
      - namespaces
      - nodes
      - pods
      - services
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - apiextensions.k8s.io
    resources:
      - customresourcedefinitions
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - cilium.io
    resources:
      - "*"
    verbs:
      - get
      - list
      - watch
---
# Source: cilium/templates/hubble-ui-clusterrolebinding.yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: hubble-ui
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: hubble-ui
subjects:
  - kind: ServiceAccount
    namespace: kube-system
    name: "hubble-ui"
---
# Source: cilium/templates/hubble-ui-service.yaml
kind: Service
apiVersion: v1
metadata:
  name: hubble-ui
  labels:
    k8s-app: hubble-ui
  namespace: kube-system
spec:
  selector:
    k8s-app: hubble-ui
  ports:
    - name: http
      port: 80
      targetPort: 8081
  type: ClusterIP
---
# Source: cilium/templates/hubble-ui-deployment.yaml
kind: Deployment
apiVersion: apps/v1
metadata:
  namespace: kube-system
  labels:
    k8s-app: hubble-ui
  name: hubble-ui
spec:
  replicas: 1
  selector:
    matchLabels:
      k8s-app: hubble-ui
  template:
    metadata:
      annotations:
      labels:
        k8s-app: hubble-ui
    spec:
      securityContext:
        runAsUser: 1001
      serviceAccount: "hubble-ui"
      serviceAccountName: "hubble-ui"
      containers:
        - name: frontend
          image: "quay.io/cilium/hubble-ui:v0.7.9@sha256:e0e461c680ccd083ac24fe4f9e19e675422485f04d8720635ec41f2ba9e5562c"
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
              name: http
          resources: {}
        - name: backend
          image: "quay.io/cilium/hubble-ui-backend:v0.7.9@sha256:632c938ef6ff30e3a080c59b734afb1fb7493689275443faa1435f7141aabe76"
          imagePullPolicy: IfNotPresent
          env:
            - name: EVENTS_SERVER_PORT
              value: "8090"
            - name: FLOWS_API_ADDR
              value: "hubble-relay:80"
          ports:
            - containerPort: 8090
              name: grpc
          resources: {}
        - name: proxy
          image: "docker.io/envoyproxy/envoy:v1.18.2@sha256:e8b37c1d75787dd1e712ff389b0d37337dc8a174a63bed9c34ba73359dc67da7"
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8081
              name: http
          resources: {}
          command: ["envoy"]
          args: ["-c", "/etc/envoy.yaml", "-l", "info"]
          volumeMounts:
            - name: hubble-ui-envoy-yaml
              mountPath: /etc/envoy.yaml
              subPath: envoy.yaml
      volumes:
        - name: hubble-ui-envoy-yaml
          configMap:
            name: hubble-ui-envoy

Здесь помимо Hubble Relay включается Hubble UI – веб GUI-клиент. Командой cilium hubble ui можно создать для него port-forward. Запустим демо из репозитория Cilium и отправим запрос из одного пода в другой:

kubectl create -f <https://raw.githubusercontent.com/cilium/cilium/HEAD/examples/kubernetes-grpc/cc-door-app.yaml>
kubectl exec terminal-87 -- python3 /cloudcity/cc_door_client.py GetName 1

Демо состоит из пода terminal-87, деплоймента cc-door-mgr и его сервиса. Второй командой мы отправим gRPC-запрос в cc-door-mgr. Если в это время открыть Hubble UI в браузере, то он в реальном времени нарисует карту сетевого взаимодействия:

В данном случае отображены две идентичности – public-terminal, которая относится к поду terminal-87, и cc-door-mgr, которая относится к подам деплоймента cc-door-mgr. Стрелка между ними указывает направление трафика: в данном случае трафик шел от public-terminal до cc-door-mgr на порт 50051 по протоколу TCP. 

Ниже можно увидеть список пакетов в составе трафика, в котором можно посмотреть дополнительную информацию, в том числе вердикт сетевых политик (т.е. был ли заблокирован этот пакет сетевой политикой или нет), TCP-флаги, лейблы идентичностей и т.д.

Также у Hubble есть CLI-клиент. В поде Cilium-агента можно получить примерно ту же информацию о трафике, если выполнить команду hubble observe:

$ hubble observe -n default

Apr  3 07:38:36.239: default/terminal-87:52380 (ID:6040) -> default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) to-endpoint FORWARDED
 (TCP Flags: ACK, PSH)
Apr  3 07:38:36.239: default/terminal-87:52380 (ID:6040) <> default/cc-door-mgr-76658457d4-sm5dp (ID:1419) pre-xlate-rev TRACED (TCP)
Apr  3 07:38:36.239: default/terminal-87:52380 (ID:6040) <- default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) to-endpoint FORWARDED
 (TCP Flags: ACK, PSH)
Apr  3 07:38:36.240: default/terminal-87:52380 (ID:6040) <- default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) to-endpoint FORWARDED
 (TCP Flags: ACK, FIN)
Apr  3 07:38:36.240: default/terminal-87:52380 (ID:6040) -> default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) to-endpoint FORWARDED
 (TCP Flags: ACK)
Apr  3 07:38:36.240: default/terminal-87:52380 (ID:6040) -> default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) to-endpoint FORWARDED
 (TCP Flags: ACK, FIN)
<...>

Наконец, у Hubble есть эндпоинт с метриками для построения графиков и дэшбордов. Чтобы его включить, в values.yaml указываются нужные метрики. С полным списком метрик можно ознакомиться здесь.

hubble:
  metrics:
    enabled: "{tcp,icmp,httpV2,drop}"

Мониторинг L7

На скриншотах Hubble выше вы могли заметить, что в таблице с сетевыми пакетами есть пустой столбец «L7 info». Почему он пуст, если мы отправляем gRPC-запрос, т.е. запрос на уровне L7? Дело в том, что Cilium и Hubble по умолчанию предоставляют мониторинг только на уровнях L3/L4. Для того, чтобы события уровня L7 стали видны, нужно создать сетевую политику с правилом уровня L7. В контексте демо мы можем использовать следующий манифест:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: example
spec:
  endpointSelector:
    matchLabels:
      app: cc-door-mgr
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: public-terminal
    toPorts:
    - ports:
      - port: "50051"
        protocol: TCP
      rules:
        http:
        - method: "POST"
          path: "/cloudcity.DoorManager/GetName"

Так как gRPC работает поверх HTTP, то мы можем использовать HTTP-правило с путем, который соответствует gRPC-вызову. В данном случае это /cloudcity.DoorManager/GetName. Теперь любой L7-трафик, который проходит через эту политику, будет виден в Hubble:

Немного более подробную информацию можно увидеть через CLI (например, код ответа и задержку):

$ hubble observe -n default -f -t l7

Apr  3 17:19:45.115: default/terminal-87:41982 (ID:6040) -> default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) http-request FORWARDED (HTTP/2 POST <http://cc-door-server:50051/cloudcity.DoorManager/GetName>)
Apr  3 17:19:45.117: default/terminal-87:41982 (ID:6040) <- default/cc-door-mgr-76658457d4-sm5dp:50051 (ID:1419) http-response FORWARDED (HTTP/2 200 1ms (POST <http://cc-door-server:50051/cloudcity.DoorManager/GetName>))

Мониторинг и применение сетевых политик на уровне L7 возможны благодаря инъекции Envoy-прокси в соединение, которое проходит через сетевую политику. «Redirected» в столбце «Verdict» на скриншоте выше как раз означает перенаправление трафика на инстанс Envoy.

L7-трафик можно просматривать даже в TLS-зашифрованных соединениях. Это реализовано с помощью сетевых политик, что позволяет расшифровывать трафик выборочно. Я бы не назвал эту фичу удобной, так как для этого нужно добавлять в под внутренний CA-сертификат и выпустить сертификаты для каждого хоста, соединение с которым вы хотите просматривать, однако в некоторых ситуациях это бывает полезно. Подробный пошаговый гайд об этом можно почитать здесь.

Ingress-контроллер и Gateway API

Cilium имеет свою реализацию ingress-контроллера и Gateway API. Для использования этих фич сначала включаем замену kube-proxy (см. раздел Пока, kube-proxy), а в values.yaml добавляем следующее:

ingressController:
  enabled: true
  loadbalancerMode: shared

Далее в поле ingressController.loadbalancerMode указываем режим использования балансировщиков:

  • dedicated – балансировщики создаются отдельно для каждого ingress;

  • shared – для всех ingress создается один общий балансировщик.

Если вы хотите использовать другой сервис для ingress-контроллера, например NodePort, то можете сконфигурировать его следующим образом:

ingressController:
  enabled: true
  loadbalancerMode: shared
  service:
    type: NodePort
    insecureNodePort: 30080
    secureNodePort: 30443

Здесь поля insecureNodePort и secureNodePort используются для указания портов HTTP и HTTPS соответственно, а в type указывается тип сервиса.

Затем устанавливаем соответствующие CRD для Gateway API:

$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/standard/gateway.networking.k8s.io_gatewayclasses.yaml>
$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/standard/gateway.networking.k8s.io_gateways.yaml>
$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/standard/gateway.networking.k8s.io_httproutes.yaml>
$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/standard/gateway.networking.k8s.io_referencegrants.yaml>
$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/experimental/gateway.networking.k8s.io_grpcroutes.yaml>
$ kubectl apply -f <https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.0.0/config/crd/experimental/gateway.networking.k8s.io_tlsroutes.yaml>

И в values.yaml добавляем следующее:

gatewayAPI:
  enabled: true

Также рекомендую после раскатки чарта перезапустить Cilium-агенты и оператор:

$ kubectl -n kube-system rollout restart deployment/cilium-operator
$ kubectl -n kube-system rollout restart ds/cilium

В кластере должны появиться IngressClass и GatewayClass cilium.

Учтите, что Ingress- и Gateway API-контроллеры не будут работать, пока для них не появится хотя бы один Ingress- или Gateway API-ресурс соответственно. Также для работы ингресса в ядре должны быть загружены модули xt_socket и iptable_raw. В некоторых системах (например, NixOS) они могут не быть загружены по умолчанию.

В целом обе реализации имеют очень базовый функционал, не хватает продвинутых фич по типу авторизации (хотя бы basic auth), rate-лимитов и т.п. Поэтому, на мой взгляд, они пока не подходят для production-сред.

L4-балансировщики

Если вы эксплуатируете Kubernetes-кластер, который не находится под управлением облачного провайдера, то для доступа своих сервисов извне вы, скорее всего, будете использовать один из следующих вариантов:

  1. NodePort или hostNetwork;

  2. NodePort или hostNetwork с внешним балансировщиком;

  3. балансировщик Cloud native, работающий в кластере.

Балансировщик Cilium относится к 3 категории, и UX со стороны администратора кластера очень похож на MetalLB – еще одно популярное решение для балансировки.

Чтобы использовать Cilium для балансировки, сначала нужно указать диапазон адресов, которые будут использоваться для сервисов с типом LoadBalancer. Это можно сделать с помощью ресурса CiliumLoadBalancerIPPool:

apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "pool"
spec:
  blocks:
  - cidr: "192.168.1.0/24"
  - start: "192.168.2.2"
    stop: "192.168.2.30"
  - start: "192.168.31.2"

В отличие от предыдущих фич, контроллер для этих объектов всегда активирован, но начинает работу после того, как в кластере появится хотя бы один ресурс CiliumLoadBalancerIPPool. В данном примере пул адресов распространяется на все сервисы, но мы можем их ограничить с помощью serviceSelector:

apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "pool"
spec:
  blocks:
  - cidr: "192.168.1.0/24"
  - start: "192.168.2.2"
    stop: "192.168.2.30"
  - start: "192.168.31.2"
  serviceSelector:
    matchLabels:
      app: backend

Обратите внимание: если создать пулы с пересекающимися диапазонами, то последний из них окажется конфликтующим и не будет выдавать адреса до тех пор, пока пересечение не будет устранено:

$ kubectl get ippool
NAME      DISABLED   CONFLICTING   IPS AVAILABLE   AGE
pool-1    false      False         30              2m
pool-2    false      True          30              14s

После того, как манифест с пулом адресов был применен, у соответствующих сервисов с типом LoadBalancer должен появиться внешний адрес (status.loadBalancer.ingress). Далее нужно решить, каким образом об этих адресах узнает внешняя сеть. Тут Cilium предлагает два способа – BGP и L2 Announcement. В рамках статьи рассмотрим только L2 Announcement, так как его проще воспроизвести в тестовых условиях (например, в домашней сети).

L2 Announcement работает с помощью протокола ARP, который, по сути, является маппингом между IP и MAC-адресами. Это значит, что адреса сервисов не назначаются напрямую на сетевые интерфейсы, а объявляются протоколом в локальной сети. Если нода, на которую указывает запись в ARP, станет недоступна, то для этого IP-адреса будет объявлен MAC-адрес другой ноды, что обеспечивает отказоустойчивость балансировщика.

Включим фичу, добавив в values.yaml следующее (подразумевается, что замена kube-proxy уже включена):

l2announcements:
  enabled: true
k8sClientRateLimit:
  qps: <qps>
  burst: <burts>

Также рекомендую рестартнуть Cilium-агенты и оператор.

k8sClientRateLimit используется для настройки rate-лимитов клиента Kubernetes, так как L2 Announcement может сильно нагружать kube-apiserver. Использовать не обязательно, но пригодится, если Cilium-оператор будет упираться в лимиты. Подробнее об этом можно почитать здесь.

Теперь можно создать политику L2 Announcement:

apiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
  name: l2announcement
spec:
  serviceSelector:
    matchLabels:
      app: backend
  nodeSelector:
    matchExpressions:
      - key: node-role.kubernetes.io/control-plane
        operator: In
        values:
        - l2announcement
  interfaces:
  - ^eth[0-9]+
  externalIPs: true
  loadBalancerIPs: true

Подробнее о полях:

  • serviceSelector – сервисы, чьи адреса будут объявляться политикой. Если значение не указано, то политика будет распространяться на все сервисы;

  • nodeSelector – ноды, чьи интерфейсы будут использоваться для объявления адресов. Если значение не указано, то политика будет распространяться на все ноды;

  • interfaces – интерфейсы для объявления. Если не указано, будут использованы все интерфейсы;

  • externalIPs и loadBalancerIPs – адреса, которые будут объявлены:

    • в случае externalIPs будут объявлены spec.externalIPs, которые задаются автором сервиса;

    • в случае loadBalancerIPs будет объявлен status.loadbalancer.ingress, который задается пулом CiliumLoadBalancerIPPool.

    Можно использовать оба варианта.

После применения манифеста CiliumL2AnnouncementPolicy в кластере появятся ресурсы Lease с префиксом cilium-l2announce- для каждого сервиса, который будет анонсироваться. К примеру, если у вас включены Ingress-контроллер и Gateway API, это будет выглядеть так:

$ kubectl get lease -A
NAMESPACE         NAME                                                  HOLDER                      AGE
<...>
kube-system       cilium-l2announce-default-cilium-gateway-my-gateway   worker2                     16m
kube-system       cilium-l2announce-kube-system-cilium-ingress          worker2                     16m
<...>

Lease используется для избрания ноды-лидера, чей MAC-адрес попадет в ARP-таблицу. Как видно, в данном случае оба сервиса будет анонсировать worker2. Для теста отправим запрос по адресу сервиса, посмотрим на записи в ARP и сравним MAC-адрес:

$ arp -a
<...>
? (192.168.31.3) at 74:56:3c:67:ec:ca on en0 ifscope [ethernet]
<...>

$ ifconfig
<...>
eno1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.31.2  netmask 255.255.255.0  broadcast 0.0.0.0
        inet6 fe80::7656:3cff:fe67:ecca  prefixlen 64  scopeid 0x20<link>
        ether 74:56:3c:67:ec:ca  txqueuelen 1000  (Ethernet)
        RX packets 13313  bytes 3241632 (3.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 22961  bytes 24963524 (23.8 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

В моем случае адрес сервиса был 192.168.31.3, и он успешно появился в таблице. MAC-адрес интерфейса в записи совпадает с тем, что показал ifconfig, а значит, все успешно заработало.

Service Mesh

Теперь предлагаю рассмотреть Cilium и eBPF в контексте Service Mesh. Я думаю, ни для кого не будет новостью, что самым спорным техническим решением в современных системах Service Mesh (к примеру, Istio и Linkerd) является использование sidecar-прокси. Разумеется, они обладают существенными преимуществами, а именно:

  • позволяют ассоциировать идентичности с подами;

  • позволяют шифровать трафика между подами;

  • дают возможность масштабировать потребление ресурсов отдельно для каждого пода;

  • предоставляют фичу «single-tenancy», благодаря которой падение прокси приводит к недоступности только того пода, в котором он находится.

При этом не менее существенными являются их недостатки:

  • увеличенное потребление ресурсов кластера;

  • увеличенное время старта подов;

  • «race condition» при старте подов;

  • проблемы с ресурсами Job и CronJob;

  • необходимость перезапуска приложения для обновления прокси.

В итоге sidecar-контейнеры воспринимаются скорее как «необходимое зло», с которым приходится мириться, чтобы использовать фичи Service Mesh. Может ли Cilium исправить эту ситуацию и избавить нас от необходимости иметь дело с sidecar-прокси?

Ответ – и да, и нет. Дело в том, что ввиду некоторых технических ограничений, на которых построен eBPF, в него пока сложно перенести большую часть функционала уровня L7 (подробнее об этом рассказал Ювал Кохави из Solo.io в своем выступлении). Поэтому Cilium приходится использовать Envoy для L7, в то время как функционал уровней L3 и L4 перенесен в ядро и работает в eBPF.

Как я уже упомянул выше, Envoy в Cilium развертывается на каждую ноду как DaemonSet, либо как вшитый в Cilium-агент. Похожим образом работает новый Ambient-режим в Istio, где прокси разворачивается либо на каждую ноду, либо на namespace. Несмотря на то, что такой подход решает некоторые проблемы sidecar'ов, он также не идеален и имеет свои издержки:

  • непредсказуемое потребление ресурсов у прокси;

  • проблемы «noisy neighbour»: прокси должен обеспечивать QoS для всех тенантов;

  • падение и обновление прокси влияет на все его тенанты.

Таким образом, Cilium избавился от sidecar'ов, но не избавился от прокси, поэтому в ближайшее время нам не избежать их присутствия в кластерах. Вопрос в том, какой скоуп для них использовать: на каждый под, каждый неймспейс или каждую ноду? Все варианты по-своему хороши и плохи, поэтому выбор придется делать, исходя из конкретных целей и задач.

Control plane

Control plane в Cilium состоит из следующих компонентов:

  1. Ingress;

  2. Gateway API;

  3. CiliumNetworkPolicy;

  4. CiliumEnvoyConfig;

  5. CiliumClusterideEnvoyConfig.

О первых трех пунктах я уже рассказал выше. По задумке разработчиков, Cilium не предоставляет наполненный фичами Control plane, как, например, в Istio, но предоставляет платформу для сторонних Control plane'ов с помощью ресурсов CiliumEnvoyConfig и CiliumClusterideEnvoyConfig. Они дают прямой доступ к конфигу Envoy и позволяют создавать своих listener'ов.

Cilium Ingress и Gateway API также используют эти ресурсы. Например, если создать Ingress для Hubble UI, его CiliumEnvoyConfig будет выглядеть так:

Скрытый текст
apiVersion: cilium.io/v2
kind: CiliumEnvoyConfig
metadata:
  creationTimestamp: "2024-04-13T17:12:39Z"
  generation: 5
  labels:
    cilium.io/use-original-source-address: "false"
  name: cilium-ingress
  namespace: kube-system
  resourceVersion: "1459379"
  uid: 71deeebf-6436-49d0-bd1b-5fd3f8e81b51
spec:
  backendServices:
  - name: hubble-ui
    namespace: kube-system
    number:
    - http
  resources:
  - '@type': type.googleapis.com/envoy.config.listener.v3.Listener
    filterChains:
    - filterChainMatch:
        transportProtocol: raw_buffer
      filters:
      - name: envoy.filters.network.http_connection_manager
        typedConfig:
          '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          commonHttpProtocolOptions:
            maxStreamDuration: 0s
          httpFilters:
          - name: envoy.filters.http.grpc_web
            typedConfig:
              '@type': type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
          - name: envoy.filters.http.grpc_stats
            typedConfig:
              '@type': type.googleapis.com/envoy.extensions.filters.http.grpc_stats.v3.FilterConfig
              emitFilterState: true
              enableUpstreamStats: true
          - name: envoy.filters.http.router
            typedConfig:
              '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          rds:
            routeConfigName: listener-insecure
          statPrefix: listener-insecure
          upgradeConfigs:
          - upgradeType: websocket
          useRemoteAddress: true
    listenerFilters:
    - name: envoy.filters.listener.tls_inspector
      typedConfig:
        '@type': type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
    name: listener
    socketOptions:
    - description: Enable TCP keep-alive (default to enabled)
      intValue: "1"
      level: "1"
      name: "9"
    - description: TCP keep-alive idle time (in seconds) (defaults to 10s)
      intValue: "10"
      level: "6"
      name: "4"
    - description: TCP keep-alive probe intervals (in seconds) (defaults to 5s)
      intValue: "5"
      level: "6"
      name: "5"
    - description: TCP keep-alive probe max failures.
      intValue: "10"
      level: "6"
      name: "6"
  - '@type': type.googleapis.com/envoy.config.route.v3.RouteConfiguration
    name: listener-insecure
    virtualHosts:
    - domains:
      - hubble.test.local
      - hubble.test.local:*
      name: hubble.test.local
      routes:
      - match:
          prefix: /
        route:
          cluster: kube-system:hubble-ui:http
  - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
    connectTimeout: 5s
    edsClusterConfig:
      serviceName: kube-system/hubble-ui:http
    name: kube-system:hubble-ui:http
    outlierDetection:
      splitExternalLocalOriginErrors: true
    type: EDS
    typedExtensionProtocolOptions:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        commonHttpProtocolOptions:
          idleTimeout: 60s
        useDownstreamProtocolConfig:
          http2ProtocolOptions: {}
  services:
  - listener: ""
    name: cilium-ingress
    namespace: kube-system

Говоря о сторонних Control plane'ах, у Cilium есть интеграция с Istio как в режиме sidecar, так и в режиме ambient. С точки зрения администратора, установка и эксплуатация Istio с Cilium практически не отличается от установки и эксплуатации c любым другим CNI. Но есть пара нюансов:

  • если включена замена kube-proxy, то в values.yaml Cilium нужно включить socketLB.hostNamespaceOnly: true;

  • Для управления трафиком уровня L7 лучше не использовать Cilium и Istio одновременно, чтобы избежать «split-brain» проблем.

Послесловие

Разумеется, в рамках одной статьи невозможно разобрать все фичи Cilium. Я постарался сосредоточиться на тех, что показались мне наиболее полезными и универсальными. Если вы хотите изучить возможности этого плагина глубже, то можете почитать еще и о том, как объединить несколько кластеров в общую сеть (Cluster mesh) или настроить шифрование трафика между нодами с помощью IPsec и Wireguard.

Тем не менее, я надеюсь, что эта обзорная статья помогла вам ознакомиться с плагином. Несмотря на некоторые мелкие неудобства, стоит сказать, что проект стремительно развивается и вместе с eBPF уже сейчас значительно упрощает работу с сетью в Kubernetes. Вкупе с другими фичами, эта технология заметно выделяет Cilium на фоне других популярных CNI-проектов вроде Calico и Flannel.

Для тех, кто также интересуется другими аспектами работы с Kubernetes, предлагаю ознакомиться с нашими материалами на эту тему:

Если же материал показался вам слишком сложным, даже не думайте опускать руки. Вы можете ознакомиться с нашими статьями о Kubernetes для начинающих бэкэнд-разработчиков, после чего вернуться к изучению CNI-плагинов: