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

Меня зовут Стас Иванкевич, я техлид в команде разработки управляющего слоя Platform V DropApp в СберТехе. В управляющий слой входят установщик кластера, консоль, API, другие компоненты и самое релевантное для этой статьи — наши многообразные операторы.

За последние несколько лет мы разработали целую пачку операторов — на один-два контроллера или сразу на множество. В ходе работы мы собрали и упорядочили лучшие практики, проверенные реальной эксплуатацией, а также подводные камни и типичные ошибки. И сегодня хотим поделиться нашей подборкой с вами.

Материала много, поэтому мы разделили его на две части. В первой поговорим про самые базовые и простые принципы — то, что лежит на поверхности, но, к сожалению, нередко игнорируется. Во второй обсудим более сложные вещи, например, обновление ресурсов и корректную работу с финализаторами. Надеюсь, наш опыт будет для вас полезен.

Базовые принципы

Начну с рекомендации, или примечания ко всему, что будет сказано: не изобретайте велосипед. Оператор К8s — не то место, где стоит показывать программерскую удаль и пытаться построить космолёт.

Мы рекомендуем использовать Kubebuilder или OperatorSDK как базовые инструменты для построения оператора. Оба они под капотом используют controller-runtime, и многие последующие рекомендации и подходы связаны именно с ним.

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

Итак, приступим.

Основы языка и уязвимости

Удивительно, но, как показывает практика, даже в самых крупных и уважаемых проектах часто находят элементарные уязвимости или проблемы в работе приложения.

Рекомендаций всего две: обе из моей личной статистики отсмотренного кода и проведённых ревью. Они универсальны и не привязаны ни к разработке на Go, ни к разработке непосредственно операторов. 

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

И вторая — убедитесь, что разбираетесь в понятии «уязвимость»: что такое CVE, что за зверь такой govulncheck и как им пользоваться. Помните: open source — ваш друг и враг одновременно. Да, он ускоряет разработку, но вы фактически приносите в свой проект кота в мешке. И порой при изучении кода очередного open source-оператора или даже мощного enterprise-продукта, становится не по себе, что на нём держится важная часть бизнеса.

Логирование и события

Теперь, когда мы разобрали самые основы, можем приступить к операторам.

Хороший оператор начинается не с Reconcile, а с мелочей, которые часто не замечают или не уделяют им достаточно внимания. Если права выданы «на глазок», логи пишутся как попало, а входные данные из CR не валидируются, — это первые признаки проблемного оператора. Конечно, так тоже будет работать, но это далеко не хороший оператор.

Логи — это основной способ понять, что делает оператор. Если в ваших логах будет слишком мало информации, то отладка превратится в угадайку. Если слишком много — это создаст лишний информационный шум. Но главное — внимательно следите за тем, какие данные вы добавляете в контекст для логирования.

В controller-runtime принято получать логгер из контекста. Например, вот базовый набор, который вы получите в новосозданном контроллере из коробки:

