Разбираемся в нюансах создания оператора на 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 и на нюансы, которые описываются вскользь или вовсе не описываются в официальном туториале или других статьях подобного вида.
В данной статье я кратко покажу:
Как подготовить окружение для создания оператора
Как писать программу и что мы можем сделать внутри основной функции обработки событий (реконсилера)
Когда вызывается реконсилер и как этим управлять
Как выходить из реконсилера
Как консистентно создавать и удалять объекты кластера
Для примера мы создадим 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 контроллера
Наш контроллер выполняет следующую логику:
По вызову реконсилера запрашивается объект multiv1alpha1.MultiSecret{}
Выходим Если он не существует
Если "родительский" объект в стадии удаления, удаляем все "дочерние" секреты и выходим (Finalizer)
Проверяем какие "дочерние" секреты существуют по всем пространствам (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
В данной статье я постарался показать как расширить стандартный оператор из примера до рабочего состояния. А так же рассмотрели как следить за ресурсами, как изменять статус ресурса, как управлять реконсилером, как писать события, как сделать свой финализатор.
ЗЫ: Большое спасибо коллегам из ИТ-Сумма за посильный вклад в создание данной статьи.