Как стать автором
Обновить
Флант
Специалисты по DevOps и Kubernetes

Пишем контроллеры Kubernetes: что нужно знать о разработке масштабируемых и надёжных контроллеров

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров2.5K
Автор оригинала: Ахмет Алп Балкан (Ahmet Alp Balkan)

Любая компания, использующая 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, часто допускают следующие ошибки:

  1. Они не понимают разницы между status и spec, а также тем, кто должен обновлять каждое поле (подробнее об этом позже).

  2. Они не понимают, как вложить дочерний объект в родительский (например, как Deployment.spec.template становится основой для Pod), поэтому «переизобретают колесо», воссоздавая свойства дочернего объекта в родительском, тем самым ухудшая структуру.

  3. Они не понимают семантику полей (например, нулевых значений, значений по умолчанию, валидации). В результате поля оказываются либо неустановленными, либо заполненными некорректными значениями, которые затем попадают в 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, даже когда в этом не было необходимости (то есть не было никаких изменений). Это явный антипаттерн и крайне плохая практика, если вы стремитесь к созданию масштабируемых и надёжных контроллеров:

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

  2. Если внешний API недоступен, реконсиляция завершится с ошибкой, даже если сам объект не менялся. В зависимости от реализации это может заблокировать выполнение следующих этапов реконсиляции, даже если они никак не завязаны на этот внешний API.

  3. Логика, которая выполняется слишком долго в цикле реконсиляции, будет тормозить рабочую горутину. Это приведёт к увеличению очереди задач и снижению общей производительности и отзывчивости контроллера, поскольку рабочая горутина будет занята этой медленной операцией.

Рассмотрим конкретный пример: допустим, у вас есть контроллер 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}. Исходя из этого, вот общие рекомендации по поводу значений, возвращаемых из неё:

  1. Если в процессе реконсиляции возникли ошибки, возвращайте ошибку, а не Requeue: true. controller-runtime автоматически выполнит повторную постановку в очередь.

  2. Используйте Requeue: true только тогда, когда ошибок нет, но какой-то запущенный процесс ещё не завершён и нужно проверить его состояние, используя стандартный механизм экспоненциальной задержки (backoff).

  3. Используйте 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.

Читайте также в нашем блоге:

Теги:
Хабы:
+15
Комментарии2

Публикации

Информация

Сайт
flant.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Александр Лукьянов