Привет, Хабр! Меня зовут Максим, я инженер в команде облачных сервисов Selectel. В этой статье хотел бы рассказать, как мы тестируем кластеры Kubernetes перед тем, как обновления попадают к клиентам.

Мой коллега Артём уже писал о тестировании в Kubernetes с помощью Python. Сегодня же мы посмотрим на небольшие примеры на Go.

Используйте навигацию, если не хотите читать текст полностью:
Предыстория
Пишем тест
Запускаем тест
Кратко. Что еще
Выводы

Перед началом скажу, чем обусловлен переход с Python на Go. Главная причина — в команде ведется разработка на Go, а значит, в ней есть множество экспертов. Однажды я уже споткнулся, когда работал в другой компании и писал тесты на Ruby: уехал в отпуск — и процесс застопорился. Ребятам пришлось разбираться в малознакомом языке, из-за чего они потеряли уйму времени. Проще говоря, исключаю bus factor. Что бы ни случилось — каникулы, простуда, отдых — коллеги быстро разберутся.

Bus Factor (или Truck Factor) — метрика, показывающая минимальное количество человек, исчезновение которых приведет к остановке проекта.

Вторая причина перехода на Go — в использовании самописного фреймворка на Python, в котором не было поддержки необходимых нам инструментов, таких как Helm и Terraform. Чтобы добавить возможность с ними работать, пришлось бы писать код с чистого листа, что, очевидно, является неоправданной тратой ресурсов.

В Go же, напротив, есть готовый фреймворк для работы с инфраструктурой — Terratest. Уже отлаженный инструмент должен ускорить создание тестов, не так ли? О нем и пойдет речь.

Предыстория


Перед погружением в особенности технологий расскажу об одном из практических кейсов, с которого началась история. Мы захотели предоставить пользователям возможность заказывать нод‑группы с GPU без предустановленных драйверов.

Раньше GPU у нас шли вместе с Nvidia Device Plugin (далее — NDP). Когда пользователь заказывал в K8s подобную конфигурацию, он получал предустановленные драйвера и другой софт. Это удобно, но не всем. Некоторым нужна возможность ставить специальные версии драйверов. Инсталлировать их самостоятельно у клиентов не получалось — каждый раз наш софт восстанавливал ноду в исходное состояние.

Мы быстро отработали запрос — и уже летом 2024 года стало возможно заказывать нод‑группы как с драйверами, так и без них. Необходимо удостовериться, что изменения не ломают существующую функциональность, а установка драйверов GPU в K8s работает корректно.

На помощь приходит GPU Operator от NVIDIA, который упрощает развертывание и управление графическими процессорами в K8s. Если его получается «завести» в кластере — значит, все работает правильно.

Казалось бы — вот оно, решение всех трудностей тестирования GPU. Однако не все так просто. При каждом новом релизе приходится повторять пусть и несложные, но множественные шаги, что не вызывает восторга. Мысль об автоматизации напрашивается сама собой. Собственно, в этом кейсе мы и решили опробовать Go в связке с Terraform. Давайте переместимся на полгода назад и посмотрим, как мы испытывали нововведения перед тем, как сделать их доступными для широкого круга клиентов.



Пишем тест


Краткая справка


Перед началом работы над автоматизацией хорошо бы понять, что, собственно, нужно автоматизировать. :)

Поскольку в данном случае речь идет про GPU, то надо проверить, как изменения отразятся на совместной работе с GPU Operator. На странице Verification: Running Sample GPU Application специалисты NVIDIA по шагам расписали, как убедиться, что все работает правильно.
GPU Operator автоматизирует задачи, связанные с драйверами NVIDIA, настройкой ресурсов видеокарт и мониторингом их использования. Приложения, работающие с ними, становится легко развертывать и масштабировать. Отпадает необходимость вручную управлять неочевидными зависимостями.
Выглядит несложно — начнем автоматизировать эти действия. Для этого возьмем:
  1. Testify и пакет suite для организации структуры тестов,
  2. Terratest для работы с K8s и Helm,
  3. Docker со всеми инструментами, необходимыми для запуска тестов.

