- PVSM.RU - https://www.pvsm.ru -

Разбираемся в нюансах создания оператора на golang

Operators are software extensions to Kubernetes that make use of custom resources to manage applications and their components. Operators follow Kubernetes principles, notably the control loop. — from kubernetes.io

В данной статье я постарался изложить на что обратить внимание при написании оператора на golang и на нюансы, которые описываются вскользь или вовсе не описываются в официальном туториале [1] или других статьях подобного вида.

В данной статье я кратко покажу: 

  • Как подготовить окружение для создания оператора

  • Как писать программу и что мы можем сделать внутри основной функции обработки событий (реконсилера)

  • Когда вызывается реконсилер и как этим управлять

  • Как выходить из реконсилера

  • Как консистентно создавать и удалять объекты кластера

Для примера мы создадим secret-operator который будет:

  • Создавать необходимые секреты во всех неймспейсах кластера

  • Создавать секреты при создании нового неймспейса

  • Восстанавливать секрет, если его кто-то удалит

  • Удалять всех потомков, если удаляется наш корневой объект

Что данный оператор НЕ делает, для упрощения кода:

  • Не обрабатываются изменения секретов

  • Не реализована логика выбора неймспейса

Немного теории

Паттерн оператор [2] реализуемый controller-runtime [3] (kubebuilder, operator-sdk) очень похож на паттерн Наблюдатель [4] (2 [5]). Мы “подписываемся” на события k8s на создание/изменение/удаление объектов на которые мы должны реагировать. При изменении данных ресурсов вызывается функция reconcile в которую передается имя "родительского" объекта к которому относятся данные события. В функции reconcile описывается проверка состояний родительских/дочерних/остальных объектов и реакция на данные события. Более подробно о том как происходит подписка на события и работа reconcile-loop описано далее.

Подготовка окружения разработки

Установка golang

Скачайте необходимый архив для необходимой ОС по ссылке [6].

Распакуйте архив например в директорию /opt/go-1.19.4

Создайте рабочую директорию для go и пропишите переменные окружения

mkdir ~/go-1.19
export GOROOT=/opt/go-1.19.4
export GOPATH=~/go-1.19
export PATH=$GOROOT/bin:$GOPATH/bin:$PATH

Установка operator SDK

Скачиваем и проверяем необходимый бинарный исполняемый файл (ссылка [7])

export ARCH=$(case $(uname -m) in x86_64) echo -n amd64 ;; aarch64) 
echo -n arm64 ;; *) echo -n $(uname -m) ;; esac)
export OS=$(uname | awk '{print tolower($0)}')
export OPERATOR_SDK_DL_URL=https://github.com/operator-framework/operator-sdk/releases/download/v1.26.0
curl -LO ${OPERATOR_SDK_DL_URL}/operator-sdk_${OS}_${ARCH}

gpg --keyserver keyserver.ubuntu.com --recv-keys 052996E2A20B5C7E
curl -LO ${OPERATOR_SDK_DL_URL}/checksums.txt
curl -LO ${OPERATOR_SDK_DL_URL}/checksums.txt.asc
gpg -u "Operator SDK (release) <cncf-operator-sdk@cncf.io>" --verify checksums.txt.asc
grep operator-sdk_${OS}_${ARCH} checksums.txt | sha256sum -c -

chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/local/bin/operator-sdk

Установка IDE

Если у вас нет предпочтительной IDE используйте Goland, скачать можно здесь [8]. Триал 30 дней при регистрации по электронной почте.

После  открытия первого проекта останется только прописать GOROOT|GOPATH в настройках (File -> settings -> Go)

Подготовка проекта operator SDK

Описание на официальном сайте тут [1]

Исходный код проекта храниться на github [9]

Создадим новый проект:

mkdir -p ~/go-1.19/src/github.com/ddnw/secret-operator
cd ~/go-1.19/src/github.com/ddnw/secret-operator
operator-sdk init --domain ddnw.ml --repo github.com/ddnw/secret-operator

Создаем новый API и контроллер:

operator-sdk create api --group multi --version v1alpha1 --kind MultiSecret --resource --controller

