Представьте, однажды вы приходите в новую компанию на позицию Automation QA и перед вами возникает задача: на пустом поле проекта посеять зерна автотестов, которые прорастут в регулярный процесс тестирования и будут отлавливать различные баги. Такая задача возникла и передо мной, поэтому я хочу поделиться своим опытом, как строил тестирование кастомного K8s CNI-плагина в новой для себя области. Статья будет полезна QA-инженерам, которые на «ты» с Python, но на «вы» с тестированием Kubernetes с помощью автотестов. При решении задачи я столкнулся с вопросами, на которые нигде не нашел ответа. Возможно, раз мне помог описанный путь, поможет и вам.

Немного контекста

Я Роман Черепанов, Automation QA, уже семь лет работаю в тестировании различных сетевых продуктов. Моя команда занимается разработкой своего Kubernetes CNI-плагина с доработками, необходимыми для настройки сетевой связности между различными облачными сервисами, предоставляемыми Cloud.ru. В качестве основы взят Multus — плагин создания нескольких интерфейсов в рамках Pod.

В процессе повествования я опущу базовые понятия о Kubernetes (что такое кластер, Pod, контейнер и т. д.), чтобы сократить хронометраж. О них вы можете прочитать в документации на официальном сайте, там достаточно доступно объяснено.

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

  • возможность деплоить Pod с CNI,

  • добавлять network attach definition (далее по тексту net-attach-def) для создания сетевых интерфейсов,

  • проверять сетевую связность с созданными интерфейсами как с внутренних, так и внешних (через интернет) хостов.

Язык фреймворка — Python. Общение с кластером через kube-api.

И первый же вопрос, который встал передо мной, оказался такой: какую библиотеку для взаимодействия с kube-api выбрать?

Как тестировщик библиотеки Kubernetes API выбирал

На официальном сайте есть список библиотек API-клиента, можно поискать в интернете другие, но с высокой вероятностью это будут неактуальные вещи. Вот пару мыслей о клиентах из списка:

Библиотека

Комментарий

kubernetes-client

Официальный клиент, самый распространенный и популярный, есть документация, много примеров как в репозитории, так и в интернете. Проект регулярно обновляется на момент написания статьи свежая версия, v36.0.3. Это синхронный клиент, хотя в некоторых методах есть возможность делать асинхронные запросы.

kubernetes_asyncio

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

cloudcoil

Высокоуровневый клиент, в котором Pydantic-модели используются для представления компонентов k8s. Нацелен, в первую очередь, на асинхронный режим работы. Не сильно популярный, но продолжает развиваться. Решил в него не погружаться.

k8s

Проект выглядит не очень активным, поэтому, думаю, можно его пропустить.

lightkube

Обертка над официальным клиентом, нацеленная на упрощение работы с K8s API для создания более удобного и человекочитаемого кода. Использует библиотеку httpx. Умеет в синхронную-асинхронную работу. Интересный вариант.

kr8s

Еще одна обертка над официальным клиентом. По словам создателя, главной целью создания этой библиотеки было максимальное упрощение взаимодействия с Kubernetes API, чтобы код был понятен людям знакомым с Kubeclt. По популярности этот клиент идет следом за официальным, что делает его хорошим кандидатом для использования. Также поддерживает синхронную-асинхронную работу.

kubernetes-py

Неживой проект, уже 5 лет не было обновлений.

kubesdk

Мощный асинхронный клиент, рассчитанный на системы с множеством кластеров. Наверное слишком мощный для простого фреймворка автотестов.

pykorm

Также неживой, 4 года без новостей.

Из всего списка я присмотрел для себя три кандидата: kubernetes-client, kr8s, lightkube.

В своем выборе я ориентировался на то, чтобы библиотека активно развивалась и поддерживалась, была стабильная, имела хорошую и понятную документацию, являлась в первую очередь синхронной (не хотелось лезть в асинхронщину). В итоге решил использовать официальный клиент как самый распространенный, хотя руки чесались попробовать kr8s. Да, придется самому писать обертки, но я воспринял это как челлендж поглубже разобраться в устройстве Kubernetes.

