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 и на нюансы, которые описываются вскользь или вовсе не описываются в официальном туториале или других статьях подобного вида.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Установка golang

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

Распакуйте архив например в директорию /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

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

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, скачать можно здесь. Триал 30 дней при регистрации по электронной почте.

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

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

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

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

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

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/

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,
		}})
	}
}

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

Finalizers

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

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

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

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

Идея простая: 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, ссылка на дополнительную информацию.

//+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

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

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

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


  1. 12rbah
    16.01.2023 10:01
    +1

    Возможно я чего не знаю, но зачем использовать GOPATH, когда это уже довольно давно считается плохой практикой и вместо него стоит использовать модули?


    1. deepdive Автор
      16.01.2023 12:08

      Я же не писал что укажите уникальный GOPATH. Проект с модулями не отменяет того что рабочая директория для golang все равно нужна.


      1. 12rbah
        16.01.2023 13:55
        +1

        Не скажу точно, но вроде проект будет и без указания gopath работать, но возможно я ошибаюсь


        1. deepdive Автор
          16.01.2023 15:34

          Работать будет, но данная секция статьи рассчитана на людей которые ранее не ставили GO (его то можно и из пакетов установить), а если инженер знаком с GO - то и сам поставит и разберется какая настройка переменных ему подойдет. Я например под разные мажорные версии GO предпочитаю иметь разные хомки. Здесь же больше расчет что начнут с чистого листа, если мало опыта в GO.


  1. ValentinaLemiakina
    16.01.2023 12:23

    С

    Приятно почитать ! Спасибо автору.