Реклама
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06

Как написать Kubernetes-оператор с нуля на Go: полный гайд

Разбираем, как работает паттерн Operator в Kubernetes, и собираем собственный контроллер на Go. Оператор будет создавать Deployment и Service, отслеживать дрейф конфигурации и откатывать несанкционированные изменения.

Обложка: Как написать Kubernetes-оператор с нуля на Go: полный гайд

Deployment умеет держать поды живыми, но не умеет мигрировать базу данных, управлять жизненным циклом приложений с данными (stateful-сервисов) и не защитит от случайного kubectl scale, который разрушит состояние. Для таких задач в экосистеме K8s существуют операторы — специальные контроллеры, которые кодируют человеческий опыт эксплуатации прямо в программный код.

Написать оператор можно на Go с помощью operator-sdk: сгенерировать скелет проекта, определить CRD, реализовать Reconcile и задеплоить в кластер. В этой статье разберём паттерн Operator и соберём рабочий оператор, который создаёт Deployment и Service по декларативной спецификации, отслеживает дрейф конфигурации и мгновенно откатывает несанкционированные изменения.

Ключевые выводы

Оператор Kubernetes — это пользовательский контроллер, который расширяет API кластера собственными ресурсами (CRD) и непрерывно приводит фактическое состояние к желаемому.

Шаблон Reconcile — Observe → Create → Correct → Report — лежит в основе любого оператора: контроллер наблюдает, создаёт недостающее, исправляет отклонения и сообщает статус.

С помощью operator-sdk и kubebuilder-меток можно сгенерировать скелет проекта, CRD и RBAC-манифесты, не писать boilerplate вручную.

Owner Reference связывает дочерние ресурсы с родительским кастомным ресурсом (CR): при удалении WebApp Kubernetes автоматически уберёт связанные Deployment и Service.

Обновление статуса через Status().Update() изолировано от основного ресурса и предотвращает бесконечные циклы реконсиляции.

Почему стандартных примитивов Kubernetes не хватает

Kubernetes предоставляет мощный набор базовых абстракций: Pod, Deployment, StatefulSet, Service, Ingress. Они отлично справляются с запуском контейнеров, балансировкой трафика и базовым масштабированием. Однако эти примитивы агностичны к бизнес-логике приложения.

Представьте, что вам нужно развернуть production-grade кластер PostgreSQL. Помимо самих подов с базой, потребуются: инициализация репликации, управление резервными копиями, обновление версий без простоя, автоматическое переключение при отказе мастера. Всё это — операционная экспертиза, которую DevOps-инженеры накапливают годами. Оператор превращает эту экспертизу в автоматизированный контроллер, который круглосуточно следит за ресурсом и принимает решения.

Сегодня операторы де-факто стали стандартом для управления сложными stateful-приложениями в Kubernetes: от баз данных и брокеров сообщений до сервисных mesh и CI/CD-систем. Концепция была сформулирована инженерами CoreOS ещё в 2016 году, а сейчас поддерживается Cloud Native Computing Foundation (CNCF) как ключевой паттерн платформенной инженерии.

Архитектура оператора: CRD, Reconciler и control loop

Любой оператор состоит из двух ключевых компонентов:

  • Custom Resource Definition (CRD) — расширение API Kubernetes, которое определяет новый тип ресурса со своей схемой Spec (желаемое состояние) и Status (фактическое состояние).
  • Контроллер (Reconciler) — программный цикл, который постоянно сравнивает Spec и Status, а затем выполняет действия для их сближения.

Контроллер не работает по принципу «выполнил шаги и вышел». Вместо этого он реализует control loop: каждую итерацию можно запускать снова и снова — результат не сломается, потому что код сравнивает «что есть» с «что нужно» и корректирует только расхождения. Это критически важно, потому что события в распределённой системе приходят асинхронно, а состояние объекта могло измениться за время обработки предыдущего события.

Создаём проект с operator-sdk

Вручную писать весь boilerplate контроллера — неэффективно. Инструмент operator-sdk (основанный на Kubebuilder) генерирует стандартную структуру проекта Go, включая точку входа main.go, Makefile с целями для сборки и тестирования, а также инфраструктуру для управления CRD.

Инициализируем проект и создаём API с контроллером:

			operator-sdk init --domain sandesh.dev --repo github.com/sandesh-ojha/webapp-operator
operator-sdk create api --group apps --version v1 --kind WebApp --resource --controller
		

Флаг --resource сгенерирует Go-структуры, описывающие схему пользовательского ресурса. Флаг --controller создаст шаблон reconciler'а — файла, в котором мы будем писать логику управления.

Определяем схему CRD

