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

Kate

Administrator
Команда форума
Сегодня поговорим о том, как тестировать 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 кластер.

 
Сверху