Operator

В данной практике опробуем работу operator-sdk на примере создания оператора на языке GO, который позволяет производить деплой приложения с конфигурацией(для простоты возьмем nginx) с запросом подтверждения операции.

Operator-SDK

Установка

Для работы потребуется утилита operator-sdk, установить ее можно используя инструкцию с официального сайта или скачать со страницы релизов на github. Для Windows рекомендуется использовать WSL для работы с operator-sdk, так как она предполагает наличие утилит командной строки, таких как make, awk, sed, bash и т.д.

Инициализация

Создадим директорию для нового проекта и произведем инициализацию:

$ mkdir operator && cd operator
$ operator-sdk init --domain miit.ru --repo github.com/yudolevich/kube-dev-course/example/operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
go get sigs.k8s.io/controller-runtime@v0.13.0
Update dependencies:
go mod tidy
Next: define a resource with:
operator-sdk create api

В команде init мы указываем произвольный домен, который будет использоваться в кастомных ресурсах, а также имя репозитория, который будет использоваться в go.mod.

Также сгенерируем код для структур апи и контроллера:

$ operator-sdk create api --group deploy --version v1alpha1 --kind Nginx --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/nginx_types.go
controllers/nginx_controller.go
Update dependencies:
go mod tidy
Running make:
make generate
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
make manifests

Таким образом мы инициализировали проект оператора, который будет обрабатывать ресурсы вида <kind>.<group>.<domain>, то есть в нашем случае nginxes.deploy.miit.ru.

GO

На данном этапе у нас есть каркас проекта оператора на golang, теперь необходимо описать нашу структуру и логику ее обработки.

Custom Types

Структура нашего кастомного ресурса находится в файле api/v1alpha1/nginx_types.go:

// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.

// NginxSpec defines the desired state of Nginx
type NginxSpec struct {
        // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
        // Important: Run "make" to regenerate code after modifying this file

        // Foo is an example field of Nginx. Edit nginx_types.go to remove/update
        Foo string `json:"foo,omitempty"`
}

// NginxStatus defines the observed state of Nginx
type NginxStatus struct {
        // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
        // Important: Run "make" to regenerate code after modifying this file
}

Здесь нам сразу же предлагается отредактировать данную структуру, внеся необходимые нам поля, а после запустить make для генерации кода. Добавим пару полей в NginxSpec: Replicas для задания количества реплик в деплойменте и Index для указания текста, который должен выводить наш nginx. Также в NginxStatus добавим индикатор подтверждения Approved, который будет сигнализировать о том, что подтверждение получено:

type NginxSpec struct {
        // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
        // Important: Run "make" to regenerate code after modifying this file

        Replicas int32  `json:"replicas,omitempty"`
        Index    string `json:"index"`
}

// NginxStatus defines the observed state of Nginx
type NginxStatus struct {
        // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
        // Important: Run "make" to regenerate code after modifying this file
        Approved bool `json:"approved"`
}

Также мы указываем json теги, которые будут использовать для сериализации нашей структуры в json формат, который будет использоваться в нашем кастомном ресурсе при описании его в yaml формате.

После редактирования структуры для генерации необходимого кода и манифестов требуется запустить make:

$ make generate
bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
$ make manifests
bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

Controllers

После инициализации operator-sdk создает директорию controllers, в которой генерирует код контроллера controllers/nginx_controller.go, обрабатывающего наши кастомные ресурсы. Здесь создается структура NginxReconciler и ее метод Reconcile, который будет вызываться при любых изменениях нашего ресурса. Собственно ее нам сразу же и предлагается изменить:

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Nginx object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.0/pkg/reconcile
func (r *NginxReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        _ = log.FromContext(ctx)

        // TODO(user): your logic here

        return ctrl.Result{}, nil
}

Nginx controller

Добавим следующую логику: при получении обновления мы запрашиваем текущее состояние ресурса методом r.Get, после чего проверяем поле approved в статусе ресурса и, если оно не проставлено, то прекращаем обработку, иначе производим деплой.

func (r *NginxReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
    nginx := &deployv1alpha1.Nginx{}
    if err := r.Get(ctx, req.NamespacedName, nginx); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }

        return ctrl.Result{}, err
    }

    logger.WithValues("name", nginx.GetName(), "namespace", nginx.GetNamespace())

    logger.Info("reconicle")
    if !nginx.Status.Approved {
        // todo: send approve request
        return ctrl.Result{}, nil
    }

    if err := r.deploy(ctx, nginx); err != nil {
        logger.Error(err, "error deploy nginx")
        return ctrl.Result{}, nil
    }

    return ctrl.Result{}, nil
}