Правим название докер образа на тот что необходим в Makefile

IMAGE_TAG_BASE ?= ddnw/secret-operator
IMG ?= $(IMAGE_TAG_BASE):$(VERSION)

Подсказка по командам SDK

#Запуск кодогенерации
make generate
#Создание манифестов
make manifests
# Сборка и пуш контейнера
make docker-build docker-push
# Установка CRD
make install
# Запуск контроллера в кластере
make deploy
# Удаление CRD и контроллера из кластера
make undeploy
# Удаление CRD
make uninstall 
# Создание тестового объекта
kubectl apply -f config/samples/multi_v1alpha1_multisecret.yaml

Структура кода оператора

Код оператора разделен на 2 основные части:

  • api - объявляющую нашу новую "родительскую" сущность для k8s

  • controller - код который читает желаемое состояние объектов k8s и стремиться применить его в k8s

API

После выполнения команды operator-sdk create api сгенерировались файлы по пути api/v1alpha1 в файле multisecret_types.go описана наша новая “родительская” сущность для которой мы и будем писать большую часть последующего кода.

Добавим в секцию spec необходимые поля, которые мы в последующем будем помещать в наши секреты, не забываем аннотации json - чтобы k8s смог в последующем эти данные сериализовать. 

После каждого изменения данной части кода запускаем make generate для автогенерации необходимой части кода в файле zz_generated.deepcopy.go

// MultiSecretSpec defines the desired state of MultiSecret
type MultiSecretSpec struct {
	Data       map[string][]byte `json:"data,omitempty"`
	StringData map[string]string `json:"stringData,omitempty"`
	Type       SecretType        `json:"type,omitempty"`
}

Так же добавляем описание структуры статусов нашего "родительского" объекта

// MultiSecretStatus defines the observed state of MultiSecret
type MultiSecretStatus struct {
	Wanted     int    `json:"wanted"`
	Created    int    `json:"created"`
	ChangeTime string `json:"change_time,omitempty"`
}

Controller

Основная логика multiSecret контроллера

Наш контроллер выполняет следующую логику:

  1. По вызову реконсилера запрашивается объект multiv1alpha1.MultiSecret{}

  2. Выходим Если он не существует

  3. Если "родительский" объект в стадии удаления, удаляем все "дочерние" секреты и выходим (Finalizer)

  4. Проверяем какие "дочерние" секреты существуют по всем пространствам (namespace), в соответствии со спецификацией "родительского" объекта создаем или удаляем их.

Reconcile loop

Reconcile - основная функция внутри которой мы проверяем состояние объектов и приводим их к желаемому состоянию. Функция обязательно должна быть идемпотентной, Вы не знаете в какой момент времени жизни объектов она вызовется.

Выход из функции

Из reconcile функции может быть несколько вариантов выхода в зависимости от того необходимо ли еще перезапустить цикл или нет.

ctrl.Result{}, err - случилась ошибка при выполнении из-за которой мы не можем продолжить выполнение, возвращаем ее чтобы перезапустить цикл позже.

ctrl.Result{Requeue: true}, nil - ошибки выполнения отсутствуют, но мы возвращаем управление контроллеру, что бы он мог обработать и другие объекты, и позже вернуться к текущему снова.

ctrl.Result{}, nil   - перезапуск цикла не требуется, желаемое состояние = существующему.

ctrl.Result{RequeueAfter: 60 * time.Second}, nil - перезапустить цикл после определенного времени. Можно использовать для уверенности в том что состояние объектов будет проверено и применено в данный интервал времени.

Жизненный цикл объекта и кэш

Объект в k8s может быть создан, обновлен или быть в стадии удаления.

Дополнительно к этому контроллер имеет свой кэш состояния объектов, с одной стороны это дает возможность не заботиться о том сколько раз мы запрашиваем состояние объекта, но так же надо понимать что мы можем “успеть” удалить один и тот же объект 2 раза. Либо реконсилер может быть вызван уже на удаленный объект. Для обработки таких ситуаций надо сравнивать возвращаемую ошибку на отсутствие объекта функцией IsNotFound(err)

