Функциональное тестирование Kubernetes Operators с Kubebuilder

от автора

Привет, Хабр!

Сегодня поговорим о том, как тестировать Kubernetes Operators с помощью одного замечательного фреймворка. Функциональное тестирование — это не просто «хорошо бы», это необходимость. А вот как сделать качественное тестирование без боли? Здесь и поможет фреймворк Kubebuilder — инструмент, который упрощает тестирование и разработку операторов.

Немного про Kubebuilder

Kubebuilder построен на базе controller‑runtime и client‑go, двух мощнейших библиотек от самого Kubernetes.

Kubebuilder автоматически генерирует много boilerplate‑кода, конфигурации CRD и все остальное, что необходимо для полноценного оператора. А еще этот инструмент включает в себя тестовый фреймворк, который позволяет тебе не только писать контроллеры, но и тестировать их в изолированной среде. Мы поговорим о тестировании чуть позже, но пока — настроим окружение и запустим Kubebuilder.

Для начала понадобится установить несколько зависимостей. Прежде чем двигаться дальше, нужно будет установить Go, потому что Kubebuilder — это инструмент для Golang.

А сам Kubebuilder можно скачать с официального репозитория, есть команда, которая сделает все за тебя:

curl -L https://github.com/kubernetes-sigs/kubebuilder/releases/download/v3.4.0/kubebuilder_linux_amd64 -o kubebuilder chmod +x kubebuilder sudo mv kubebuilder /usr/local/bin/

Если ты на MacOS:

brew install kubebuilder

Проверяем установку:

kubebuilder version

Если все прошло успешно, увидишь версию Kubebuilder и то, что все нужные компоненты работают.

Теперь создадим новый проект оператора. Kubebuilder генерирует основу для оператора, начиная с командной строки. Сначала нужно инициализировать проект:

kubebuilder init --domain my.domain --repo github.com/your-username/my-operator

Эта команда создаст минимальную структуру проекта с основными файлами для Go-модуля и зависимостями. Параметр --domain указывает доменное имя для твоих CRD. Например, если ты разрабатываешь оператора для своей компании, то можешь указать --domain yourcompany.com.

Далее нужно создать API и контроллер для нашего оператора:

kubebuilder create api --group batch --version v1 --kind Job

Эта команда генерирует необходимые файлы для API и контроллера Kubernetes. Параметр --group указывает на группу ресурсов (в данном случае это batch), --version на версию API, а --kind на тип ресурса, с которым работает оператор (например, Job).

После этого мы видим новую структуру проекта с файлом API в api/v1/job_types.go, где определена структура CRD, и файлом контроллера в controllers/job_controller.go, где прописана логика работы оператора.

Теперь рассмотрим как писать логику для нашего оператора. Возьмем за основу пример с контроллером Job. В файле job_controller.go ты найдешь метод Reconcile, который отвечает за то, как оператор реагирует на изменения в ресурсах. Здесь мы будем писать логику, что делать, когда Kubernetes вносит изменения в объект Job.

Пример простейшей логики:

func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {     log := log.FromContext(ctx)      // Получаем ресурс     var job batchv1.Job     if err := r.Get(ctx, req.NamespacedName, &job); err != nil {         log.Error(err, "unable to fetch Job")         return ctrl.Result{}, client.IgnoreNotFound(err)     }      // Здесь пишем логику работы с ресурсом, например:     // Проверяем, создан ли под для этого Job, если нет — создаем.          return ctrl.Result{}, nil }

Здесь мы используем стандартный клиент Kubebuilder для получения объекта Job из кластера. После этого можно написать любую логику, которую ты хочешь внедрить в работу оператора.

Но мы здесь собрались для тестирования. Приступим.

Функциональное тестирование Kubernetes Operators с Kubebuilder

EnvTest — это lightweight-окружение для тестирования контроллеров Kubernetes, которое позволяет запускать тесты без развертывания полноценного кластера.

Первым делом нам нужно подготовить тестовое окружение. Для этого воспользуемся пакетом controller-runtime/pkg/envtest, который уже входит в состав Kubebuilder. Для начала, добавим его в зависимости нашего проекта:

go get sigs.k8s.io/controller-runtime/pkg/envtest

Затем создаем файл main_test.go, где будет находиться наш тестовый код:

package main_test  import (     "testing"     "sigs.k8s.io/controller-runtime/pkg/client"     "sigs.k8s.io/controller-runtime/pkg/envtest"     "sigs.k8s.io/controller-runtime/pkg/log/zap"     "github.com/onsi/gomega" )  var k8sClient client.Client var testEnv *envtest.Environment  func TestMain(m *testing.M) {     gomega.RegisterFailHandler(gomega.Fail)     testEnv = &envtest.Environment{         CRDDirectoryPaths: []string{"../config/crd/bases"},     }      var err error     cfg, err := testEnv.Start()     if err != nil {         panic(err)     }      k8sClient, err = client.New(cfg, client.Options{})     if err != nil {         panic(err)     }      code := m.Run()     testEnv.Stop()     os.Exit(code) }

Что тут происходит:

  • envtest.Environment настраивает минимальный Kubernetes API-сервер и etcd для тестирования CRD и контроллеров.

  • client.New создает клиента для взаимодействия с объектами в кластере.

Этот код запускает тестовую среду и инициализирует API-сервер. Теперь можно приступать к написанию тестов.

Тестирование CRD

Начнем с простого теста, который проверяет, создается ли корректно наш CRD.

Допустим, мы работаем с ресурсом Job. Пример кода для создания CRD и проверки, что оно корректно создано в кластере:

func TestCreateCRD(t *testing.T) {     g := gomega.NewWithT(t)      // Создаем объект CRD     job := &batchv1.Job{         ObjectMeta: metav1.ObjectMeta{             Name: "test-job",             Namespace: "default",         },         Spec: batchv1.JobSpec{             Template: corev1.PodTemplateSpec{                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "busybox",                             Image: "busybox",                             Command: []string{"sleep", "10"},                         },                     },                     RestartPolicy: corev1.RestartPolicyNever,                 },             },         },     }      // Создаем объект в тестовой среде     err := k8sClient.Create(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Проверяем, что объект действительно создан     fetchedJob := &batchv1.Job{}     err = k8sClient.Get(context.Background(), client.ObjectKey{Name: "test-job", Namespace: "default"}, fetchedJob)     g.Expect(err).NotTo(gomega.HaveOccurred())     g.Expect(fetchedJob.Name).To(gomega.Equal("test-job")) }

Этот тест проверяет, что при создании объекта Job наш контроллер корректно его обрабатывает и объект появляется в кластере. Используя gomega как фреймворк для утверждений, можно убедиться, что ошибки не возникают, и объект действительно создан.

Взаимодействие с другими объектами в кластере

Теперь усложним задачу и проверим, как оператор взаимодействует с другими объектами Kubernetes. Например, оператор должен автоматически создавать ConfigMap при создании определенного CRD. Вот как можно протестировать эту логику:

func TestConfigMapCreation(t *testing.T) {     g := gomega.NewWithT(t)      // Создаем CRD     job := &batchv1.Job{         ObjectMeta: metav1.ObjectMeta{             Name: "job-with-configmap",             Namespace: "default",         },         Spec: batchv1.JobSpec{             Template: corev1.PodTemplateSpec{                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "nginx",                             Image: "nginx",                         },                     },                     RestartPolicy: corev1.RestartPolicyNever,                 },             },         },     }      err := k8sClient.Create(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Проверяем, что ConfigMap создан     configMap := &corev1.ConfigMap{}     err = k8sClient.Get(context.Background(), client.ObjectKey{Name: "job-configmap", Namespace: "default"}, configMap)     g.Expect(err).NotTo(gomega.HaveOccurred())     g.Expect(configMap.Data["config"]).To(gomega.Equal("some-config-data")) }

Здесь проверяем, что при создании Job, наш контроллер автоматически создает ConfigMap, содержащий нужные данные.

Обработка событий и реакция на изменения

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

Пример теста, который проверяет реакцию на событие:

func TestJobFailureEvent(t *testing.T) {     g := gomega.NewWithT(t)      // Создаем объект Job с ошибочным подом     job := &batchv1.Job{         ObjectMeta: metav1.ObjectMeta{             Name: "failing-job",             Namespace: "default",         },         Spec: batchv1.JobSpec{             Template: corev1.PodTemplateSpec{                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "busybox",                             Image: "busybox",                             Command: []string{"false"}, // Под завершится с ошибкой                         },                     },                     RestartPolicy: corev1.RestartPolicyNever,                 },             },         },     }      err := k8sClient.Create(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Проверяем, что оператор среагировал на событие и выполнил корректные действия     // Например, оператор создает событие с ошибкой     events := &corev1.EventList{}     err = k8sClient.List(context.Background(), events, client.InNamespace("default"))     g.Expect(err).NotTo(gomega.HaveOccurred())     g.Expect(events.Items).NotTo(gomega.BeEmpty())     g.Expect(events.Items[0].Reason).To(gomega.Equal("FailedJob")) }

Этот тест симулирует ошибку в Job и проверяет, что оператор правильно реагирует на это событие, создавая запись о сбое.

Тестирование обновлений ресурсов

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

func TestUpdateJobConfig(t *testing.T) {     g := gomega.NewWithT(t)      // Создаем исходный объект Job     job := &batchv1.Job{         ObjectMeta: metav1.ObjectMeta{             Name:      "update-job",             Namespace: "default",         },         Spec: batchv1.JobSpec{             Template: corev1.PodTemplateSpec{                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "nginx",                             Image: "nginx",                         },                     },                     RestartPolicy: corev1.RestartPolicyNever,                 },             },         },     }      err := k8sClient.Create(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Изменяем Job     job.Spec.Template.Spec.Containers[0].Image = "nginx:latest"     err = k8sClient.Update(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Проверяем, что изменения были приняты и оператор обновил ConfigMap     configMap := &corev1.ConfigMap{}     err = k8sClient.Get(context.Background(), client.ObjectKey{Name: "update-job-configmap", Namespace: "default"}, configMap)     g.Expect(err).NotTo(gomega.HaveOccurred())     g.Expect(configMap.Data["config"]).To(gomega.Equal("updated-config-data")) }

Оператор реагирует на обновление существующего ресурса и выполняет соответствующие действия, как обновление ConfigMap.

Тестирование зависимостей между ресурсами

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

func TestJobDeploymentSync(t *testing.T) {     g := gomega.NewWithT(t)      // Создаем Job     job := &batchv1.Job{         ObjectMeta: metav1.ObjectMeta{             Name:      "sync-job",             Namespace: "default",         },         Spec: batchv1.JobSpec{             Template: corev1.PodTemplateSpec{                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "nginx",                             Image: "nginx",                         },                     },                     RestartPolicy: corev1.RestartPolicyNever,                 },             },         },     }      err := k8sClient.Create(context.Background(), job)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Создаем связанный Deployment     deployment := &appsv1.Deployment{         ObjectMeta: metav1.ObjectMeta{             Name:      "sync-deployment",             Namespace: "default",         },         Spec: appsv1.DeploymentSpec{             Selector: &metav1.LabelSelector{                 MatchLabels: map[string]string{"app": "nginx"},             },             Template: corev1.PodTemplateSpec{                 ObjectMeta: metav1.ObjectMeta{                     Labels: map[string]string{"app": "nginx"},                 },                 Spec: corev1.PodSpec{                     Containers: []corev1.Container{                         {                             Name:  "nginx",                             Image: "nginx",                         },                     },                 },             },         },     }      err = k8sClient.Create(context.Background(), deployment)     g.Expect(err).NotTo(gomega.HaveOccurred())      // Проверяем, что Deployment синхронизирован с Job     fetchedDeployment := &appsv1.Deployment{}     err = k8sClient.Get(context.Background(), client.ObjectKey{Name: "sync-deployment", Namespace: "default"}, fetchedDeployment)     g.Expect(err).NotTo(gomega.HaveOccurred())     g.Expect(fetchedDeployment.Spec.Template.Spec.Containers[0].Image).To(gomega.Equal("nginx")) }

Этот тест проверяет, что оператор синхронизирует состояние Deployment с изменениями в Job.

Заключение

Kubebuilder дает возможность тестировать сложные сценарии в легковесной среде, не поднимая полноценный Kubernetes кластер.


Скоро в рамках онлайн-курса «Инфраструктурная платформа на основе Kubernetes» пройдут открытые уроки:


ссылка на оригинал статьи https://habr.com/ru/articles/848582/