import (
	logf "sigs.k8s.io/controller-runtime/pkg/log"
…
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := logf.FromContext(ctx)

Тут важно понимать: логгер, полученный из контекста через FromContext, содержит некоторые данные уже из коробки. И их не нужно добавлять в логгер вручную. Если вы посмотрите на код controller-runtime, который вызывает ваш Reconcile, то увидите примерно следующее:

log := c.LogConstructor(&req)
reconcileID := uuid.NewUUID()
 
log = log.WithValues("reconcileID", reconcileID)
ctx = logf.IntoContext(ctx, log)
ctx = addReconcileID(ctx, reconcileID)
 
log.V(5).Info("Reconciling")
…
log.V(5).Info("Reconcile successful")

Во-первых, у каждого вызова Reconcile будет свой уникальный reconcileID. Это значение можно получить из контекста, если потребуется. А так оно уже из коробки будет добавляться ко всем логам, которые вы будете вызывать.

Но это не всё. Если покопаться в коде ещё немного, то окажется, что в контекст для логов также добавятся name и namespace из ctrl.Request. И да, их тоже не нужно добавлять вручную в каждом контроллере: controller-runtime уже об этом позаботился. Не стоит добавлять и собственные логи для начала и конца Reconcile: они также есть в коробке.

По аналогии с работой controller-runtime вы и сами можете расширять данные, которые будут использованы в логгере. Вот так:

log = log.WithValues("mydata", data)

Следующий момент — использование логгера где-то в глубине контроллера. Порой встречаются вот такие вызовы:

func (r *MyReconciler) myDeepMethod(ctx context.Context, log logr.Logger) error {

Это неправильно: не нужно передавать логгер внутрь методов. На самом деле, достаточно передавать только контекст и уже внутри метода получать логгер из этого контекста.

А чтобы ваши данные попали в новый логгер, нужно просто создать новый контекст с этим логгером:

log = log.WithValues("mydata", data)
ctx = logf.IntoContext(ctx, log)
…
func (r *MyReconciler) myDeepMethod(ctx context.Context) error {
	log := logf.FromContext(ctx)

В целом, это все базовые принципы работы с логами.

RBAC и принцип минимальных прав

Представим ситуацию: ваш оператор выходит в прод, и его начинают активно эксплуатировать. Через месяц прод-кластер взламывают, и оказывается, что точкой входа стал именно ваш оператор. Просто потому, что вы до последнего откладывали работу над разграничением прав, ведь «локально всё работает». Или, что ещё хуже, — даже не знали, что такое RBAC.

Главная ошибка — игнорировать RBAC и использовать то, что по умолчанию нагенерил Kubebuilder или OperatorSDK. Ещё одна частая ошибка — выдать оператору cluster-admin, чтобы «точно всё работало». Работать, конечно, будет, но оператор получит возможность удалить вообще любой ресурс в любом namespace. Например, в случае возникновения бага или несанкционированного доступа в него.

Предположим, вы сгенерировали новый контроллер и получили такие строчки в файле с вашим «реконсилятором»:

// +kubebuilder:rbac:groups=my.rgp.ru,resources=myres,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=my.rgp.ru,resources=myres/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=my.rgp.ru,resources=myres/finalizers,verbs=update

Это позволит вашему оператору получить доступ к ресурсу myres в группе my.rgp.ru, к обновлению финализаторов, просмотру и обновлению статуса. И не только этому, а всем контроллерам в рамках текущего оператора. Это первое, что стоит помнить.

А дальше ваш оператор начинает требовать, например, читать список подов в namespace, где развёрнут CR одного из контроллеров. Наивный путь: просто скопировать первую строчку и подредактировать её:

// +kubebuilder:rbac:groups=””,resources=pods,verbs=get;list;watch;create;update;patch;delete

Да, это будет работать. Но у вашего контроллера будет возможность не только сделать то, что ему нужно, но и потенциально навредить. И этой же возможностью будут обладать и другие контроллеры в операторе. При этом навредить можно сразу во всём кластере, в любом существующем NS.

Система по умолчанию исходит из того, что ваш оператор работает со всем кластером сразу, и не будет его ограничивать. Максимум, что вы можете сделать, — ограничить действия, которые оператор может совершить с ресурсами в кластере.

Если вы заранее знаете список NS, в которых будет работать ваш оператор, вы можете вручную или через kustomize переопределить ClusterRole на Role и добавить RoleBinding на нужные NS.

Если вы не можете заранее узнать набор NS, в которых должен работать ваш оператор, но вам крайне важно, чтобы оператор был безопасным, — вы можете пойти по пути per-namespace operator. И просто требуйте явной установки оператора во все обслуживаемые NS.

Прелесть операторов per-namespace в том, что они работают строго в рамках одного NS. У такого оператора крайне узкие права, и, если вдруг происходит взлом, злоумышленник может получить доступ максимум к данным и сервисам, расположенным в одном-единственном NS. Достигается это созданием соответствующих Role и RoleBinding для оператора.

Очевидным недостатком тут будет overhead, ведь вместо одного оператора cluster wide у вас несколько (может, даже десятков или сотен) копий операторов. По одному на каждый оператор. Но такова цена безопасности.

Валидация CR: схема и код

Представьте себе интернет-магазин, у которого на фронте нет ни одной валидации. Вместо количества вы можете спокойно передать, например, текст, а в адресе доставки указать Марс. Звучит нелепо, правда? Так вот, CRD и CR — это своеобразный фронтенд вашего оператора. И валидация в них не менее важна, чем в любом веб-приложении.

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

Валидация ресурсов в кластере — один из основных способов избежать потенциальных проблем. Kubebuilder предоставляет нам удобный механизм для конструирования валидации непосредственно из кода:

// +kubebuilder:validation:*

Через такой механизм удобно добавлять валидацию на уровне CRD. Она просто не даст пользователю добавить в кластер CR со значениями, которые считаются невалидными в вашем операторе.

Такая валидация способна проверить граничные значения полей CRD. А с помощью блока XValidation можно проверить и весьма сложные правила. Однако и этого порой бывает недостаточно, и тогда стоит смотреть в сторону валидационных вебхуков.

Вебхуки бывают разных видов, но в контексте разговора про валидацию нас интересует именно ValidatingWebhookConfiguration. Создать такой вебхук можно силами kubebuilder:

$ kubebuilder create webhook

И, казалось бы, вот и всё, что нужно. Добавить инвалидную CR нельзя — значит, всё хорошо. Но это неверно. Валидацию CRD и вебхуки можно просто отключить. Сознательно или нет, ради эксперимента или злонамеренно — это неважно. Главное — не воспринимать валидацию и вебхуки как защиту вашего оператора от невалидных данных. Это лишь часть UX вашего оператора, не более. В терминах веб-приложений бэкенд — это ваш контроллер, фронтенд — kubectl, а валидация CRD и вебхуки — не более чем проверки на уровне фронтенда.

Вспомним про наш пример с интернет-магазином и валидацией на фронте. Да, отсутствие валидации на фронте и возможность передать строку вместо количества товара — это досадно. Но если бэкенд не валидирует входные данные, то это буквально фатальная ошибка разработчика.

Именно поэтому каждый контроллер в рамках оператора обязан дублировать все проверки CRD и вебхуков на каждый вызов реконсиляции. Иначе очень просто попасть в ситуацию, отладка которой может занять не одну неделю или даже привести к необратимым последствиям.

Заключение

Разработка Kubernetes-операторов — это нечто большее, чем просто написание кода для Reconcile. Это работа с полноценной экосистемой. И то, что мы рассмотрели, — лишь верхушка айсберга. Базовые, но крайне важные принципы, о которых, к сожалению, часто забывают.

В следующей части разберём некоторые более сложные, более приближенные к написанию кода моменты. Поговорим про асинхронность и горутины внутри Reconcile, конфликты обновлений и способы уживаться с ними, использование финализаторов и владение ресурсами.

Оставайтесь на связи!

Комментарии (0)