Разработчик kr8s написал статью со сравнением нескольких клиентов (kubernetes-client, kubernetes-asyncio, pykube-ng, lightkube) с примерами кода при выполнении тех или иных базовых операций на кластере. Из списка клиентов только pykube-ng устарел, остальные актуальны и если кто-то выбирает какой клиент взять для работы, рекомендую прочитать статью.

Как тестировщик скелет фреймворка создавал

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

  • K8sAPI будет работать напрямую с API кубера и получать сырые данные.

  • DataCollector будет брать сырые данные, обрабатывать, выдергивать оттуда нужную информацию и складировать в pandas DataFrame — мне очень нравится этот инструмент как способ работы с данными, мощная штука.

  • SSH-клиент на базе Netmiko будет взаимодействовать с тестовыми ВМ, которые будут выполнять роль клиента.

Структура моего фреймворка
Структура моего фреймворка

Подробное наполнение фреймворка я опущу, ведь главная звезда нашей сцены — это клиент Kubernetes API и его взаимодействие с реальным кластером, ведь для написания качественных тестов нужно выполнить три этапа:

1. Задеплоить тестовые K8s-объекты. Это могут быть Deployment, Pod, CRD.

2. Выполнить действия над этими объектами. Проверить, что создались сами объекты, интерфейсы, есть сетевая связность и т. д.

3. Удалить все, что мы наворотили во время теста. Чтобы оно не мешало другим тестам в будущем.

Переходим к первой сложности, которая у меня возникла на этапе развертывания тестового окружения.

Как тестировщик неправильно манифест в метод совал

Для тестирования развертывания CNI в тестовой Pod разработчики предоставили мне такой пример yaml-манифеста:

apiVersion: "k8s.cni.cncf.io/v1"  
kind: NetworkAttachmentDefinition  
metadata:  
  name: cni-net 
  namespace: default  
spec:  
  config: >  
    {  
      "cniVersion": "1.1.0",  
      "type": "cni",  
      "name": "test-cni",    
      "ip": "172.19.0.2/32",   
  }  
---  
apiVersion: apps/v1  
kind: Deployment  
metadata:  
  name: deploy-cni  
  namespace: default  
spec:  
  replicas: 2  
  selector:  
    matchLabels:  
      app: deploy-cni 
  template:  
    metadata:  
      annotations:  
        k8s.v1.cni.cncf.io/networks: cni-net
      labels:  
        app: deploy-cni
      containers:  
        - name: swiss-army-knife  
          image: swiss-army-knife:latest  
          command: [ "/bin/sleep", "3650d" ]  
          imagePullPolicy: IfNotPresent  
      restartPolicy: Always

В этом манифесте описано создание Deployment и net-attach-def. Второй объект — это вариант custom resource definition от Multus для описания параметров создания нескольких сетевых интерфейсов в Pod.

Вооружившись примером кода создания Deployment из официального репозитория kubernetes-api и чуть-чуть подправив его под свои нужды, я попробовал создать свой первый Pod:

config.load_kube_config()

    with open(path.join(path.dirname(__file__), "config_files/test-cni.yaml")) as f:
        dep = yaml.safe_load(f)
        k8s_apps_v1 = client.AppsV1Api()
        resp = k8s_apps_v1.create_namespaced_deployment(
            body=dep, namespace="default")
        print(f"Deployment created. Status='{resp.metadata.name}'")

И тут же наткнулся на ошибку yaml.composer.ComposerError: expected a single document in the stream. Проблема в том, что метод safe_load не понимает разделителя «---» и нужно делить манифест на два отдельных файла, после чего работать с каждым файлом отдельно. Альтернативный вариант — взять метод kubernetes.utils.create_from_yaml. В этом случае под капотом метода yaml-файл делится на отдельные словари согласно разделителю, а потом они добавляются в список и передаются дальше на создание объектов. Из преимуществ — то, что не нужно использовать менеджер контекста и отдельный вызов метода создания Deployment. Функция create_from_yaml принимает в качестве аргументов API для создания K8s-объекта и путь к файлу с манифестов, делая два дела: читает файл и создает объект. Удобно и лаконично.

Вроде бы проблема решена, но не тут-то было. После применения получаем новую ошибку: module 'kubernetes.client' has no attribute 'K8sCniCncfIoV1Api'.