Откроем api/v1/webapp_types.go. Здесь мы описываем два структурных блока: WebAppSpec — то, что задаёт пользователь в YAML-манифесте, и WebAppStatus — то, что оператор сообщает о текущем состоянии.

			type WebAppSpec struct {
    Replicas int32  `json:"replicas"`
    Image    string `json:"image"`
}

type WebAppPhase string

const (
    PhaseScaling WebAppPhase = "Scaling"
    PhaseRunning WebAppPhase = "Running"
)

type WebAppStatus struct {
    Phase             WebAppPhase `json:"phase,omitempty"`
    AvailableReplicas int32       `json:"availableReplicas"`
}
		

Важнейшая часть — kubebuilder-маркеры над основной структурой:

			// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas"
// +kubebuilder:printcolumn:name="Available",type="integer",JSONPath=".status.availableReplicas"
// +kubebuilder:resource:scope=Namespaced,shortName=wa
type WebApp struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   WebAppSpec   `json:"spec,omitempty"`
    Status WebAppStatus `json:"status,omitempty"`
}
		

Маркер +kubebuilder:subresource:status сообщает Kubernetes, что для этого ресурса нужен отдельный endpoint /status. Без него любое обновление статуса будет восприниматься API-сервером как изменение всего объекта, что вызовет каскадную реконсиляцию и может привести к бесконечному циклу.

Маркеры +kubebuilder:printcolumn настраивают вывод команды kubectl get webapps: вместо голого имени ресурса пользователь увидит фазу, желаемое и доступное количество реплик.

Reconciler: сердце оператора

Файл internal/controller/webapp_controller.go содержит функцию Reconcile — точку входа в control loop. Перед ней размещаются RBAC-маркеры, которые генерируют манифесты прав доступа при выполнении make manifests:

			// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
		

Без этих маркеров оператор не получит прав на чтение и запись стандартных ресурсов Deployment и Service, и при запуске в кластере упадёт с ошибкой доступа. Разберём функцию Reconcile по шагам.

Шаг 1. Получение актуального состояния

Reconciler получает не сам объект, а лишь его имя и пространство имён. Это архитектурное решение Kubernetes: между постановкой события в очередь и его обработкой объект мог измениться. Поэтому первое действие — всегда запросить свежую версию ресурса из API.

			func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := logf.FromContext(ctx)

    app := &appsv1.WebApp{}
    err := r.Get(ctx, req.NamespacedName, app)
    if err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }
    // ...
		

Здесь apierrors импортируется из пакета k8s.io/apimachinery/pkg/api/errors, а ctrl — из sigs.k8s.io/controller-runtime.

Шаг 2. Реконсиляция Deployment

Сначала проверяем, существует ли связанный Deployment. Если нет — создаём его через вспомогательную функцию deploymentForWebApp.

			foundDep := &k8sappsv1.Deployment{}
err = r.Get(ctx, types.NamespacedName{Name: app.Name, Namespace: app.Namespace}, foundDep)

if err != nil && apierrors.IsNotFound(err) {
    dep, err := r.deploymentForWebApp(app)
    if err != nil {
        return ctrl.Result{}, err
    }
    if err := r.Create(ctx, dep); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{Requeue: true}, nil
}
		

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

Вот как выглядит функция deploymentForWebApp:

			func (r *WebAppReconciler) deploymentForWebApp(app *appsv1.WebApp) (*k8sappsv1.Deployment, error) {
    ls := map[string]string{"app": app.Name}
    replicas := app.Spec.Replicas

    dep := &k8sappsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      app.Name,
            Namespace: app.Namespace,
        },
        Spec: k8sappsv1.DeploymentSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: ls,
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{Labels: ls},
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{{
                        Name:  "webapp",
                        Image: app.Spec.Image,
                        Ports: []corev1.ContainerPort{{
                            ContainerPort: 80,
                            Protocol:      corev1.ProtocolTCP,
                        }},
                    }},
                },
            },
        },
    }

    if err := ctrl.SetControllerReference(app, dep, r.Scheme); err != nil {
        return nil, err
    }
    return dep, nil
}
		

Owner Reference — это механизм garbage collection в Kubernetes. Когда пользователь удаляет ресурс WebApp, кластер автоматически удалит все дочерние объекты, на которые ссылается поле ownerReferences. Без этой связи после удаления кастомного ресурса в кластере останутся «зомби»-поды и сервисы.

Шаг 3. Обнаружение дрейфа конфигурации

Если Deployment уже существует, мы не просто идём дальше — сравниваем желаемое и фактическое состояние. Это и есть то, что отличает оператор от одноразового скрипта.

			desiredReplicas := app.Spec.Replicas
desiredImage    := app.Spec.Image
actualImage     := foundDep.Spec.Template.Spec.Containers[0].Image

