Представьте, однажды вы приходите в новую компанию на позицию 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. Отдаю себе отчет, что некоторые решения могут быть неоптимальными, но они рабочие и свою задачу выполняют. Если у вас есть опыт работы с данными инструментами и свои варианты решения возникшей передо мной задачи — приглашаю вас обсудить их в комментарии.