Итак, некоторое время назад я писал статью о том, как мы переехали на werf со скрипта. По большому счёту, это продолжение той истории. Задача встала такая: нужно максимально автоматизировано разворачивать свежее приложение на нескольких кластерах kubernetes, которое уже имеет обвязку для деплоя в виде werf. После некоторых изысканий и попыток использовать "коробочные" решения самой верфи и куба, я понял, что придётся написать собственный оператор, чтобы получить прям 100% покрытия всех "хотелок". Чтобы у "гоферов" прям конкретно подгорело, для этих целей я выбрал свой любимый Python и kopf. Обозначим наши (или точнее мои) цели. Мне нужно: Управлять в некотором количестве кластеров автоматическим деплоем приложения. Делать это независимо от кодовой базы самого приложения. Свести к минимуму всё, что может потребовать участия человека для обновления. Построить процесс перехода от staging к production через MR/PR в репозитории. Сделать поддержку всего этого максимально простой (чтоб самый простой разработчик уровня jun/middle мог запустить новый кластер в продакшен). Попробовать сделать что-то общественно полезное и получить удовольствие от процесса. Так как приложение уже сейчас выкатывается с помощью верфи, нужно было использовать эту возможность в связке с масштабированием процесса в ширину. Сейчас это довольно сложно, потому что converge требует наличия исходников, телодвижений в самом проекте (гитерменизм по умолчанию запрещает в converge использовать файлы values для выкатки, а применение бандлов - нет). На самом деле, у меня много от чего подгорало в процессе, о чём я очень много писал разработчикам в чате. Но я не один такой, а у разработчиков свои приоритеты, и они мне ничем не обязаны, кроме взаимной любви. Ещё одна проблема возникла из-за моей невнимательности, потому что я с полной уверенностью был убеждён в том, что werf bundle apply --tag [semver] работает. Однако оказалось, что в самой документации написано, что "так было бы круто, но пока мы так не умеем". Как, собственно, нет и механизма удаления бандлов из кластера (но можно использовать [werf] helm uninstall). Простите мне моё нытьё. Но не всё так плохо. На самом деле, несмотря на мою невнимательность, ребята очень потрудились над документацией. Более того, они продолжают над ней работать и взаимодействуют с сообществом. Там я нашёл всё необходимое для реализации джобы. Ещё одно "большое спасибо" им стоит сказать за возможность задавать абсолютно любой параметр через переменные окружения. Это сильно упрощает работу с верфью в кубере. Поэтому я как представитель сообщества хочу пожелать им долгих лет жизни, мотивации, сил и средств для реализации всех фич.  Для тех, кто когда-либо писал свой оператор для куба на Go, некоторые действия или заморочки могут показаться странными. Но, попробовав kopf в качестве базы, я втянулся, и местами мне даже больше понравилось. Весь код уместился примерно в 300 строк, поэтому прекрасно подойдёт как учебное пособие или место хранения подсказок. Я старался много логики на себя не брать и максимально позволить куберу делать всю работу за меня. Поэтому вся логика оператора будет заключаться в том, чтобы получить информацию из registry, проверить обновления и запустить выкат обновления джобой. Мой личный совет тем, кто планирует писать оператор с помощью kopf, - начните с Custom Resource Definition и подёргайте ручки так, как вы хотите их использовать в будущем. Вкратце, что это такое и с чем это едят. CRD - это объявление новых таблиц в хранилище Kubernetes как БД. Т.е. вы можете создать свою таблицу, записавать туда данные, а эти данные будут там храниться, местами даже валидироваться при записи. Мне когда-то помогла одна статья, которую я оставлю в ресурсах ниже, чтобы понять назначение тех или иных полей. Тем не менее, я ещё раз объясню, как их писать с акцентами на то, что важно для написания оператора.  Я не буду распаляться на метаинформацию, которая в принципе у всех сущностей в кубе одинаковая, и сразу приступлю к описанию спеки. Имя группы это уникальное имя во всём кластере, которое используется чаще всего как раз при указании apiVersion. Если сравнивать это с базой Postgres, то это больше похоже на объявление непосредственно базы. Каждая группа, как база данных: содержит в себе несколько таблиц с разными табличными пространствами и таблицами. Очень рекомендуется писать здесь что-то уникальное, что будет определять назначение структуры ваших данных уже на этапе названия. Scope определяет как данные будут храниться: в отдельных namespace'ах или на уровне всего кластера. По сути бывает всего два вида: Namespaced и Cluster. Говоря об аналогии Postgres, то на этом этапе вы определяете сможет ли ваша база иметь схемы данных или нет. Names это отдельная эпопея. Тут вы определяете имена сущностей для будущего поля kind, а также в целом имя выделенной базы. Самое главное: то имя, которое вы задали в metadata, должно состоять из {spec.names.plural}.{spec.group}. Таковы условия, иначе CRD не создастся. Самый необязательный это shortNames. Это просто массив сокращений, который можно будет использовать, например, в kubectl командах. В самом ближайшем приближении это схема данных. Массив версий позволяет плавно переводить с одного API на другое, расширяя или убирая какой-то функционал. Коротко о структуре: name - имя версии. Будет использоваться как apiVersion: {spec.names.plural}.{spec.group}/{spec.versions[]name}. served - включает или выключает версию. storage - в каком-то смысле говорит о том, что текущая версия является актуальной на данный момент. Только одна версия может быть актуальной. additionalPrinterColumns - отвечает за список полей, которые увидит администратор сущности, когда будет за ней следить в kubecli. Поля можно выковыривать из любой структуры внутри таблицы. Чаще всего используется с jsonpath. schema.openAPIV3Schema - описание полей таблицы и их типов. Используется openapi для всех описаний + некоторые расширения от кубера. Процесс перехода от версии к версии очень хорошо описан в официальной документации, поэтому повторяться не буду (ссылку оставлю внизу). Говоря о расширениях для схемы, самые востребованные, пожалуй это: x-kubernetes-preserve-unknown-fields - говорит о том, что type: object может содержать абсолютно любые поля с любым уровнем вложенности внутри. Фактически это произвольный json-объект. x-kubernetes-int-or-string - определяет тип как строку или целое число и заменяет целую структуру из anyOf. x-kubernetes-validations - массив из правил для валидации полученного объекта. Очень крутой инструмент, который появился в версии 1.25 и позволяет порой избавиться от написания хуков на валидацию. Вы можете прогуглить каждый из параметров отдельно, если понадобится. Внизу я оставлю ссылку на документацию про валидацию, где эти параметры используются. Больше подробностей для описания схемы лучше читать, моя задача была дать хотя бы азы, чтобы было понятно как читать мой CRD, и в целом было представление о том, что за магические строки мы пишем в нашем операторе. Наше описание включает только одну версию, которая содержит стандартный набор параметров + описывает некоторую спеку и статус. В первом мы будем хранить описание нашей установки, а с помощью статуса будем управлять обновлением. Оператор - это некоторый демон, который крутится в кластере (или за его пределами), подписывается на некоторые события в различных сущностях (CRD или стандартных) и обрабатывает их состояние. По сути, это бесконечный цикл чтения и записи сущностей и событий. Любой объект в кластере кубера формирует те или иные события (создание, запуск, остановка, обновление и т.д.), которые отражаются в общей шине событий. Собственно, задача оператора генерировать и обрабатывать события. Kopf (Kubernetes Operator Pythonic Framework) - это фреймворк для написания операторов с помощью Python. Проект молодой (2019 год), но развивается активно. Пока что не поддерживает кластеризацию как таковую, но думаю кто-то однажды додумается написать какую-то прослойку в виде PUB/SUB или AMPQ. Возможно, однажды с психу, это буду я. Поддерживает типы и большая часть структур написана на датклассах. Философия проекта такова, что есть некоторые обработчики (хэндлеры), на которые можно навесить логику обработки того или иного события с фильтрацией по полям или типам событий, а так же записать изменение статуса объекта. Дополнительно, kopf поддерживает написание таймеров (обработка объекта через определённый интервал времени без внешнего события), демонов (кастомная реализация собственного таймера) и хуков (валидация и мутация объекта при записи). Собственно, таймеры - это самая простая реализация, которая нам и нужна. Чтобы написать свой оператор, достаточно накидать один или несколько файлов в уже готовый cli: kopf run file.py -m package.submodule ... Итак, мы создали свою структуру данных, теперь нужно подписаться на события в этой структуре и начать их обрабатывать с какой-то периодичностью.  Самый первый хук - это kopf.on.create, который вызывается каждый раз, когда у нас появляется новый объект бандла. Я его использую для того, чтобы наполнить внутренний словарь с обработчиками репозиториев. Т.к. это in-memory хранилище, при возвращении оператора в строй, необходимо его наполнить снова. Также мы помечаем status.ready как активный, чтобы можно было включать таймер по событию. Результат каждого хендлера будет записан в одноимённое поле в статусе, если результат не None. На обновление мы так же реагируем, чтобы обновить обработчик в нашей структуре. Потому что это то же самое, но мне показалось так удобнее и быстрее доступ к объекту, вместо поиска. Технически, переписать это на индекс вообще не проблема, но пока что меня такая структура устраивает. Пока пропустим обновление и посмотрим на ядро самого оператора - таймер. В нём можно отметить две вещи. Во-первых, это when - функция для вызова обработчика только на тех сущностях, которые имеют положительный статус ready. Во-вторых, таймер выключается для бандлов, где присутствует определённая аннотация. Это необходимо, чтобы иметь возможность приостановить обновление, когда это необходимо. Суть логики примерно такая: Мы получаем обработчик и самые свежие имена манифестов, подходящих под маску (да, я реализовал своими силами semver-обновление). Сверяем контрольную сумму этого манифеста и последнего развёрнутого. Если версия отличается, то мы запускаем джобу по раскатке бандла, с монтированием конфигмапа в качестве списка файлов values. Ждём завершения джобы с тем или иным статусом. Записываем новую версию и контрольную сумму. Так же я навесил хендлер на обновление развёрнутого приложения при изменении только тех ConfigMap'ов, которые используются нашим оператором. Мне достаточно было пропатчить один из параметров статуса с помощью kubernates client. Дополнительно, пришлось заморочиться удалением релиза в случае удаления объекта. Благо достаточно подчистить релиз в helm, чтобы убрать почти все признаки приложения. В целом, всё сводится к тому, чтобы появился некоторый контейнер или сервис, который будет иметь подключение к кластеру куба (с применёнными crd) и запущен с нашим коротким файлом в аргументах. Чтобы не раздувать статью, я опущу процесс сборки и деплоя образа. На поверку, вместе с oras и kopf образ в несжатом виде имеет размер 196мб. Для маленького оператора, я считаю это довольно много, но для большого скорее всего выровняется по размерам с Go-реализацией, потому текст всегда меньше бинаря. Чтобы было проще тестировать, я создал репозиторий с тестовым набором бандлов и разными версиями. В репозитории проекта, я оставил инструкции как развернуть тестовое окружение: Создаём новый namespace, в котором у нас будут храниться настройки деплоя и само приложение (можно раздельно). Создаём в оном сервис аккаунт по заветам werf. Применяем там же наши тестовые бандлы. Ждём, когда заполнятся все поля в статусе. Чтобы можно было проверить semver-обновление, достаточно указать в одном из бандлов версию со звёздочкой в патче и вскоре, очередное обновление по расписанию раскатает его в кластере с последней версией. Если поменять параметры в созданной конфигмапе, то это тоже вызовет переустановку приложения. Собственно, к чему это всё я? А к тому, что написать собственный оператор для kubernetes это довольно простая задача, если есть общее представление о предмете. Очень многие рутинные задачи по выкатке решаются теми или иными коробочными решениями, хотя порой можно написать решение под себя в 300 строк кода. Конечно, я мог бы взять ArgoCD, пропатчить в нём возможность накатки бандлов и так сделать на каждом кластере. Но зачем? Ведь можно написать довольно простой оператор, который покроет эту простую и специфичную задачу. Более того, вместе с ArgoCD я получу кучу лишнего мусора в кластере, который мне совершенно не нужен (но бизнес за него заплатит полную цену). Поэтому хочу вас вдохновить, чтобы реализация какого-то оператора не вызывала у вас боль и ужас, а удовольствие от проделанной работы. Управляйте кластером, а не давайте ему управлять вами. P.S.: На написание оператора ушло в сумме 8-10 часов вместе с реализацией сборки и деплоя. https://habr.com/ru/company/vk/blog/515138/ - статья про Custom Resource Definition. https://kubernetes.io/blog/2022/09/23/crd-validation-rules-beta/ - статья про Validation Rules (k8s>=1.25), которая может избавить от боли поддержки вебхуков оператором. https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/ - описание перехода по версиям CRD. https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules - валидация параметров сверх OpenAPI правил. https://gitlab.com/onegreyonewhite/werf-operator - сам проект оператора.  Специалист