if foundDep.Spec.Replicas == nil || *foundDep.Spec.Replicas != desiredReplicas || actualImage != desiredImage {
    logger.Info("Drift detected! Updating Deployment",
        "DesiredReplicas", desiredReplicas,
        "ActualReplicas", foundDep.Spec.Replicas,
        "DesiredImage", desiredImage,
        "ActualImage", actualImage,
    )

    foundDep.Spec.Replicas = &desiredReplicas
    foundDep.Spec.Template.Spec.Containers[0].Image = desiredImage

    if err := r.Update(ctx, foundDep); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{Requeue: true}, nil
}
		

Представьте, что кто-то из команды в обход оператора выполнил следующую команду или подменил образ на уязвимую версию. Оператор мгновенно фиксирует расхождение и принудительно возвращает ресурс к значениям, заданным в WebApp.Spec.

			kubectl scale deployment sandy-app --replicas=10
		

Шаг 4. Реконсиляция Service

С вычислительным слоем разобрались — теперь нужен сетевой доступ. Логика полностью идентична: проверяем наличие Service, создаём при отсутствии, устанавливаем Owner Reference. Для локального тестирования в Minikube используем тип NodePort.

			foundSvc := &corev1.Service{}
err = r.Get(ctx, types.NamespacedName{Name: app.Name + "-service", Namespace: app.Namespace}, foundSvc)

if err != nil && apierrors.IsNotFound(err) {
    svc, err := r.serviceForWebApp(app)
    if err != nil {
        return ctrl.Result{}, err
    }
    if err := r.Create(ctx, svc); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{Requeue: true}, nil
}
		

Вспомогательная функция serviceForWebApp строит объект Service с нужными селекторами и портами:

			func (r *WebAppReconciler) serviceForWebApp(app *appsv1.WebApp) (*corev1.Service, error) {
    ls := map[string]string{"app": app.Name}

    svc := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      app.Name + "-service",
            Namespace: app.Namespace,
        },
        Spec: corev1.ServiceSpec{
            Selector: ls,
            Type:     corev1.ServiceTypeNodePort,
            Ports: []corev1.ServicePort{{
                Protocol:   corev1.ProtocolTCP,
                Port:       80,
                TargetPort: intstr.FromInt(80),
            }},
        },
    }

    if err := ctrl.SetControllerReference(app, svc, r.Scheme); err != nil {
        return nil, err
    }
    return svc, nil
}
		

Шаг 5. Обновление статуса

Последний шаг — сообщить пользователю текущее состояние приложения через status subresource. Здесь критически важно использовать именно r.Status().Update(), а не r.Update().

			available := foundDep.Status.AvailableReplicas
app.Status.AvailableReplicas = available

if available == app.Spec.Replicas {
    app.Status.Phase = appsv1.PhaseRunning
} else {
    app.Status.Phase = appsv1.PhaseScaling
}

if err := r.Status().Update(ctx, app); err != nil {
    return ctrl.Result{}, err
}
return ctrl.Result{}, nil
		

Разделение основного endpoint ресурса и subresource /status — фундаментальное свойство Kubernetes. Оно гарантирует, что обновление статуса не триггерит новое событие изменения ресурса и, соответственно, не запускает бесконечную реконсиляцию.

Тестирование: как проверить, что оператор работает

Для локального тестирования подойдёт Minikube. Запускаем оператор в одном терминале, а в другом — создаём кастомный ресурс.

			# Терминал 1: установка CRD и запуск контроллера
make install
make run

# Терминал 2: создание ресурса WebApp
cat > my-app.yaml << 'EOF'
apiVersion: apps.sandesh.dev/v1
kind: WebApp
metadata:
  name: sandy-app
  namespace: default
spec:
  replicas: 3
  image: nginx:1.25-alpine
EOF
kubectl apply -f my-app.yaml
		

Проверяем, что оператор создал инфраструктуру и отчитался о статусе:

			$ kubectl get webapps
NAME        PHASE     REPLICAS   AVAILABLE
sandy-app   Running   3          3

$ kubectl get pods
NAME                        READY   STATUS
sandy-app-7c8585559-2mxgk   1/1     Running
sandy-app-7c8585559-8pabc   1/1     Running
sandy-app-7c8585559-xy9jz   1/1     Running
		

Демонстрация: откат несанкционированных изменений

Главная ценность оператора — самовосстановление. Сымитируем вмешательство: масштабируем Deployment в обход кастомного ресурса.

			$ kubectl scale deployment sandy-app --replicas=10
deployment.apps/sandy-app scaled
		

Мгновенно в логах контроллера появляется сообщение:

Лог оператора:
INFO Drift detected! Updating Deployment {"DesiredReplicas": 3, "ActualReplicas": 10}

