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

4d4fe1b1b419ec4133b8998174135061.png

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

Сегодня поговорим о том, как тестировать 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» пройдут открытые уроки:

© Habrahabr.ru