Сам процесс деплоя можно описать отдельно:

func (r *NginxReconciler) deploy(ctx context.Context, nginx *deployv1alpha1.Nginx) error {
    labels := map[string]string{"nginx": nginx.GetName()}
    owner := metav1.OwnerReference{
        APIVersion: nginx.APIVersion,
        Kind:       nginx.Kind,
        UID:        nginx.GetUID(),
        Name:       nginx.GetName(),
    }

    cm := &v1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:            nginx.GetName(),
            Namespace:       nginx.GetNamespace(),
            Labels:          labels,
            OwnerReferences: []metav1.OwnerReference{owner},
        },
        Data: map[string]string{
            "index.html": nginx.Spec.Index,
        },
    }

    deploy := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:            nginx.GetName(),
            Namespace:       nginx.GetNamespace(),
            OwnerReferences: []metav1.OwnerReference{owner},
        },
        Spec: appsv1.DeploymentSpec{
            Selector: &metav1.LabelSelector{MatchLabels: labels},
            Replicas: &nginx.Spec.Replicas,
            Template: v1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{Labels: labels},
                Spec: v1.PodSpec{
                    Containers: []v1.Container{
                        {
                            Name:  "nginx",
                            Image: "nginx",
                            VolumeMounts: []v1.VolumeMount{
                                {
                                    Name:      "index",
                                    MountPath: "/usr/share/nginx/html",
                                },
                            },
                        },
                    },
                    Volumes: []v1.Volume{
                        {
                            Name: "index",
                            VolumeSource: v1.VolumeSource{
                                ConfigMap: &v1.ConfigMapVolumeSource{
                                    LocalObjectReference: v1.LocalObjectReference{
                                        Name: nginx.GetName(),
                                    },
                                },
                            },
                        },
                    },
                },
            },
        },
    }

    if err := r.apply(ctx, cm); err != nil {
        return err
    }

    if err := r.apply(ctx, deploy); err != nil {
        return err
    }

    return nil
}

func (r *NginxReconciler) apply(ctx context.Context, obj client.Object) error {
    logger := log.FromContext(ctx)

    if err := r.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil {
        if !errors.IsNotFound(err) {
            return err
        }

        logger.Info("create")
        if err := r.Create(ctx, obj); err != nil {
            return err
        }

        return nil
    }

    logger.Info("update")
    if err := r.Update(ctx, obj); err != nil {
        return err
    }

    return nil
}

Здесь мы описываем структуры Deployment и ConfigMap также как мы это делали в yaml формате, но с использованием golang, что дает нам дополнительную гибкость за счет возможностей полнофункционального языка и удобство за счет работы в IDE или продвинутом текстовом редакторе с возможностью автодополнения и подсвечивания ошибок. Как видно мы взяли параметры из нашего ресурса nginx, так что replicas попало в Deployment, а index в ConfigMap, который монтируется в него. Также мы использовали поле OwnerReference, таким образом при удалении нашего кастомного ресурса nginx также каскадно удалятся все его дочерние ресурсы.

Осталось только описать логику для подтверждения деплоя.

Telegram

Для подтверждения деплоя предлагаю создать контроллер, который будет писать вам в telegram при появлении нового ресурса nginx. Чтобы создать нового бота достаточно написать t.me/botfather команду /newbot, он спросит вас имя и уникальный username для бота и выдаст ссылку на него и токен, которым мы сможем воспользоваться в нашем операторе.

Также нам понадобится chat_id для отправки сообщений, для этого достаточно перейти в диалог с ботом и нажать start, после чего выполнить в терминале:

$ token=<токен бота>
$ curl https://api.telegram.org/bot${token}/getUpdates

После чего вы получите json с сообщением /start, в котором в поле id будет указан идентификатор диалога с ботом, которым мы и воспользуемся далее вместе с токеном.

Telegram controller

Для взаимодействия с telegram можно воспользоваться пакетом go-telegram-bot-api, выполним команду go get:

$ go get -u github.com/go-telegram-bot-api/telegram-bot-api/v5

