Любая компания, использующая Kubernetes, рано или поздно задумывается о разработке собственных контроллеров. Ведь и правда, что может быть плохого в возможности выделять ресурсы декларативно? Контрольные циклы — это так увлекательно, а Kubebuilder позволяет даже новичку создать собственный контроллер Kubernetes. И вот пользователи в production вовсю используют твой неотлаженный проект, который ты написал, не имея представления о том, как проектировать идиоматические API и создавать надёжные контроллеры.

Увы, низкого порога входа, благих намерений и «иллюзии работающей реализации» недостаточно для разработки контроллеров production-уровня. Я неоднократно сталкивался с негативными последствиями использования контроллеров, созданных без глубокого понимания Kubernetes и механизмов работы. Причём от этого страдали даже крупные компании. Мы возвращались к началу и по нескольку раз переделывали контроллеры, исправляя ошибки, которые новички допускали при их разработке.
Содержание статьи:
Разрабатывая CRD, помните об API Kubernetes
Благодаря controller-gen, написать struct Go и создать на его основе Kubernetes CustomResourceDefinition (CRD) можно менее чем за 5 минут. А вот на то, чтобы перейти с этого спроектированного «на коленке» API на улучшенную вторую версию, требуется уже несколько месяцев. И всё это время старый API работает в production. Не поступайте так.
Если вы всерьёз намерены разрабатывать надёжные и долговечные production-контроллеры, основательно изучите принципы построения API, которыми разработчики Kubernetes руководствуются при проектировании. Затем изучите встроенные API, задавая себе вопросы вроде: «Зачем нужно это поле?», «Почему это поле не логического типа?», «Почему здесь используется список объектов, а не массив строк?». Лишь когда вы научитесь понимать логику встроенных API Kubernetes и принципы их проектирования, вы сможете создать долговечный API для кастомных ресурсов.
Новички, которые обычно слабо разбираются в принципах построения API, часто допускают следующие ошибки:
Они не понимают разницы между
status
иspec
, а также тем, кто должен обновлять каждое поле (подробнее об этом позже).Они не понимают, как вложить дочерний объект в родительский (например, как
Deployment.spec.template
становится основой дляPod
), поэтому «переизобретают колесо», воссоздавая свойства дочернего объекта в родительском, тем самым ухудшая структуру.Они не понимают семантику полей (например, нулевых значений, значений по умолчанию, валидации). В результате поля оказываются либо неустановленными, либо заполненными некорректными значениями, которые затем попадают в API. Я рассматривал эту тему в статье «Подводные камни при генерации CRD». Если непонятно, как поведёт себя API, когда поле не задано, считайте, что вы уже потерпели неудачу. В руководстве по принципам построения API эта тема достаточно хорошо раскрыта.
Изучение встроенных API Kubernetes покажет, что поле spec
не всегда обязательно, а поле status
есть не у всех API. Я бы даже сказал больше: стоит посмотреть и на кастомные API таких проектов, как Knative, Istio и другие популярные контроллеры. Так вы лучше поймёте, как устроены поля и как переиспользовать встроенные в Kubernetes типы, такие как ControllerRevision
и PodSpecTemplate
.
Контроллеры с единственным предназначением
Раз за разом мы сталкиваемся с тем, что инженеры навешивают на существующие контроллеры новые, никак не связанные с ними задачи просто потому, что им кажется, что это подходящее место, чтобы их «пристроить». Базовые контроллеры Kubernetes устроены иначе, и тому есть причина.
Одни из ключевых принципов проектирования Kubernetes — наличие у контроллеров чёткого входа и выхода, а также выполнение ими определённой, четко сформулированной функции. Например, Job-контроллер отслеживает объекты типа Job
и создаёт поды. Это простая и ясная модель, которую легко понять. Аналогичным образом, любой API рассчитан на четко определённую функциональность. Выходные данные одного контроллера могут быть входными данными для другого. Именно так выглядит хорошо продуманная система с точки зрения философии Unix.
Рекомендую изучить типовые формы контроллеров (отличный доклад Дэниела Смита, одного из архитекторов kube-apiserver) и основные контроллеры Kubernetes. Вы заметите, что все они выполняют предельно чёткую задачу, а входы/выходы настолько понятны, что их работу можно объяснить с помощью простенькой диаграммы. Если ваш контроллер не такой, то, скорее всего, вы неправильно спроектировали либо его, либо CRD.
Грамотно разработанные API и контроллеры будут работать бесшовно, будто встроены в базовый API Kubernetes или являются стандартным оператором.
Чувствуете, что с устройством контроллера что-то не то, у него слишком много входов/выходов, он перегружен функциями, или просто «не лежит к нему душа»? Скорее всего, вы делаете что-то не по-кубернетовски. Мне самому пришлось с этим немало помучиться, особенно когда разрабатывал контроллеры, которые управляют внешними ресурсами с недекларативной парадигмой конфигурации.
Форма метода Reconcile()
Предположим, вы используете Kubebuilder (с популярным набором библиотек controller-runtime «под капотом» для сборки контроллеров) и реализуете метод Reconcile()
, который controller-runtime вызывает при каждом изменении одного из входных параметров. Здесь происходит вся магия. А поскольку метод Reconcile()
можно написать как угодно, большинство новичков сваливают сюда всю свою кодовую мешанину.
Именно поэтому крупные проекты вроде Knative разрабатывают собственные шаблоны, в которых каждый контроллер выполняет определённый набор шагов в одной и той же последовательности. Создавая общий шаблон или фреймворк для контроллеров, вы устанавливаете своего рода рамки, чтобы разработчики не сбивались с пути и не допускали ошибок в логике реконсиляции.
Досадно, но controller-runtime не задаёт никаких стандартов в этом отношении. Самый надёжный вариант — изучить код других контроллеров (вроде Cluster API), чтобы перенять отработанные приёмы и в полной мере освоить механизм реконсиляции.
Ещё есть такие штуки, как Reconciler.io и Apollo SDK от Reddit, которые предлагают конечные автоматы для реконсиляторов controller-runtime.
Со временем стало ясно, что практически все наши контроллеры используют похожий алгоритм реконсиляции. Вот он в формате псевдокода:
func (m *FooController) Reconcile(..., req ctrl.Request) (ctrl.Result, error) {
log := ctrl.LoggerFrom(ctx)
obj := new(apiv1.Foo)
// 1. Извлечь ресурс из кэша: r.Client.Get(ctx, req.NamespacedName, obj).
// 2. Финализировать объект, если тот удаляется.
// 3. Добавить финализаторы + r.Client.Update() если отсутствует.
orig := foo.DeepCopy() // сделать копию объекта, который будем менять ниже
foo.InitializeConditions() // установить все условия на "Unknown", если отсутствует
// 4. Реконсилировать ресурс и его дочерние ресурсы (магия сокрыта здесь).
// Вычислить условия и другие поля "status".
reconcileErr := r.reconcileKind(ctx, kcp) // Магия происходит здесь!
// 5. if (orig.Status != foo.Status): client.Status.Patch(obj)
}
Ключевой момент здесь в том, что мы всегда инициализируем условия и обновляем статус в reconcileKind
даже в случае ошибки при реконсиляции.
Советую придерживаться похожего общего формата для контроллеров, создаваемых в вашей компании. Хотя можно применять плагины Kubebuilder для кастомизации шаблонов, но вряд ли получится заставить всех им следовать.
Сообщайте о состоянии и условиях
Я практически не встречал начинающих инженеров, которые правильно проектировали бы поля status
в CRD (если они в принципе там есть). Всё это подробно обсуждается в принципах построения API, поэтому я буду краток. Если API-объект реконсилируется контроллером, ресурс должен отображать его статус в полях status
. Например, нет контроллера ConfigMap, поэтому в ConfigMap нет поля status
.
В LinkedIn наши кастомные API-объекты содержат поле status.conditions
по аналогии с условиями в Kubernetes или Knative. И мы применяем нечто вроде менеджера наборов условий Knative (condition set manager), который обеспечивает высокоуровневые методы для задания условий, их сортировки и тому подобного.
Это позволяет на уровне абстракции задавать условия для API-объектов в логике реконсилера и сообщать о них.
func (r *FooReconciler) reconcileKind(obj *Foo) errror {
// Создаем/настраиваем объект Bar, ждем его готовности (Ready).
if err := r.reconcileBar(obj); if err != nil {
obj.MarkBarNotReady("не смог настроить ресурс Bar: %w", err)
return fmt.Errorf("не удалось реконсилировать Bar: %w", err)
}
obj.MarkBarReady()
}
Как только мы меняем какое-то условие, менеджер условий тут же пересчитывает верхнеуровневое условие Ready
, которое есть у всех наших объектов (как и рекомендуют принципы построения API Kubernetes). Другие контроллеры и пользователи смотрят на это Ready-условие, чтобы оценить состояние объектов (вдобавок вы получаете возможность использовать kubectl cond
).
Научитесь использовать observedGeneration
Во всех наших секциях conditions
есть поле observedGeneration
. Примечательно, что оно отсутствует даже в некоторых популярных CRD (вроде ArgoCD Application).
По сути, это поле говорит нам, было ли условие вычислено на основе последней конфигурации объекта или же мы смотрим на информацию о статусе масштабирования, потому что контроллер ещё не приступил к реконсиляции объекта после обновления.
Например, просто увидеть Ready: True
недостаточно. Это лишь означает, что когда-то давно это было так. Условие становится важным и информативным, только когда cond.observedGeneration == metadata.generation
.
Реальный случай. У нас в production был контроллер без поддержки observedGeneration
. Клиенты обновляли spec
объекта и тут же проверяли его условие Ready
. Но это условие почти всегда было неактуальным, поскольку контроллер ещё не успел провести реконсиляцию объекта. В результате клиенты считали, что развёртывание приложения завершено (completed). А на самом деле оно могло даже ещё не начаться (иногда всё это дело падало, но этого никто не замечал).
Разберитесь с кэшированными клиентами
По умолчанию controller-runtime предоставляет клиент для Kubernetes API, который обслуживает запросы на чтение из in-memory-кэша (используются общие информеры из client-go
). Обычно это не страшно, потому что контроллеры и так работают с неактуальными данными, но именно здесь кроется тонкость и потенциальная проблема: забыли об этом — получили контроллеры с багами (подробнее об этом в разделе про «expectations» ниже).
Когда вы выполняете запись, которая напрямую попадает на API-сервер, её результаты могут не сразу отобразиться в кэшированном клиенте. Например, удалив объект, вы можете с удивлением обнаружить его в результатах list при следующей реконсиляции.
Малоизвестная особенность controller-runtime, о которой большинство начинающих разработчиков не догадываются, состоит в том, что controller-runtime создаёт новые информеры «на лету». Обычно, когда вы явно указываете источники событий при создании контроллера (например, в builder.{For,Owns,Watches}
), информеры и кэши запускаются при старте.
Однако, если попытаться запросить через client.{Get,List}
ресурсы, которые не были заявлены в конфигурации контроллера заранее, controller-runtime динамически инициализирует информер и приостановит работу, ожидая, пока заполнится его кэш. Это может привести к следующим проблемам:
controller-runtime начнет отслеживать (watch) ресурсы указанного типа и сохранять в кэше все экземпляры этих ресурсов (даже если запрашивался только один), что может привести к нехватке памяти.
Время выполнения реконсиляции станет непредсказуемым из-за процесса синхронизации кэша информера. В этот период вызывающая горутина будет заблокирована и не сможет выполнять реконсиляцию других ресурсов.
Именно поэтому я рекомендую установить параметр ReaderFailOnMissingInformer: true
и отключить такое поведение, чтобы точно знать, за какими ресурсами следит (watch) и какие ресурсы кэширует контроллер. В ином случае остаётся только гадать, какие информеры controller-runtime использует в процессе работы.
controller-runtime предоставляет множество других возможностей для настройки кэширования, например полное отключение кэширования для определённых типов ресурсов, исключение некоторых полей из in-memory-кэша или ограничение кэша определёнными пространствами имён. Я рекомендую изучить эти возможности, чтобы лучше понять, как настроить поведение кэша под свои нужды.
Быстрая и автономная реконсиляция
Реконсиляция объекта, который уже находится в актуальном состоянии (то есть когда желаемое состояние соответствует текущему), должна выполняться очень быстро и в автономном режиме, то есть без каких-либо обращений к API (как к внешним, так и для записи в Kubernetes API). Поэтому контроллеры используют кэшированных клиентов для извлечения данных из кэша для операций чтения и определения текущего состояния системы.
Мне не раз приходилось сталкиваться с контроллерами, которые при каждом вызове Reconcile()
выполняли ненужные API-вызовы к внешним системам или отправляли обновления статуса в Kubernetes API, даже когда в этом не было необходимости (то есть не было никаких изменений). Это явный антипаттерн и крайне плохая практика, если вы стремитесь к созданию масштабируемых и надёжных контроллеров:
Такие контроллеры буквально заваливают внешние API ненужными запросами при запуске (или во время полной пересинхронизации, или из-за ошибок, приводящих к бесконечным циклам).
Если внешний API недоступен, реконсиляция завершится с ошибкой, даже если сам объект не менялся. В зависимости от реализации это может заблокировать выполнение следующих этапов реконсиляции, даже если они никак не завязаны на этот внешний API.
Логика, которая выполняется слишком долго в цикле реконсиляции, будет тормозить рабочую горутину. Это приведёт к увеличению очереди задач и снижению общей производительности и отзывчивости контроллера, поскольку рабочая горутина будет занята этой медленной операцией.
Рассмотрим конкретный пример: допустим, у вас есть контроллер S3Bucket, который создаёт S3-бакеты и управляет ими через API AWS S3. Нет никакого смысла обращаться к API S3 при каждой реконсиляции. Вместо этого следует сохранять результаты вызовов к API S3, например, в поле status.observedGeneration
, чтобы отслеживать, какое последнее поколение объекта было успешно применено в API S3. Если значение этого поля равно 0, контроллер понимает, что нужно совершить вызов «Create Bucket» в API S3. Когда клиент обновит кастомный ресурс S3Bucket, его metadata.generation
перестанет совпадать с сохранённым status.observedGeneration
, и контроллер поймет, что необходимо вызвать «Update Bucket» в API S3. И только после успешного выполнения этой операции он должен обновить status.observedGeneration
. Так можно избежать лишних обращений к внешнему API S3, если объект уже находится в актуальном состоянии.
Реконсиляция возвращаемых значений
Функция Reconcile()
в контроллере возвращает значения типа ctrl.Result
и error
. Как правило, начинающие разработчики не до конца понимают, что именно нужно возвращать из Reconcile()
.
Функция Reconcile()
вызывается при каждом изменении источников событий, указанных в builder.{For,Owns,Watches}
. Исходя из этого, вот общие рекомендации по поводу значений, возвращаемых из неё:
Если в процессе реконсиляции возникли ошибки, возвращайте ошибку, а не
Requeue: true
. controller-runtime автоматически выполнит повторную постановку в очередь.Используйте
Requeue: true
только тогда, когда ошибок нет, но какой-то запущенный процесс ещё не завершён и нужно проверить его состояние, используя стандартный механизм экспоненциальной задержки (backoff).Используйте
RequeueAfter: <TIME>
только тогда, когда нужно, чтобы реконсиляция объекта запустилась снова через какое-то время. Это полезно для реализации периодической реконсиляции по расписанию (например, для контроллера CronJob или при необходимости задать свой интервал повторных попыток).
Выбор того, как будет работать Reconcile()
, — это дело вкуса: можно стараться максимально продвинуться за один проход, а можно инициировать повторную реконсиляцию после каждого изменения. Стоит отметить, что первый вариант, как правило, лучше подходит для юнит-тестов и чаще встречается в Open Source-контроллерах. В конечном счете — при правильно настроенных триггерах событий — объект всё равно будет автоматически повторно ставиться в очередь.
Механика workqueue/resync
У OpenKruise есть статья про механику workqueue, обязательно почитайте. Часто вижу, как начинающие разработчики не учитывают, что объект гарантированно будет реконсилироваться одновременно разными воркерами, и из-за этого добавляют в свои контроллеры ненужные блокировки.
Ещё они не понимают, как часто и когда объект реконсилируется. Например, контроллер обновил объект — тот сразу же снова в очереди на реконсиляцию (обновление запускает событие watch
).
Даже если объекты не менялись, все ресурсы, за которыми контроллер следит, будут периодически снова попадать в очередь на реконсиляцию (это называется «resync»; период задаётся параметром SyncPeriod
). Таково поведение по умолчанию, потому что контроллеры иногда могут пропустить события watch
(хотя это и редкость) или не успеть обработать часть событий при смене лидера. Из-за этого приходится делать полную реконсиляцию всех объектов, которые есть в кэше (см. примечание ниже). Так что по умолчанию считайте, что контроллер будет периодически проверять «всё и вся» на предмет изменений.
Случай из практики: один из наших контроллеров управлял тысячами объектов и делал полный resync каждые 20 минут. На реконсиляцию каждого объекта уходило несколько секунд. В итоге, когда клиент создавал или менял объект, контроллер брался за него только спустя несколько минут, потому что новый запрос вставал в конец workqueue. А если такое происходило в начале полного resync или при запуске контроллера, задержка до того, как новый объект попадал в работу, была ещё больше.
В controller-runtime v0.20 появилась приоритетная очередь для workqueue. Теперь реконсиляция объектов не по edge-trigger (то есть в результате create/update и т. п.) стала менее приоритетной. Это сделало контроллер быстрее и отзывчивее во время полных resync и при запуске.
Вот почему так важно разбираться в том, как работает workqueue, понимать, как настроить количество воркеров (MaxConcurrentReconciles
) и следить за такими показателями, как задержка реконсиляции контроллера, глубина очереди workqueue и число активных воркеров, — всё это необходимо, чтобы понять, насколько хорошо масштабируется контроллер.
Примечание
Важно помнить, что полная периодическая пересинхронизация может создать большую нагрузку на API-сервер Kubernetes, особенно в версиях до 1.27 (если контроллер следит за большим количеством ресурсов с высокой кардинальностью, например подов), поскольку вызовы List
в kube-apiserver достаточно ресурсоёмкие (в новых версиях K8s проблему частично решили и продолжают над ней работать. В Uber, например, рассказывали, что отказались от полной выгрузки списка ресурсов от kube-apiserver: они заметили, что объекты пропускают реконсиляцию не из-за проблем с потоком watch
, а из-за дропа событий при смене лидера. Поэтому вместо того, чтобы использовать SyncPeriod
, который делает полный List
, они просто заново ставят в очередь на обработку все объекты, которые уже есть в кэше контроллера.
Паттерн «expectations»
Как отмечалось ранее, клиент controller-runtime для операций чтения использует локальный кэш информеров, что позволяет избежать избыточных запросов к API-серверу. Непосредственное обращение к API-серверу происходит только в процессе запуска контроллера и во время resync.
Этот кэш поддерживается в актуальном состоянии на основе получаемых событий watch
от API-сервера. Из-за этого контроллер почти наверняка рано или поздно столкнется с ситуацией, когда данные в кэше будут устаревшими, потому что события watch
поступают не сразу после записей. Кэшированные клиенты не обеспечивают согласованность «запись — чтение», то есть что вы сразу же после записи сможете прочитать именно то, что записали (read-your-writes consistency).
Проектируя Reconcile()
, необходимо отталкиваться от этого предположения. Оно совсем не очевидно на первый взгляд, но такова реальность работы с кэшированным клиентом. Вот несколько примеров, чтобы лучше понять, о чём речь.
Пример 1. Вы пишете контроллер ReplicaSet
. Контроллер видит ReplicaSet, в котором указано replicas: 5
, и запрашивает список подов через client.List
(который берёт данные из кэша). В ответ получает лишь 3 пода. Проблема в неактуальности кэша информера — на самом деле в API уже 5 подов. Но контроллер об этом не знает, поэтому решает создать ещё 2 пода. В итоге вы получаете 7 подов. Явно не то, что нужно.
Пример 2. Теперь количество реплик ReplicaSet уменьшается с 5 до 3. Контроллер запрашивает список подов, видит 5 подов, удаляет 2 пода, а когда в следующий раз снова запрашивает список подов, старые поды всё ещё там, и контроллер удаляет ещё 2 пода. Если поды выбираются каким-либо случайным, недетерминированным образом (например, сортируются по имени), ReplicaSet отмасштабируется с 5 до 1 пода — определённо не то, чего вы хотели.
Пример 3. Для каждого объекта kind=A создаётся объект kind=B. При изменении A обновляется и B. Обновление проходит успешно, но при следующей реконсиляции A контроллер не видит обновлений в B. В результате он снова пытается обновить B до целевого состояния и получает ошибку Conflict
. Причина — попытка обновить старую версию объекта. Действительно, объект уже обновлён, зачем обновлять его снова?
Не знаете, как решить подобные проблемы с контроллером? Скорее всего, вы просто не знакомы с паттерном «expectations».
Его суть как раз в том, что контроллеры должны вести в памяти учёт своих ожиданий, возникших после успешных операций записи. Как только ожидание записано, контроллер знает, что его задача — дождаться, пока кэш догонит реальность (что запустит новую реконсиляцию), и не действовать на основе устаревших данных из кэша.
Многие ключевые контроллеры используют этот паттерн, а Elastic Operator даже сопровождает реализацию подробным объяснением! Мы сами реализовали пару его вариантов в LinkedIn.
Заключение
Если у вас есть вопросы по разработке контроллеров, заходите в Slack Kubernetes и задавайте их в канале #controller-runtime. Там очень помогают! Хотите увидеть хороший пример контроллера? Посмотрите на Cluster API. А ещё не забудьте про руководство по лучшим практикам от Operator SDK.
Мы в LinkedIn используем упражнение по разработке контроллеров, придуманное одним из бывших коллег, чтобы помочь новым инженерам освоиться и понять их устройство. Оно затрагивает многие аспекты разработки контроллеров и знакомит людей с особенностями различных API Kubernetes:
Упражнение: разработайте API SequentialJob и соответствующий контроллер. API должен обеспечивать возможность задавать список образов контейнеров для пакетных заданий, которые будут выполняться одно за другим, последовательно.
Вспомогательные вопросы:
Как пользователь будет задавать список контейнеров? (Используете ли вы основные типы?)
Выводится ли статус? Как статус вычисляется? Как пользователь узнаёт об «упавших» задачах (job)?
Как валидируются введённые пользователем данные? Куда выводятся сообщения об ошибках реконсиляции?
Что произойдёт, если SequentialJob изменится во время выполнения задач (job)?
Как происходит очистка созданных дочерних ресурсов?
Надеюсь, что эта статья была для вас полезной.
Спасибо Майку Хелмику (Mike Helmick) за то, что прочитал черновики и помог своими замечаниями.
Ахмет Алп Балкан
Инженер-программист в команде LinkedIn по разработке вычислительной инфраструктуры на базе Kubernetes. В прошлом работал в Twitter, Google Cloud и Microsoft Azure с контейнерами и Kubernetes. Мейнтейнер ряда Open Source-инструментов в экосистеме Kubernetes.
P. S.
Читайте также в нашем блоге: