Привет, Хабр!
Сегодня поговорим о том, как тестировать 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» пройдут открытые уроки:
8 октября: «Хранение данных в Kubernetes: Volumes, Storages, Stateful-приложения». Узнать подробнее
21 октября: «Service Mesh: Введение в Istio». Узнать подробнее