func (r *MultiSecretReconciler) deleteSecret(ctx context.Context, secret *corev1.Secret) error {
	log := ctrllog.FromContext(ctx)
	err := r.Delete(ctx, secret)
	if errors.IsNotFound(err) {
		log.Info("corev1.Secret resource not found. Ignoring since object must be deleted",
			"NameSpace", secret.Namespace, "Name", secret.Name)
		return nil
	}
	if err != nil {
		log.Error(err, "Failed to delete corev1.Secret",
			"NameSpace", secret.Namespace, "Name", secret.Name)
		return err
	}
	return nil
}

После каждого запроса объекта и удаления, надо понимать какой тип ошибки вернулся, это проблема доступа к API или такого объекта не существует, и уже на основании этого принимать решение что делать далее. В приведенном примере если наш объект multiSecret не существует, то мы ничего не делаем, если же это ошибка доступа к API мы возвращаем ошибку чтобы reconcileLoop снова встал в очередь на выполнение.

	// Get MultiSecret object
	mSecret := &multiv1alpha1.MultiSecret{}
	err := r.Get(ctx, req.NamespacedName, mSecret)
	if err != nil {
		if errors.IsNotFound(err) {
			log.Info("MultiSecret resource not found. Ignoring since object must be deleted")
			return reconcile.Result{}, nil
		}
		log.Error(err, "Failed to get MultiSecret")
		return reconcile.Result{}, err
	}

Watching Resources

Функция SetupWithManager - устанавливает события на которые должен срабатывать reconciler, а так же делает сопоставление, reconciler какого объекта необходимо вызывать.

Первым идет метод For - для "родительского" объекта. Тут нам не надо ничего придумывать, на его события создания/изменения/удаления будет вызываться reconciler

Если Вы задумали контроллер, который будет обрабатывать события только на объекты созданные им самим, и объекты будут находится в том же пространстве (namespace), тогда можно использовать хендлеры через метод Owns

https://sdk.operatorframework.io/docs/building-operators/golang/tutorial/ [1]

func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&cachev1alpha1.Memcached{}).
		Owns(&appsv1.Deployment{}).
		Complete(r)
}

Главное не забывать делать ссылку на “родительский” объект из “дочерних”

// deploymentForMemcached returns a memcached Deployment object
func (r *MemcachedReconciler) deploymentForMemcached(m *cachev1alpha1.Memcached) *appsv1.Deployment {
	ls := labelsForMemcached(m.Name)
	replicas := m.Spec.Size

	dep := &appsv1.Deployment{
    ...
	}
	// Set Memcached instance as the owner and controller
	ctrl.SetControllerReference(m, dep, r.Scheme)
	return dep
}

Из приведенного ранее ТЗ нам надо отрабатывать:

  • События изменений нашего “родительского” объекта

  • Объектов которые мы будем создавать (secrets) в разных пространствах (namespace)

  • Объекты которые нам не принадлежат, создание новых пространств (namespace)

// SetupWithManager sets up the controller with the Manager.
func (r *MultiSecretReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&multiv1alpha1.MultiSecret{}).
		Watches(
			&source.Kind{Type: &corev1.Secret{}},
			handler.EnqueueRequestsFromMapFunc(r.secretHandlerFunc),
			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
		).
		Watches(
			&source.Kind{Type: &corev1.Namespace{}},
			handler.Funcs{CreateFunc: r.nsHandlerFunc},
		).
		Complete(r)
}

Что мы тут видим - мы следим за объектами corev1.Secret, вызываем функцию secretHandlerFunc - которая делает сопоставление к какому "родительскому" объекту он относится.

func (r *MultiSecretReconciler) secretHandlerFunc(a client.Object) []reconcile.Request {
	anno := a.GetAnnotations()
	name, ok := anno[annotationOwnerName]
	namespace, ok2 := anno[annotationOwnerNamespace]
	if ok && ok2 {
		return []reconcile.Request{
			{
				NamespacedName: types.NamespacedName{
					Name:      name,
					Namespace: namespace,
				},
			},
		}
	}
	return []reconcile.Request{}
}

