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
В данной статье я постарался показать как расширить стандартный оператор из примера до рабочего состояния. А так же рассмотрели как следить за ресурсами, как изменять статус ресурса, как управлять реконсилером, как писать события, как сделать свой финализатор.
ЗЫ: Большое спасибо коллегам из ИТ-Сумма за посильный вклад в создание данной статьи.
12rbah
Возможно я чего не знаю, но зачем использовать GOPATH, когда это уже довольно давно считается плохой практикой и вместо него стоит использовать модули?
deepdive Автор
Я же не писал что укажите уникальный GOPATH. Проект с модулями не отменяет того что рабочая директория для golang все равно нужна.
12rbah
Не скажу точно, но вроде проект будет и без указания gopath работать, но возможно я ошибаюсь
deepdive Автор
Работать будет, но данная секция статьи рассчитана на людей которые ранее не ставили GO (его то можно и из пакетов установить), а если инженер знаком с GO - то и сам поставит и разберется какая настройка переменных ему подойдет. Я например под разные мажорные версии GO предпочитаю иметь разные хомки. Здесь же больше расчет что начнут с чистого листа, если мало опыта в GO.