Советы по созданию операторов уровня продакшена с помощью Kubebuilder.
В этой статье рассматривается простой пример оператора для сценария автоматического создания ServiceAccount и ClusterRoleBinding с помощьюKubebuilder.
Не всякий оператор подходит для продакшена. Вот такой, например, не подойдёт:
Нехороший оператор
В этой статье мы рассмотрим несколько советов по созданию стабильного и функционального оператора и обсудим, какие препятствия подстерегают нас на этом пути.
Начнём с контроллера оператора — что это и что мы хотим с ним сделать в Kubebuilder.
Хороший оператор
Первым делом используем Kubebuilder, чтобы создать следующую версию типа CRD и контроллер.
kubebuilder create api --group identity --version v2 --kind UserIdentityv2
Если не указать новый kind, Kubebuilder не создаст новый контроллер, а потребует написать логику согласования двух версий одного контроллера.
Команда Create создаст классы Go, например useridentityv2_types.go, в каталоге api/v2.
Давайте напрямую скопируем поля v1.UserIdentity.
Команда создаст новый файл useridentityv2_controller.go в каталоге контроллера и одновременно скопирует похожую логику из контроллера v1.
log := r.Log.WithValues("useridentity", req.NamespacedName)
Обычно логи нужны для обработки ошибок.
if err != nil {
log.Error(err, fmt.Sprintf("Error create ServiceAccount for user:
%s, project: %s", user, project))
return ctrl.Result{}, nil
}
Добавляем логи для ключевых событий.
log.V(10).Info(fmt.Sprintf("Create Resources for User:%s, Project:%s", user, project))
log.V(10).Info(fmt.Sprintf("Create ServiceAccount for User:%s, Project:%s finished", user, project))
Пара слов об уровне детализации логов:
Для просмотра логов мы выполняем в kubectl следующую команду. (В двойные фигурные скобки {{}} заключены значения, которые нужно заменить на свои.)
kubectl get po -n {{ns}} -L {{label}}={{value}} --sort-by='{{field}}' -v10
Не переборщите с логами, иначе при отладке придётся копаться в миллионах записей. Как работает механизм логирования в Kubernetes см. здесь
Чтобы задать условия для CRD, добавим поле conditions в определение статуса UserIdentity.
type UserIdentityV2Status struct {
// Conditions is the list of error conditions for this resource
Conditions status.Conditions `json:"conditions,omitempty"`
}
Теперь в контроллере можно менять условия CRD.
Type: Ready,
Status: v1.ConditionTrue,
Reason: UpToDate,
Message: err.Error(),
}
Когда контроллер задаст условие, можно будет легко найти проблемное CRD через kubectl.
kubectl get po -n {{ns}} -L {{label}}={{value}}
Для этого ему нужны проба готовности и проба работоспособности. Для согласования CRD нужно добавить код для этих проб.
Вот пример пробы работоспособности и проверки работоспособности.
Добавив нужную логику, мы можем включить пробу работоспособности в файл config/manager/manger.yaml.
livenessProbe:
httpGet:
path: /healthz
port: 6789
initialDelaySeconds: 20
periodSeconds: 10
Это YAML не для CRD, а для менеджера развёртывания.
Способ реализации зависит от того, как мы получаем информацию об обновлении пользователей.
Цикл согласования на основе событий
Сервер API Kubernetes нативно поддерживает отслеживание событий через функцию watch. Получается, в контроллере остаётся выполнить всего два действия:
ch := make(chan event.GenericEvent)
subscription := r.PubsubClient.Subscription("userevent")
userEvent := CreateUserEvents(mgr.GetClient(), subscription, ch)
go userEvent.Run()
return ctrl.NewControllerManagedBy(mgr).
For(&identityv2.UserIdentityV2{}).
Watches(&source.Channel{Source: ch, DestBufferSize: 1024}, &handler.EnqueueRequestForObject{}).
Complete(r)
Наконец, настроим PubsubTopic и разрешения RBAC.
Вот как должен выглядеть относительно надежный оператор:
Надежный оператор
Как реализовать эти возможности?
Зачем нужны комментарии?
rintcolumn
runing
reserveUnknownFields
Больше о комментариях читайте в документации по Kubebuilder.
С помощью нашего кода мы можем вывести столбец RoleRef, который используется UserIdentityV2.
// +kubebuilder
rintcolumn
RoleRef rbacv1.RoleRef `json:"roleRef,omitempty"`
Возьмём для примера UserIdentity. Здесь мы жестко закодировали создание ServiceAccount и ClusterRoleBinding, что приводит к следующим проблемам:
Этот код нужен для парсинга шаблона, и давайте изменим его в файле UserIdentityV3_types.go. Заметили, что здесь мы создали UserIdentityV3? Он реализует неструктурированную функцию для сравнения кода v1 и v2.
// Template is a list of resources to instantiate per repository in Governator
Template []unstructured.Unstructured `json:"template,omitempty"`
Этот шаблон использует функцию шаблона Go, чтобы мы могли парсить шаблон, внедрять параметры в контроллер и создавать объекты с помощью неструктурированного API. Изучите код.
В Kubernetes ресурсы сообщают об изменении своего статуса и других новостях с помощью событий, поэтому мы можем использовать команду kubectl get events, чтобы не копаться в объёмных логах.
Здесь нам нужен пакет Kubernetes client-go/tools/record.
import "k8s.io/client-go/tools/record"
Ещё в функции согласования нужно определить Recorder.
Recorder record.EventRecorder
Теперь можно создавать события.
r.Recorder.Event(&userIdentity, corev1.EventTypeNormal, string(condition.Reason), condition.Message)
// or
r.Recorder.Event(&userIdentity, corev1.EventTypeWarning, string(UpdateFailed), "Failed to update resource status")
Чтобы добавить в UserIdentityV3 вебхук верификации, выполним следующую команду.
kubebuilder create webhook --group identity --version v3 --kind
UserIdentityV3 --defaulting --programmatic-validation
Будет создан файл useridentityv3_webhook.go в каталоге api/v3/. Вебхук по умолчанию настроен на менеджер в main.go.
if err = (&identityv3.UserIdentityV3{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "UserIdentityV3")
os.Exit(1)
}
Как мы уже говорили, если добавить комментарий // +kubebuilder:validation к полю типов, будет выполняться валидация, но вебхук способен на большее.
Правда, для UserIdentity пока ничего в голову не пришло. Если интересно, можете дополнить мой код своей логикой.
return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
Мы хотим оптимизировать сборку мусора, особенно если у нас есть много тесно связанных ресурсов. Представьте, как тесно связаны друг с другом объекты Deployment, Service, Pod.
Мы можем назначить CRD владельцем всех неструктурированных ресурсов. Потом можно удалить оператор, и они удалятся вместе с ним.
return ctrl.SetControllerReference(userIdentity, &existing, r.Scheme)
Препятствия
В целом, с Kubebuilder приятно работать, но без проблем, конечно, не обходится. Проблемы возникают с самим Kubebuilder, с библиотекой Kubernetes controller-runtime и даже с Go, включая использование Google Pubsub.
Самые заметные проблемы:
Если посмотреть на код CreateOrUpdate, мы увидим, что объект выполняет копию, полагаясь на функцию DeepCopy в zz_generated.deepcopy.go, который автоматически создается инструментом Kubebuilder после определения CRD.
Если объект не структурирован или создается динамически и без реализации функции DeepCopy, его придется каждый раз создавать заново!
Чтобы решить эту проблему, нужна функция mutate, определённая этим методом. Функция mutate вызывается независимо от создания или обновления объекта. Она возвращает ожидаемую операцию и ошибку.
// The MutateFn is called regardless of creating or updating an object.
//
// It returns the executed operation and an error.
func CreateOrUpdate(ctx context.Context, c client.Client, obj runtime.Object, f MutateFn) (OperationResult, error) {}
Инициализация объекта с функцией MutateFn позволит не создавать его заново.
Мы снова и снова проверяли код и прокомментировали буквально каждую его часть для тестирования.
Наконец, мы обнаружили, что вышестоящий gRPC-интерфейс, к которому мы подключились, уже перемещался, а время ожидания подключения не истекало, так что оператор просто зависал. Мы перешли с gRPC на SRV, чтобы решить проблему.
Но мы все равно хотели бы, чтобы оператор нормально прекращал работу, а не зависал. Для этого мы чуть-чуть изменили context, который используется повсюду.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
Всегда добавляйте к контексту время ожидания!
Эти три проблемы, к сожалению, не единственные, но такая уж у нас работа — находить и решать проблемы. А ещё создавать их.
Если не обращать внимания на некоторые недостатки, Kubebuilder можно считать удобным инструментом создания операторов, который освобождает время разработчиков на написание логики контроллера.
Работая над операторами, мы начинаем лучше понимать принципы работы вебхуков, контроллера и т.д. Разбираться в опенсорс-коде в Kubernetes SIG и даже писать код в некоторых SIG при желании.
Ждём выхода Kubebuilder 3.0 в Kubernetes slack или группе Google.
Весь код для этой статьи можно найти на Github.
Пишем собственный оператор Kubernetes
habr.com
В этой статье рассматривается простой пример оператора для сценария автоматического создания ServiceAccount и ClusterRoleBinding с помощьюKubebuilder.
Не всякий оператор подходит для продакшена. Вот такой, например, не подойдёт:

- Условие CRD не задано. Поле статуса условия обычно используется инструментами kubectl для наблюдения за статусом ресурсов.
- Без проверок работоспособности невозможно добавить пробу работоспособности (liveness) и пробу готовности (readiness).
В этой статье мы рассмотрим несколько советов по созданию стабильного и функционального оператора и обсудим, какие препятствия подстерегают нас на этом пути.
Начнём с контроллера оператора — что это и что мы хотим с ним сделать в Kubebuilder.
- Контроллер реализует цикл согласования и управляет им.
- Контроллер считывает желаемое состояние из YAML-файла ресурса и следит за тем, чтобы ресурсы пребывали в этом состоянии.
- Оператор должен решать одну задачу и делать это хорошо, по принципу единственной ответственности (Single Responsibility Principle, SRP). Это нужно для стабильности, производительности и простоты разработки и расширения.
- На один контроллер — одно CRD, в продолжение первого пункта.
- Пространства имён должны настраиваться, как того требуют рекомендации по применении пространств имён в Kubernetes. В нашем примере разные ServiceAccounts лучше разместить в разных пространствах имен.
- Отслеживаем метрики. Очевидно, что нам нужен Prometheus для мониторинга операторов.
- Один оператор не должен быть связан с другим. Не стоит усложнять.
- Используем вебхуки для проверки CRD. Если у CRD есть разные версии, нам нужны вебхуки.

Совершенствуем наш оператор
Мы хотим сделать наш оператор более надёжным и стабильным. Оставим код UserIdentity для сравнения, добавим новый kind и контент.Первым делом используем Kubebuilder, чтобы создать следующую версию типа CRD и контроллер.
kubebuilder create api --group identity --version v2 --kind UserIdentityv2
Если не указать новый kind, Kubebuilder не создаст новый контроллер, а потребует написать логику согласования двух версий одного контроллера.
Команда Create создаст классы Go, например useridentityv2_types.go, в каталоге api/v2.
Давайте напрямую скопируем поля v1.UserIdentity.
Команда создаст новый файл useridentityv2_controller.go в каталоге контроллера и одновременно скопирует похожую логику из контроллера v1.
Добавление логов
Для начала добавим побольше логов. У Kubebuilder есть logr для записи логов. Добавим имя переменной к объекту log по умолчанию.log := r.Log.WithValues("useridentity", req.NamespacedName)
Обычно логи нужны для обработки ошибок.
if err != nil {
log.Error(err, fmt.Sprintf("Error create ServiceAccount for user:
%s, project: %s", user, project))
return ctrl.Result{}, nil
}
Добавляем логи для ключевых событий.
log.V(10).Info(fmt.Sprintf("Create Resources for User:%s, Project:%s", user, project))
log.V(10).Info(fmt.Sprintf("Create ServiceAccount for User:%s, Project:%s finished", user, project))
Пара слов об уровне детализации логов:
Благодаря своей гибкости уровни детализации всё чаще используются в приложениях на Go и становятся стандартом. Дополнительная изоляция упрощает отладку.Для типа info можно указывать произвольный уровень важности логов, не используя семантически значимые обозначения, вроде warning, trace или debug.
Источник: https://github.com/go-logr/logr
Для просмотра логов мы выполняем в kubectl следующую команду. (В двойные фигурные скобки {{}} заключены значения, которые нужно заменить на свои.)
kubectl get po -n {{ns}} -L {{label}}={{value}} --sort-by='{{field}}' -v10
Не переборщите с логами, иначе при отладке придётся копаться в миллионах записей. Как работает механизм логирования в Kubernetes см. здесь
Задаем условия
Если говорить об управлении ресурсами Kubernetes, условия (condition) очень важны и связаны с жизненным циклом pod’ов. Если неправильно задать условие в цикле синхронизации, возникнут проблемы с пробами, например пробами готовности. Подробнее в статье об условиях в контроллерах Kubernetes.Чтобы задать условия для CRD, добавим поле conditions в определение статуса UserIdentity.
type UserIdentityV2Status struct {
// Conditions is the list of error conditions for this resource
Conditions status.Conditions `json:"conditions,omitempty"`
}
Теперь в контроллере можно менять условия CRD.
- Добавим условие UpdateFailed для ошибки.
- После успешного выполнения задаем для условия UpToDate.
Type: Ready,
Status: v1.ConditionTrue,
Reason: UpToDate,
Message: err.Error(),
}
Когда контроллер задаст условие, можно будет легко найти проблемное CRD через kubectl.
kubectl get po -n {{ns}} -L {{label}}={{value}}
Добавление проверок работоспособности
Kubernetes может как автоматически закрывать неработоспособные pod’ы, так и перезапускать их.Для этого ему нужны проба готовности и проба работоспособности. Для согласования CRD нужно добавить код для этих проб.
Вот пример пробы работоспособности и проверки работоспособности.
Добавив нужную логику, мы можем включить пробу работоспособности в файл config/manager/manger.yaml.
livenessProbe:
httpGet:
path: /healthz
port: 6789
initialDelaySeconds: 20
periodSeconds: 10
Это YAML не для CRD, а для менеджера развёртывания.
Добавляем логику удаления ресурса
В цикле синхронизации у нас есть только логика для увеличения числа пользователей и настройки соответствующих ресурсов, но если пользователь удаляется, ресурсы тоже нужно удалить.Способ реализации зависит от того, как мы получаем информацию об обновлении пользователей.
- Интерфейс FindAll. Получаем информацию обо всех пользователях, а потом добавляем и удаляем соответствующие ресурсы по этому списку.
- Уведомление о событиях. Можно подписаться на события, чтобы узнавать о добавлении и удалении пользователей, и архитектура на основе событий сейчас очень популярна.

Сервер API Kubernetes нативно поддерживает отслеживание событий через функцию watch. Получается, в контроллере остаётся выполнить всего два действия:
- Создаём события, за которыми нужно следить. Здесь это событие обновления пользователя из топика pubsub.
- Добавляем вотчер в функцию SetupWithManager контроллера.
ch := make(chan event.GenericEvent)
subscription := r.PubsubClient.Subscription("userevent")
userEvent := CreateUserEvents(mgr.GetClient(), subscription, ch)
go userEvent.Run()
return ctrl.NewControllerManagedBy(mgr).
For(&identityv2.UserIdentityV2{}).
Watches(&source.Channel{Source: ch, DestBufferSize: 1024}, &handler.EnqueueRequestForObject{}).
Complete(r)
Наконец, настроим PubsubTopic и разрешения RBAC.
Расширяем функционал
Мы можем и дальше улучшать UserIdentity. Например, добавить поддержку kubectl и дополнительных возможностей Kubernetes.Вот как должен выглядеть относительно надежный оператор:

Как реализовать эти возможности?
Используем Kubebuilder
У Kubebuilder есть много удобных функций, и функция комментариев, наверное, лучшая из них.Зачем нужны комментарии?
- Добавляем детали в CRD. Например, если добавить к полю следующий комментарий, информация в поле будет отображаться в выходных данных kubectl get.
- Добавляем дефолтное значение или проверку в поле CRD. Например, если добавить следующий комментарий, поле будет принимать только значения 1, 2, 3. При другом значении будет выдаваться ошибка.
- Запрещаем серверу API отсекать поля без значений.
Больше о комментариях читайте в документации по Kubebuilder.
С помощью нашего кода мы можем вывести столбец RoleRef, который используется UserIdentityV2.
// +kubebuilder
RoleRef rbacv1.RoleRef `json:"roleRef,omitempty"`
Поддержка неструктурированных данных
Иногда в CRD требуются нестандартные решения. Мы не хотим ограничиваться имеющимися Kubernetes API или типами ресурсов при выполнении особых операций с неструктурированными данными.Возьмём для примера UserIdentity. Здесь мы жестко закодировали создание ServiceAccount и ClusterRoleBinding, что приводит к следующим проблемам:
- Мы должны использовать библиотеки core/v1 и rbac/v1, соответствующие ServiceAccount и ClusterRoleBinding. Чем больше ресурсов мы создаем, тем больше у нас API, но не все обязательные типы поддерживаются Go.
- Мы не можем создавать обязательные ресурсы, динамически меняя CRD. Нужно будет каждый раз менять логику контроллера.
Этот код нужен для парсинга шаблона, и давайте изменим его в файле UserIdentityV3_types.go. Заметили, что здесь мы создали UserIdentityV3? Он реализует неструктурированную функцию для сравнения кода v1 и v2.
// Template is a list of resources to instantiate per repository in Governator
Template []unstructured.Unstructured `json:"template,omitempty"`
Этот шаблон использует функцию шаблона Go, чтобы мы могли парсить шаблон, внедрять параметры в контроллер и создавать объекты с помощью неструктурированного API. Изучите код.
Поддержка событий
Версия v2 уже поддерживает условия, не хватает только событий.В Kubernetes ресурсы сообщают об изменении своего статуса и других новостях с помощью событий, поэтому мы можем использовать команду kubectl get events, чтобы не копаться в объёмных логах.
Здесь нам нужен пакет Kubernetes client-go/tools/record.
import "k8s.io/client-go/tools/record"
Ещё в функции согласования нужно определить Recorder.
Recorder record.EventRecorder
Теперь можно создавать события.
r.Recorder.Event(&userIdentity, corev1.EventTypeNormal, string(condition.Reason), condition.Message)
// or
r.Recorder.Event(&userIdentity, corev1.EventTypeWarning, string(UpdateFailed), "Failed to update resource status")
Используем вебхуки
Kubebuilder нативно поддерживает вебхуки, но только вебхуки доступа.Вебхук — это обратный HTTP-вызов: HTTP POST, который выполняется, когда что-то происходит, простое уведомление о событии через HTTP POST. Источник: kubernetes.io
Чтобы добавить в UserIdentityV3 вебхук верификации, выполним следующую команду.
kubebuilder create webhook --group identity --version v3 --kind
UserIdentityV3 --defaulting --programmatic-validation
Будет создан файл useridentityv3_webhook.go в каталоге api/v3/. Вебхук по умолчанию настроен на менеджер в main.go.
if err = (&identityv3.UserIdentityV3{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "UserIdentityV3")
os.Exit(1)
}
Как мы уже говорили, если добавить комментарий // +kubebuilder:validation к полю типов, будет выполняться валидация, но вебхук способен на большее.
Правда, для UserIdentity пока ничего в голову не пришло. Если интересно, можете дополнить мой код своей логикой.
Периодическое согласование
Если добавить следующий код в конец функции согласования, оператор будет выполнять согласование каждые 10 минут.return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
Задаём OwnerReference
В Kubernetes при удалении владельца удаляются всего его подресурсы, так что если мы выполним kubectl delete useridentityv3, все подресурсы удалятся. Можно задать set cascade = false, чтобы отключить это поведение.Мы хотим оптимизировать сборку мусора, особенно если у нас есть много тесно связанных ресурсов. Представьте, как тесно связаны друг с другом объекты Deployment, Service, Pod.
Мы можем назначить CRD владельцем всех неструктурированных ресурсов. Потом можно удалить оператор, и они удалятся вместе с ним.
return ctrl.SetControllerReference(userIdentity, &existing, r.Scheme)
Препятствия на нашем пути

В целом, с Kubebuilder приятно работать, но без проблем, конечно, не обходится. Проблемы возникают с самим Kubebuilder, с библиотекой Kubernetes controller-runtime и даже с Go, включая использование Google Pubsub.
Самые заметные проблемы:
Непредсказуемые тесты Ginkgo
Или в Kubebuilder что-то не так с suite_test и ginkgo или это я не умею ими пользоваться.- В IDE нельзя запускать разные тест-кейсы отдельно. В отличие от JUnit, где это возможно, suite_test не поддерживается. Во всяком случае в GoLand. А что если у нас 20 тест-кейсов? А если вообще 100?
- Состояние гонки откровенно раздражает. Мы пишем модульные тесты по принципам F.I.R.S.T. Так вот в ginkgo принцип изолированности постоянно нарушается, и я никак не могу найти причину. Даже если создать разные CRD с разными именами и использовать разные методы согласования в одном suite_test, возникает гонка (включите race detector) и результаты получаются совершенно дикие.
CreateOrUpdate
Вот это настоящая подстава. В комментарии к методу написано, что он создаёт или обновляет объект obj в кластере Kubernetes. Текущее состояние объекта должно согласовываться с желаемым с помощью переданной функции Reconcile. obj должен быть структурным указателем, чтобы его можно было обновлять с учетом содержимого, возвращаемого сервером.Можно подумать, что мы передаем объект, и если значение его поля не изменилось, запустится апдейт. На деле всё не так. Оказывается, ресурсы, которые мы создаём оператором, удаляются, а потом создаются снова при апгрейде CRD. Даже если по факту ничего не поменялось.// CreateOrUpdate creates or updates the given object obj in the Kubernetes
// cluster. The object’s desired state should be reconciled with the existing
// state using the passed in ReconcileFn. obj must be a struct pointer so that
// obj can be updated with the content returned by the Server.
Если посмотреть на код CreateOrUpdate, мы увидим, что объект выполняет копию, полагаясь на функцию DeepCopy в zz_generated.deepcopy.go, который автоматически создается инструментом Kubebuilder после определения CRD.
Если объект не структурирован или создается динамически и без реализации функции DeepCopy, его придется каждый раз создавать заново!
Чтобы решить эту проблему, нужна функция mutate, определённая этим методом. Функция mutate вызывается независимо от создания или обновления объекта. Она возвращает ожидаемую операцию и ошибку.
// The MutateFn is called regardless of creating or updating an object.
//
// It returns the executed operation and an error.
func CreateOrUpdate(ctx context.Context, c client.Client, obj runtime.Object, f MutateFn) (OperationResult, error) {}
Инициализация объекта с функцией MutateFn позволит не создавать его заново.
Установка времени ожидания для контекста
При запуске оператор застревал на каком-то этапе, но у нас не было ни логов, ни событий, ничего, что помогло бы решить проблему.Мы снова и снова проверяли код и прокомментировали буквально каждую его часть для тестирования.
Наконец, мы обнаружили, что вышестоящий gRPC-интерфейс, к которому мы подключились, уже перемещался, а время ожидания подключения не истекало, так что оператор просто зависал. Мы перешли с gRPC на SRV, чтобы решить проблему.
Но мы все равно хотели бы, чтобы оператор нормально прекращал работу, а не зависал. Для этого мы чуть-чуть изменили context, который используется повсюду.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
Всегда добавляйте к контексту время ожидания!
Эти три проблемы, к сожалению, не единственные, но такая уж у нас работа — находить и решать проблемы. А ещё создавать их.
Заключение
После всех наших изменений UserIdentityV3 превратился во вполне приличный оператор, годный для продакшена.Если не обращать внимания на некоторые недостатки, Kubebuilder можно считать удобным инструментом создания операторов, который освобождает время разработчиков на написание логики контроллера.
Работая над операторами, мы начинаем лучше понимать принципы работы вебхуков, контроллера и т.д. Разбираться в опенсорс-коде в Kubernetes SIG и даже писать код в некоторых SIG при желании.
Ждём выхода Kubebuilder 3.0 в Kubernetes slack или группе Google.
Весь код для этой статьи можно найти на Github.
Пишем собственный оператор Kubernetes

Пишем сложные операторы Kubernetes
Советы по созданию операторов уровня продакшена с помощью Kubebuilder. В этой статье рассматривается простой пример оператора для сценария автоматического создания ServiceAccount и ClusterRoleBinding...