Сама функция действует несложно, ищем в объекте необходимые аннотации, и возвращаем вызов необходимого реконсилера "родительского" объекта. Предикат указывает на то когда срабатывать. Данный предикат срабатывает на любое изменение версии объекта, создание и удаление сюда тоже входит.

По corev1.Namespace похожее поведение, но отрабатываем только создание пространства и в nsHandlerFunc отдаем вызовы на все реконсилеры наших “родительских” объектов.

func (r *MultiSecretReconciler) nsHandlerFunc(e event.CreateEvent, q workqueue.RateLimitingInterface) {
	multiSecretList := &multiv1alpha1.MultiSecretList{}
	err := r.List(context.TODO(), multiSecretList)
	if err != nil {
		return
	}
	for _, ms := range multiSecretList.Items {
		q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
			Name:      ms.Name,
			Namespace: ms.Namespace,
		}})
	}
}

Более подробно о слежении за ресурсами можно почитать здесь [10].

Finalizers

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

Если мы создаем "дочерние" объекты в том же пространстве (namespace) что и "родительский" объект, то финализаторы для удаления  "дочерних" нам не нужны. 

Хватит указания на "родительский" объект, а k8s удалит их сам. подробнее [11]

В нашем случае при удалении "родительского" объекта мы хотим удалять и все "дочерние" во всех пространствах (namespaces) для этого будем использовать финализатор [12].

Идея простая: k8s при удалении объекта проставляет время удаления и смотрит есть ли у него финализаторы. Пока они есть - объект не удаляется и дается время чтобы контролеры могли закончить необходимые действия.

В нашем коде пишем следующую логику:

  • Если родительский объект не в стадии удаления и у него нет нашего финализатора, то мы его добавляем

  • Если родительский объект в стадии удаления, удаляем все "дочерние" и в случае успеха удаляем запись финализатора

	inFinalizeStage := false
	// Check Finalizer
	if mSecret.ObjectMeta.DeletionTimestamp.IsZero() {
		if !ctrlutil.ContainsFinalizer(mSecret, FinalizerName) {
			ctrlutil.AddFinalizer(mSecret, FinalizerName)
			if err := r.Update(ctx, mSecret); err != nil {
				return ctrl.Result{}, err
			}
			changed = true
		}
	} else {
		// The object is being deleted
		inFinalizeStage = true
		if ctrlutil.ContainsFinalizer(mSecret, FinalizerName) {
			// our finalizer is present, so lets handle any external dependency
			if err := r.deleteAllSecrets(ctx, genGlobalName(mSecret.Name, mSecret.Namespace, multiSecName), nameSpaces); err != nil {
				// if fail to delete the external dependency here, return with error
				// so that it can be retried
				return ctrl.Result{}, err
			}
			changed = true

			// remove our finalizer from the list and update it.
			ctrlutil.RemoveFinalizer(mSecret, FinalizerName)
			if err := r.Update(ctx, mSecret); err != nil {
				return ctrl.Result{}, err
			}
		}
	}

Status

Добавим статусы нашему объекту, чтобы они красиво выводились по get запросу:

$ k get multisecrets.multi.ddnw.ml
NAME             	WANTED   CREATED   CHANGETIME
multisecret-sample   9    	9     	2022-05-31T12:14:35+03:00

По коду добавляем счетчики:

	// Calculate Wanted Status
	sWantedStatus := 0
	existedSecrets := 0
	changed := false
	for _, ns := range nameSpaces {
		if nsInList(mSecret, ns) {
			sWantedStatus++
		}
	}

Добавляем обновление статуса при выходе из функции. Обновление будет срабатывать только при выходе без ошибок, для этого мы задали имена выходным параметрам:

func (r *MultiSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrlRes ctrl.Result, ctrlErr error) 

Здесь же можно посмотреть как выполняется patch статуса объекта:

	// Update Status on reconcile exit
	defer func() {
		if ctrlErr == nil {
			if changed || sWantedStatus != mSecret.Status.Wanted || existedSecrets != mSecret.Status.Created {
				patch := client.MergeFrom(mSecret.DeepCopy())
				mSecret.Status.Wanted = sWantedStatus
				mSecret.Status.Created = existedSecrets
				mSecret.Status.ChangeTime = time.Now().Format(time.RFC3339)
				ctrlErr = r.Status().Patch(ctx, mSecret, patch)
			}
			if ctrlErr != nil {
				log.Error(ctrlErr, "Failed to update multiSecret Status",
					"Namespace", mSecret.Namespace, "Name", mSecret.Name)
			}
		}
	}()