Если этот файл применить через команду kubectl на мастер-ноде кластера, то все заработает. Но при взаимодействии с kube-api через Python-клиент все сложнее. В документации перечислено несколько десятков API для взаимодействия с различными ресурсами кубера. Подробнее о каждой API можно прочитать здесь.

В Python-клиенте для каждого endpoint создан свой класс. Как я уже упоминал выше, net-attach-def является вариантом custom resource definition, поэтому необходимо использовать класс CustomObjectsApi().

Методы для работы с CRD требуют больше аргументов для создания объекта, нужно указать больше данных о нем и передать манифест в JSON-формате:

from kubernetes import client

client.CustomObjectsApi().create_namespaced_custom_object(  
    group="k8s.cni.cncf.io",  
    version="v1",  
    namespace=namespace,  
    plural="network-attachment-definitions",  
    body=net_attach_def_manifest_body,  
)

После замены мне удалось создать тестовые Pod в рамках deployment и первый этап был завершен.

Уже в процессе написания статьи я наткнулся на еще один способ применения манифеста. Есть такой класс DynaminClient, который может взаимодействовать с любой K8s API для создания ресурса будь то CRD, Pod, Namespace и тд. Пример метода:

from kubernetes import client

def apply_manifest_via_dynamic_client(self, namespace: str, manifests_list: list) -> None:
    for manifest in manifests_list:
        api_version = manifest.get("apiVersion")
        kind = manifest.get("kind")
        resourse_api = client.dynamic_client.resources.get(
            api_version=api_version,
            kind=kind
            )
        resourse_api.create(body=manifest, namespace=namespace)

Однако, к сожалению, этот метод не делает все по одной кнопке. Все равно нужно читать yaml-файл с помощью метода safe_load_all и из полученного списка в цикле брать каждый манифест. Далее находить нужные данные для получения API ресурса и уже после этого создавать объект с помощью манифеста. Этот способ может пригодится, если у вас в манифесте несколько различных ресурсов и не хочется под каждый создавать отдельный yaml.

Итого моим решением оказалось разделение манифеста на два (deployment и net-attach-def), использование функции create_from_yaml, написание двух отдельных методов для создания этих объектов в классе K8sApi. Кода стало в два раза больше, но более изящного способа я не нашел. Если кто-то из уважаемых читателей сталкивался с такой проблемой и нашел другой способ решения, пожалуйста, поделитесь в комментариях, с интересом почитаю.

Как тестировщик неверно netcat с толкача заводил

Переходим к следующему этапу, а именно к проверке сетевой связности. Наши интерфейсы созданы, маршруты настроены (в автоматическом режиме) и Pod готова принимать трафик от других хостов.

Чтобы проверить, что пакеты действительно могут правильно входить в Pod, нужно с виртуальной машины (ВМ), имитатора клиента, отправить трафик на вход в Pod. Ping для этого подошел бы, но ICMP заблочен в кластере политиками firewall, поэтому альтернативным вариантом был выбран netcat. Можно и TCP и UDP-дейтаграммы отправить, и в использовании он прост. Виртуальная машина создана штатными средствами консоли, также она находится с кластером в одной VPC, но разных подсетях, маршрутизация между ними настроена.

Задача следующая: запустить на Pod сервер netcat, на ВМ запустить клиент и получить сообщение об успешном подключении. В репозитории официального клиента есть пример кода для выполнения любой команды в Pod:

exec_command = ['/bin/sh']
    resp = stream(api_instance.connect_get_namespaced_pod_exec,
                  name,
                  'default',
                  command=exec_command,
                  stderr=True, stdin=True,
                  stdout=True, tty=False,
                  _preload_content=False)
    commands = [
        "echo This message goes to stdout",
        "echo \"This message goes to stderr\" >&2",
    ]

    while resp.is_open():
        resp.update(timeout=1)
        if resp.peek_stdout():
            print(f"STDOUT: {resp.read_stdout()}")
        if resp.peek_stderr():
            print(f"STDERR: {resp.read_stderr()}")
        if commands:
            c = commands.pop(0)
            print(f"Running command... {c}\n")
            resp.write_stdin(c + "\n")
        else:
            break

    resp.write_stdin("date\n")
    sdate = resp.readline_stdout(timeout=3)
    print(f"Server date command returns: {sdate}")
    resp.write_stdin("whoami\n")
    user = resp.readline_stdout(timeout=3)
    print(f"Server user is: {user}")
    resp.close()

Эта часть кода отвечает за выполнение команды в Pod и чтения результата.

Но есть нюанс: этот метод выполняет команду и ожидает вывода, а netcat работает в интерактивном режиме либо до первого успешного подключения, либо до посинения вместе с флагом «-k». Отсюда следует вывод, что надо использовать асинхронное выполнение команды или же запускать приложение в фоне. Уже упоминалось, что я не хотел лезть в асинхронщину без крайней необходимости, но в итоге полез. И у меня ничего не получилось. Я пробовал асинхронную версию клиента, библиотеку asyncio, засунуть выполнение в тред. Последовательность выполнения команды была неправильной, трафик не проходил, а сроки уже поджимали, так что я бросил эту затею и решил сделать все в синхронном варианте.

Запустив утилиту netcat в Pod с «&» через команду kubectl exec, чтобы добиться запуска в фоне, я обнаружил интересную особенность: приложение не работает, а вот какие-то зомби-процессы выполнения bash появились. Самое смешное, что во время гугления Gemini (или что там нынче под капотом поисковика) меня упорно убеждал в корректности этого варианта запуска. Альтернативным решением оказалось выполнение netcat при создании Pod с помощью команды в манифесте:

spec:  
  containers:  
    - name: swiss-army-knife  
      image: swiss-army-knife:latest  
      command: [ "/bin/sh", "-c" ]  
      args: ["netcat -lvnp 12345 -k & sleep infinity"]  
      imagePullPolicy: IfNotPresent  
  restartPolicy: Always

Эта часть манифеста, отвечающая за запуск контейнера в Pod.

Далее идем на ВМ с помощью нашего SSH-класса, запускаем netcat на отправку сегментов ииии... И о чудо, наконец-то все проблемы были решены, и я получил заветный вывод netcat на стороне клиента: Connection to 172.19.0.2 12345 port [tcp/*] succeeded!

Как тестировщик за собой Pod подчищал

Финалом любого хорошего автотеста должна быть очистка стенда от его деятельности. Нужно удалить Deployment (автоматом удалится созданная Pod) и net-attach-def. Примера в официальном репозитории не оказалось, и я полез искать соответствующие методы. Через короткое время я их нашел в API AppsV1Api() и CustomObjectsApi() классах:

from kubernetes import client

client.AppsV1Api().delete_namespaced_deployment(name=deployment_name, namespace=namespace)
client.CustomObjectsApi().crd_api_v1.delete_namespaced_custom_object(  
    group="k8s.cni.cncf.io",  
    version="v1",  
    namespace=namespace,  
    plural="network-attachment-definitions",  
    name=net_attach_def_name,  
)

Net-attach-def удаляется моментально, а вот после удаления Deployment желательно написать функцию ожидания удаления Pod, иначе есть риск, что при запуске следующих тестов случится коллизия и они упадут.

Как тестировщик к успеху пришел

Мне удалось достичь основных шагов из списка требований: с помощью фреймворка можно написать тест, который создаст Pod, запустит в ней netcat-сервер, подключится к ВМ и с клиента отправит сегменты и дейтаграммы прямиком в Pod, проверив, что все получилось, а в конце — удалит все. В этой статье я хотел сфокусироваться именно на тех трудностях, с которыми я столкнулся при работе с Kubernetes и на решение которых потратил не один час в экспериментах и поисках. Описание всех нюансов строения фреймворка займет не одну статью.

Это было мое знакомство c K8s-клиентом и написание автотеста для проверки сетевой доступности интерфейсов в Pod. Отдаю себе отчет, что некоторые решения могут быть неоптимальными, но они рабочие и свою задачу выполняют. Если у вас есть опыт работы с данными инструментами и свои варианты решения возникшей передо мной задачи — приглашаю вас обсудить их в комментарии.

Комментарии (0)