И создадим файл controllers/telegram_controller.go, в котором опишем структуру контроллера, функцию создания нового контроллера и логику получения обновлений о новых сообщениях и проставление в статусе нашего кастомного ресурса nginx поля approved:true. Также сделаем метод SendDeploy, который будет отправляет сообщение о появлении нашего ресурса nginx в кластере.

Предупреждение

Данный код не предназначен для продуктивного использования и нужен лишь в целях обучения работы с operator-sdk.

package controllers

import (
    "context"
    "fmt"
    "strings"

    tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
    "github.com/yudolevich/kube-dev-course/example/operator/api/v1alpha1"
    v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/types"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

type TBot struct {
    *tgbotapi.BotAPI
    kube client.Client
    uid  int64
}

func NewTBot(token string, uid int64, client client.Client) (*TBot, error) {
    bot, err := tgbotapi.NewBotAPI(token)
    if err != nil {
        return nil, err
    }

    return &TBot{BotAPI: bot, uid: uid, kube: client}, nil
}

func (b *TBot) Start(ctx context.Context) error {
    for update := range b.GetUpdatesChan(tgbotapi.NewUpdate(0)) {
        if update.CallbackQuery == nil {
            continue
        }

        name := strings.Split(update.CallbackQuery.Data, "/")
        if len(name) != 2 {
            continue
        }

        b.kube.Status().Patch(ctx, &v1alpha1.Nginx{
            ObjectMeta: v1.ObjectMeta{Name: name[1], Namespace: name[0]},
        }, client.RawPatch(types.MergePatchType, []byte(`{"status":{"approved": true}}`)),
        )
    }

    return nil
}

func (b *TBot) SendDeploy(nginx *v1alpha1.Nginx) {
    msg := tgbotapi.NewMessage(
        b.uid,
        fmt.Sprintf(`Nginx: %s/%s
        replicas: %d
        index.html: %s`,
            nginx.GetNamespace(), nginx.GetName(),
            nginx.Spec.Replicas, nginx.Spec.Index),
    )
    msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
        tgbotapi.NewInlineKeyboardRow(
            tgbotapi.NewInlineKeyboardButtonData(
                "deploy",
                fmt.Sprintf("%s/%s", nginx.GetNamespace(), nginx.GetName()),
            ),
        ),
    )

    b.Send(msg)
}

Теперь добавим в nginx_controller.go отправку сообщения в telegram там где мы оставили todo комментарий, также расширив структуру нашего NginxReconciler:

// NginxReconciler reconciles a Nginx object
type NginxReconciler struct {
    client.Client
    Tbot   *TBot
    Scheme *runtime.Scheme
}
...
    logger.Info("reconicle")
    if !nginx.Status.Approved {
        r.Tbot.SendDeploy(nginx)
        return ctrl.Result{}, nil
    }