Дополнительно чтобы работал вывод статуса с get необходимо дополнительно задать маркеры для генерации CRD, ссылка [13] на дополнительную информацию.

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Wanted",type=integer,JSONPath=`.status.wanted`
//+kubebuilder:printcolumn:name="Created",type=integer,JSONPath=`.status.created`
//+kubebuilder:printcolumn:name="ChangeTime",type=string,JSONPath=`.status.change_time`

Events

Для того чтобы в последствии понимать, что делает наш контроллер, добавим генерацию событий (Events), которые можно будет видеть, в том числе и в выводе describe объекта multisecret.

Для этого в структуру реконсилера добавляем рекордер и права в маркере:

// MultiSecretReconciler reconciles a MultiSecret object
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
type MultiSecretReconciler struct {
	client.Client
	Scheme   *runtime.Scheme
	Recorder record.EventRecorder
}

В main.go добавляем инициализацию рекордера:  

	if err = (&controllers.MultiSecretReconciler{
		Client:   mgr.GetClient(),
		Scheme:   mgr.GetScheme(),
		Recorder: mgr.GetEventRecorderFor("multisecret-controller"),
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "unable to create controller", "controller", "MultiSecret")
		os.Exit(1)
	}

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

msg := fmt.Sprintf("Created corev1.Secret, NameSpace: %s, Name: %s", newSecret.Namespace, newSecret.Name)
r.Recorder.Event(mSecret, "Normal", "Created", msg)
k describe multisecrets.multi.itsumma.ru multisecret-sample
Name:     	multisecret-sample
....
Status:
  change_time:  2022-05-31T14:02:18+03:00
  Created:  	9
  Wanted:   	9
Events:
  Type	Reason   Age            	From                	Message
  ----	------   ----           	----                	-------
  Normal  Created  2s (x3 over 160m)  multisecret-controller  Created corev1.Secret, NameSpace: secret-operator-system, Name: multisecret-sample.secret-operator-system.multisec

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

ЗЫ: Большое спасибо коллегам из ИТ-Сумма за посильный вклад в создание данной статьи.

Автор:
deepdive

Источник [14]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/go/382051

Ссылки в тексте:

[1] туториале: https://sdk.operatorframework.io/docs/building-operators/golang/tutorial/

[2] оператор: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/

[3] controller-runtime: https://github.com/kubernetes-sigs/controller-runtime

[4] Наблюдатель: https://refactoring.guru/ru/design-patterns/observer

[5] 2: https://medium.com/nuances-of-programming/%D0%BF%D0%B0%D1%82%D1%82%D0%B5%D1%80%D0%BD-%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F-%D0%BD%D0%B0%D0%B1%D0%BB%D1%8E%D0%B4%D0%B0%D1%82%D0%B5%D0%BB%D1%8C-%D0%BE%D0%B1%D1%8A%D0%B5%D0%BA%D1%82-%D0%BF%D0%BE%D0%B4-%D0%BF%D1%80%D0%B8%D1%86%D0%B5%D0%BB%D0%BE%D0%BC-4dc3a453aaa4

[6] ссылке: https://go.dev/dl/

[7] ссылка: https://sdk.operatorframework.io/docs/installation/

[8] здесь: https://www.jetbrains.com/go/download

[9] github: https://github.com/ddnw/secret-operator

[10] здесь: https://book.kubebuilder.io/reference/watching-resources.html

[11] подробнее: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/

[12] финализатор: https://kubernetes.io/blog/2021/05/14/using-finalizers-to-control-deletion/

[13] ссылка: https://book.kubebuilder.io/reference/generating-crd.html

[14] Источник: https://habr.com/ru/post/710588/?utm_source=habrahabr&utm_medium=rss&utm_campaign=710588