Приседаем с подготовкой


Для начала создадим необходимую структуру, а именно директорию suites для хранения тестовых наборов и tests — место для наших тестов:


В gpu.go опишем наш suite в виде структуры и тест к нему:

type GpuTestSuite struct {
    suite.Suite
    ctx context.Context
    t   *testing.T
}
func (g *GpuTestSuite) TestInstallGpuOperator() {}

А в suites/e2e_test.go добавим вызов нашего suite:

func TestE2ESuite(t *testing.T) {
	suite.Run(t, new(tests.GpuTestSuite))
}

Выполнение проверки будет выглядеть следующим образом:

go test -v -timeout=30m suites/e2e_test.go

Для создания теста и его запуска все готово, осталось совсем малость — написать его. :)

Чтобы получить GPU Operator, сначала нужно добавить Helm‑репозиторий от NVIDIA. Сложного в этом ничего нет, а в Terratest даже есть готовый методhelm.AddRepo. Передаем нужный конфиг от кластера и ссылку на сам репозиторий:

func (g *GpuTestSuite) TestInstallGpuOperator() {
    ...
    helm.AddRepo(t, helmOptions, "nvidia", «https://helm.ngc.nvidia.com/nvidia")
    ...
}

helmOptions — структура, которая используется для настройки поведения команд Helm. Она определяет, как именно Terratest взаимодействует с Helm при выполнении таких операций, как установка, обновление или удаление чартов. Исчерпывающее описание структуры можно найти на github. Нам важно определить такие параметры, как HomePath и EnvVars.

HomePath задает путь к домашней директории Helm, в которой тот хранит свои настройки, репозитории и кэш. Указание HomePath позволяет Terratest изолировать тесты, используя отдельные конфигурации Helm для каждого из них.

EnvVars — словарь переменных окружения, которые будут установлены перед выполнением команд Helm, таких как URL-адреса репозитория, учетные данные и другие динамические значения. Мы пропишем KUBECONFIG, чтобы Helm знал, где взять конфигурацию кластера.

// code
helmOptions := &helm.Options{
	EnvVars: map[string]string{
		"KUBECONFIG": <k8s конфиг>
	},
}

Где взять конфигурационный файл? У нас есть API, с помощью которого и получим нужный нам kubeconfig.

GET ​​/v1/clusters/{cluster_id}/kubeconfig

Далее я попытался установить GPU Operator с помощью Helm:

helm install --wait --generate-name \
    -n gpu-operator --create-namespace \
    nvidia/gpu-operator

Функция helm.Install требует chart, которого у меня нет, так как использую простую однострочную команду. Пытался передать ExtraArgs в helm.Options и оставить параметры функции пустыми. Не сработало — при вызове метода append возникает ошибка:

Error: INSTALLATION FAILED: expected at most two arguments, unexpected arguments

Посмотрев метод install, решил взять RunHelmCommandAndGetOutputE — эта функция позволяет передать в Helm аргументы как слайс строк. Итоговая команда получилась длинной, но рабочей:

helm.RunHelmCommandAndGetOutputE(t, helmOptions, "install", []string{"--generate-name", "-n", "gpu-operator", "--create-namespace", "nvidia/gpu-operator"}...)

При ее запуске мы видим установку:

Running command helm with args [install --generate-name -n gpu-operator --create-namespace nvidia/gpu-operator

Отлично! Мы настроили нужные нам инструменты и выполнили необходимые шаги для запуска тестового пода. Осталось немного — заимствовать манифест запуска cuda-vectoradd.

Запускаем тест


Пример манифеста пода cuda-vectoradd берем с сайта NVIDIA. Сохраняем его в файл, который так и назовем — cuda-vectoradd.yaml. Осталось добавить строчку кода в тест:

k8s.KubectlApply(t, options, "cuda-vectoradd.yaml")

Если сейчас запустить тест, то он завершится сразу после применения манифеста. Нам же нужно получить какое-то подтверждение того, что мы все сделали правильно. Обратимся к документации (вообще, читатель мануалы — хорошая практика) и увидим, что в выводе пода cuda-vectoradd должен содержаться отчет о проделываемых шагах и подтверждение об успешности проверки:

[Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
Test PASSED
Done

Будем искать в логах заветный Test PASSED. В самом Terratest я не нашел каких-либо методов ожидания логов — есть просто k8s.GetPodLogs. Зато подглядел, как можно организовать retry. Обернем все это в цикл с таймером и получим более‑менее приятную для глаз функцию. Суть ее работы в том, что мы, получив логи, проверяем вхождение строки Test PASSED. Если она находится — считаем, что все идет по плану:

func PodProduceExpectedLogs(t *testing.T, opt *k8s.KubectlOptions, podName string, expLogs string, retries int) {
	pod := k8s.GetPod(t, opt, podName)
	statusMsg := fmt.Sprintf("Wait for pod %s to be provisioned.", pod.Name)
	action := func() bool {
		logs := k8s.GetPodLogs(t, opt, pod, "")
		return strings.Contains(logs, expLogs)
	}
	result := doRetry(statusMsg, retries, action)
	require.Truef(t, result, "Pod not produced expected log: %s", expLogs)
}

Сама же функция, которая будет повторять действия по таймеру в цикле, выглядит так:

func doRetry(actionDescription string, retries int, f actionFunc) bool {
	var (
		result bool
		i      = 0
	)
	tickers := time.NewTicker(5 * time.Second)
	defer tickers.Stop()
	for tick := range tickers.C {
		i++
		result = f()
		switch {
		case result:
			return true
		case retries == i:
			log.Printf("failed %s and %d second", actionDescription, tick.Second())
			return false
		default:
			log.Printf("next retry after %s: %s", tick.String(), actionDescription)
		}
	}
	return result
}

Собственно, тест можно запускать. Если у нас все работает — получим ожидаемый положительный результат. Давайте проведем испытание и посмотрим вывод:

go test -v -timeout=30m suites/e2e_test.go -testify.m=TestInstallGpuOperator


Содержимое получаемых логов в поде соответствует информации на сайте NVIDIA.

Что мы в итоге проверили данным тестом? Мы удостоверились, что продукт готов к релизу с нововведениями и наши клиенты смогут без проблем устанавливать/использовать GPU Operator. Регресс в дальнейшем поможет убедиться, что какие-либо изменения не ломают логику развертывания нод-групп GPU без драйверов.

Предлагаю ниже рассмотреть дополнительные проверки за счет некоторых техник тест-дизайна.

Немного тест-дизайна


Что можно улучшить? Будем запускать тесты с разными версиями драйверов NVIDIA на неодинаковых конфигурациях нод.

Возвращаемся на страницу поддержки платформы NVIDIA, в раздел GPU Operator Component Matrix, и находим там доступные версии. Мы возьмем рекомендуемые, они же самые последние, а также используемые по умолчанию.

В команду установки оператора нужно передать опцию --set driver.version=<version>. По старой схеме добавляем ее в наш слайс строк:

helm.RunHelmCommandAndGetOutputE(t, helmOptions, "install", []string{"--generate-name", "-n", "gpu-operator", "--create-namespace", "nvidia/gpu-operator", "--set", "driver.version=" + version}...)

Обворачиваем код в цикл и добавляем в команду установку GPU Operator указание версии:

versions := []string{"550.127.05", "565.57.01"}
for _, version:= range versions {
	t.Run("check gpu driver version "+ver, func(t *testing.T) {
		… создание кластера и получение kubeconfig…
		options := k8s.NewKubectlOptions("", vars.K8sDefaultConfig, vars.DefaultNamespace)
		defer k8s.KubectlDelete(t, options, kubeResourcePath)
                … тут у меня cleanup - удаление кластера и других ресурсов
               // arrange
		helmOptions := &helm.Options{
			EnvVars: map[string]string{
				"KUBECONFIG": vars.K8sDefaultConfig,
			},
		}
		helm.AddRepo(t, helmOptions, "nvidia", "https://helm.ngc.nvidia.com/nvidia")
		_, err = helm.RunHelmCommandAndGetOutputE(t, helmOptions, "install", []string{"--generate-name", "-n", "gpu-operator", "--create-namespace", "nvidia/gpu-operator", "--set", "driver.version=" + version}...)
		require.NoError(err)
		// action
		k8s.KubectlApply(t, options, kubeResourcePath)
		// assert
		wait.PodProduceExpectedLogs(t, options, podName, "Test PASSED", 20)
	})
}

Таким образом, при запуске приведенного примера мы проверили парочку версий драйверов. Посмотрим на результат:


Можно добавить разные типы конфигураций нод, вытаскивая их из OpenStack. Так удастся увеличить покрытие — будет протестирована работа всевозможных конфигураций нод GPU с разными версиями драйверов.

Вышеописанным способом мы проверяем варианты видеокарт, которые доступны на нашей инфраструктуре. Полное тестирование дает уверенность, что у клиентов не возникнет неожиданных трудностей.

Кратко. Что еще


Terratest предоставляет модуль для работы с Terraform. Так как для взаимодействия с инфраструктурой доступен провайдер Selectel, то неплохо бы проверить, как наши услуги работают через Terraform.

В данном случае я сделал следующее. Взял конфигурации из OpenStack, сгенерировал tf‑файлы из шаблона и далее запускаю тесты. Приведу его часть:

resource "selectel_mks_nodegroup_v1" "nodegroup_0" {
  cluster_id        = selectel_mks_cluster_v1.cluster_1.id
  project_id        = "{{ .ProjectID }}"
  region            = selectel_mks_cluster_v1.cluster_1.region
  availability_zone = "{{ .AvailZone }}"
  flavor_id         = "{{ .FlavorID }}"
  nodes_count       = 1
  volume_type       = "{{ .VolumeType }}"
  volume_gb         = "{{ .VolumeGb }}"
}
output "clusterID" {
  value = selectel_mks_cluster_v1.cluster_1.id
}
output "nodeGroupID" {
  value = selectel_mks_nodegroup_v1.nodegroup_0.id
}

Ниже — код самого теста. Все максимально просто:
for i, tt := range tests {
	s.t.Run(tt.name, func(t *testing.T) {
                  …
		terraform.InitAndApply(s.t, terraformOptions) // создаем
		terraform.Destroy(s.t, terraformOptions) // удаляем
		clusterID := terraform.Output(s.t, terraformOptions, "clusterID")
		nodeGroupID := terraform.Output(s.t, terraformOptions, "clusterID")
		assert.NotEmptyf(clusterID, "cluster ID is empty")
		assert.NotEmptyf(nodeGroupID, "nodegroup ID is empty")
	})
}

Можно случайным образом выбирать любые 5, 7, 10 конфигураций и запускать тесты.



Выводы


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

Стоит упомянуть, что в Terratest есть модуль и для работы с Docker — его я использую для тестирования нашего сервиса Container Registry. К сожалению, в нем нет методов для работы с приватными реестрами. Я пока сделал PR в проект, а в ожидании его принятия просто добавил методы в наш тестовый фреймворк.

Я подумал, что подобная функциональность будет полезна не только мне, но и сообществу, поэтому создал issue на GitHub. Может, кому пригодится.

Без ложки дегтя не обошлось: некоторые модули в Terratest — это обычные вызовы утилит через shell. Пример:

command := shell.Command{
	Command: "<команда>",
	Args:    <какие то аргументы>,
	Env:     <env>,
	Logger:  <logger>,
}

Все эти утилиты приходится тащить в Docker‑образы, которые из-за этого получаются немаленького размера. С Docker можно было бы организовать работу как в testcontainers-go, где клиент взаимодействует с помощью API. Получилось бы даже «втащить» и testcontainers-go дополнительно, но не хочется привносить в тестовый фреймворк еще один инструмент.

Для хардкорщиков: напишите свой CLI‑фреймворк на Bash — получится альтернатива Terratest. :)

На этом все. Рад был поделиться с вами своими наработками и мыслями. Надеюсь, кто-нибудь найдет их полезными для себя.

Спасибо за внимание!

Комментарии (0)