Осталось лишь дописать инициализацию нашего приложения в main.go, для этого добавим в него создание TBot контроллера и добавление его в Manager и NginxReconciler:

    chatid, err := strconv.ParseInt(os.Getenv("TELEGRAM_CHATID"), 10, 64)
    if err != nil {
        setupLog.Error(err, "error parse chatid")
        os.Exit(1)
    }

    tbot, err := controllers.NewTBot(os.Getenv("TELEGRAM_APITOKEN"), chatid, mgr.GetClient())
    if err != nil {
        setupLog.Error(err, "unable to create telegram bot controller")
        os.Exit(1)
    }

    if err = (&controllers.NginxReconciler{
        Client: mgr.GetClient(),
        Scheme: mgr.GetScheme(),
        Tbot:   tbot,
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Nginx")
        os.Exit(1)
    }
    //+kubebuilder:scaffold:builder

    if err := mgr.Add(tbot); err != nil {
        setupLog.Error(err, "unable to add telegram bot controller to manager")
        os.Exit(1)
    }

Build & Deploy

На всякий случай повторим генерацию кода и манифестов, а также сделаем проверку зависимостей и запустим сборку:

$ make generate
bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
$ make manifests
bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
$ go mod tidy
$ make docker-build
...
Step 16/16 : ENTRYPOINT ["/manager"]
 ---> Running in a396e3faa2fa
Removing intermediate container a396e3faa2fa
 ---> 22229db80f19
Successfully built 22229db80f19
Successfully tagged controller:latest

Перед деплоем необходимо внести пару изменений в манифесты, так как мы используем переменные среды и также запретим загрузку образа из внешних источников, для этого приведем файл config/default/manager_config_patch.yaml к следующему виду:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: controller-manager
  namespace: system
spec:
  template:
    spec:
      containers:
      - name: manager
        imagePullPolicy: Never
        env:
        - name: "TELEGRAM_CHATID"
          value: "<chat id>"
        - name: "TELEGRAM_APITOKEN"
          value: "<bot token>"

Указав свои chat id и bot token, и добавим строку в файл config/default/kustomization.yaml в блок patchesStrategicMerge, приведя его к виду:

patchesStrategicMerge:
# Protect the /metrics endpoint by putting it behind auth.
# If you want your controller-manager to expose the /metrics
# endpoint w/o any authn/z, please comment the following line.
- manager_auth_proxy_patch.yaml
- manager_config_patch.yaml

Также необходимы права на взаимодействие с ресурсами Deployment и ConfigMap нашему оператору, для этого создадим кластерную роль и биндинг config/rbac/role-deploy.yaml:

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  creationTimestamp: null
  name: manager-role-deploy
rules:
- apiGroups:
  - ""
  resources:
  - configmaps
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch
- apiGroups:
  - apps
  resources:
  - deployments
  verbs:
  - create
  - delete
  - get
  - list
  - patch
  - update
  - watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: manager-rolebinding-deploy
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: manager-role-deploy
subjects:
- kind: ServiceAccount
  name: controller-manager
  namespace: system

Также необходимо указать этот файл в config/rbac/kustomization.yaml:

- service_account.yaml
- role.yaml
- role_binding.yaml
- leader_election_role.yaml
- leader_election_role_binding.yaml
- auth_proxy_service.yaml
- auth_proxy_role.yaml
- auth_proxy_role_binding.yaml
- auth_proxy_client_clusterrole.yaml
- role-deploy.yaml

Осталось лишь загрузить образ в кластер и выполнить деплой:

$ kind load docker-image controller
Image: "" with ID "sha256:22229db80f19bba9f657e7095bc8da00b10b8d221b4ba7e60e83dafe0c087f9c" not yet present on node "kind-control-plane", loading...
$ make deploy
namespace/operator-system created
customresourcedefinition.apiextensions.k8s.io/nginxes.deploy.miit.ru created
serviceaccount/operator-controller-manager created
role.rbac.authorization.k8s.io/operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/operator-manager-role created
clusterrole.rbac.authorization.k8s.io/operator-manager-role-deploy created
clusterrole.rbac.authorization.k8s.io/operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/operator-manager-rolebinding-deploy created
clusterrolebinding.rbac.authorization.k8s.io/operator-proxy-rolebinding created
service/operator-controller-manager-metrics-service created
deployment.apps/operator-controller-manager created
$ kubectl get po -n operator-system
NAME                                           READY   STATUS    RESTARTS   AGE
operator-controller-manager-6798bdbdbd-n2cqt   2/2     Running   0          5m50s

Run

Создадим наш кастомный ресурс типа nginxes.deploy.miit.ru:

$ kubectl create -f - <<EOF
apiVersion: deploy.miit.ru/v1alpha1
kind: Nginx
metadata:
  labels:
  name: nginx-sample
spec:
  replicas: 1
  index: |
    It's alive!
EOF
nginx.deploy.miit.ru/nginx-sample created

После чего оператор должен отправить вам сообщение:

Если на текущий момент посмотреть в проекте, то никаких подов создано не будет:

$ kubectl get po
No resources found in default namespace.

После же нажатия на кнопку deploy в сообщении - оператор подтвердит создание ресурсов в кластере и под будет создан, при запросе к нему можно убедиться, что значение index.html взято из нашего ресурса:

$ kubectl get po
NAME                            READY   STATUS    RESTARTS   AGE
nginx-sample-6b79c55d84-j6j62   1/1     Running   0          4s

$ kubectl get --raw /api/v1/namespaces/default/pods/$(k get po -o name | cut -d/ -f2)/proxy
It's alive!

После удаления нашего ресурса nginx также каскадно удалятся Deploy, Pod и ConfigMap:

$ kubectl delete nginx nginx-sample
nginx.deploy.miit.ru "nginx-sample" deleted
$ kubectl get po
No resources found in default namespace.

Пример кода из данной практики можно посмотреть по ссылке.