А через несколько секунд избыточные поды начинают завершаться:

			$ kubectl get pods
NAME                        READY   STATUS
sandy-app-...-2mxgk        1/1     Running
sandy-app-...-8pabc        1/1     Running
sandy-app-...-xy9jz        1/1     Running
sandy-app-...-zbt99        0/1     Terminating
sandy-app-...-plk22        0/1     Terminating
		

В это время статус WebApp отражает промежуточное состояние Scaling, а после полного схождения — автоматически переключается обратно на Running.

Когда операторы необходимы, а когда избыточны

Не каждому приложению нужен собственный оператор. Для stateless-сервисов, которые достаточно описать парой манифестов Deployment + Service, оператор будет накладным расходом. Однако есть категории систем, где без оператора не обойтись:

  • Базы данных — PostgreSQL, MySQL, MongoDB, etcd: репликация, бэкапы, обновления, failover.
  • Брокеры сообщений — Kafka, RabbitMQ, NATS: управление партициями, топиками, кластерной топологией.
  • Сетевые компоненты — Ingress-контроллеры, service mesh: динамическая маршрутизация и политики безопасности.
  • CI/CD и GitOps — Tekton, Argo CD: оркестрация пайплайнов и синхронизация состояния кластера с репозиторием.

В российской инфраструктурной практике операторы активно используются в Managed Kubernetes от крупных облачных провайдеров. Например, в Яндекс Облаке операторы лежат в основе managed-сервисов баз данных, обеспечивая автоматизацию резервного копирования, мониторинга и масштабирования.

Часто задаваемые вопросы
1
Чем оператор отличается от обычного Helm-чарта?

Helm-чарт — это шаблонизатор манифестов: он создаёт ресурсы один раз при установке и не следит за их состоянием дальше. Оператор — это живой процесс внутри кластера, который постоянно наблюдает за ресурсами, обнаруживает отклонения и корректирует их. Helm разворачивает, оператор — управляет жизненным циклом.

2
Можно ли писать операторы на языках, отличных от Go?

Да. Официальные client libraries существуют для Python (kubernetes-client/python), Java и JavaScript. Однако экосистема вокруг Go наиболее зрелая: controller-runtime, operator-sdk и kubebuilder написаны именно на Go и предоставляют наиболее полный набор инструментов.

3
Что произойдёт, если удалить кастомный ресурс WebApp?

Kubernetes запустит garbage collection: благодаря Owner Reference, установленному через ctrl.SetControllerReference, все дочерние ресурсы — Deployment и Service — будут удалены автоматически. Это избавляет от ручной чистки и предотвращает накопление «зомби»-ресурсов.

4
Как оператор влияет на производительность API-сервера Kubernetes?

Каждый оператор подключается к API-серверу через watch-коннекции и получает события об изменениях целевых ресурсов. В небольших кластерах накладные расходы пренебрежимо малы. В больших кластерах (1000+ узлов) важно настраивать rate limiting и использовать label selectors для фильтрации событий, чтобы снизить нагрузку на API-сервер и etcd.

5
Как защитить оператор от случайного удаления или изменения?

Используйте RBAC с принципом минимальных привилегий: оператору должны быть выданы только те verbs и resources, которые ему действительно нужны. Дополнительно можно настроить PodDisruptionBudget для pod'ов оператора и использовать leader election (--leader-elect), чтобы при запуске нескольких реплик только один инстанс выполнял реконсиляцию.

Выводы

Паттерн Operator — это не просто модное слово в экосистеме Kubernetes, а проверенный подход к автоматизации эксплуатации сложных приложений. Вместо того чтобы полагаться на runbook'и и ручные действия инженеров, оператор кодирует операционную экспертизу в программу, которая работает круглосуточно, не устаёт и не забывает проверить важный шаг.

В этой статье мы прошли полный путь: от генерации скелета проекта через operator-sdk до работающего контроллера, который создаёт инфраструктуру, отслеживает дрейф конфигурации и автоматически восстанавливает желаемое состояние. Ключевые навыки — понимание асинхронной природы control loop, правильное использование Owner Reference и изолированное обновление статуса — применимы далеко за рамками Kubernetes.

Оператор — это не магия, а дисциплина. Каждая итерация Reconcile — это честный вопрос: «Что должно быть?» против «Что есть сейчас?». Ответить на него правильно — значит построить надёжную систему.
Сандеш ОджхаSoftware Engineer, автор оригинального туториала

Полный код проекта доступен в репозитории автора оригинального туториала: SandeshOjha06/k8-operator. Если вы планируете развиваться в направлении platform engineering или SRE, умение писать и отлаживать собственные контроллеры станет серьёзным конкурентным преимуществом.

Источники: