Итак, некоторое время назад я писал статью о том, как мы переехали на 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, проверить обновления и запустить выкат обновления джобой.

CRD

Мой личный совет тем, кто планирует писать оператор с помощью kopf, - начните с Custom Resource Definition и подёргайте ручки так, как вы хотите их использовать в будущем.

Вкратце, что это такое и с чем это едят. CRD - это объявление новых таблиц в хранилище Kubernetes как БД. Т.е. вы можете создать свою таблицу, записавать туда данные, а эти данные будут там храниться, местами даже валидироваться при записи. Мне когда-то помогла одна статья, которую я оставлю в ресурсах ниже, чтобы понять назначение тех или иных полей. Тем не менее, я ещё раз объясню, как их писать с акцентами на то, что важно для написания оператора.

CRD оператора
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: bundles.operator.werf.dev
spec:
  group: operator.werf.dev
  names:
    kind: Bundle
    listKind: BundleList
    plural: bundles
    singular: bundle
    shortNames:
      - bndl
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      additionalPrinterColumns:
        - jsonPath: .spec.registry
          name: Registry
          type: string
        - jsonPath: .spec.repo
          name: Registry
          type: string
        - jsonPath: .spec.version
          name: Target
          type: string
        - jsonPath: .status.deploy.version
          name: Version
          type: string
        - jsonPath: .status.deploy.digest
          name: Last
          type: string
      schema:
        openAPIV3Schema:
          type: object
          required:
            - spec
          properties:
            apiVersion:
              description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
              type: string
            kind:
              description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
              type: string
            metadata:
              type: object
            spec:
              type: object
              required:
                - registry
                - repo
              properties:
                registry:
                  type: string
                repo:
                  type: string
                version:
                  type: string
                  default: latest
                auth:
                  type: string
                project_namespace:
                  type: string
                values:
                  type: string
                env:
                  type: object
                  x-kubernetes-preserve-unknown-fields: true
            status:
              type: object
              properties:
                ready:
                  type: integer
                  default: 0
                forceUpdate:
                  type: integer
                  default: 0
                target:
                  type: object
                  properties:
                    digest:
                      type: string
                    version:
                      type: string
                deploy:
                  type: object
                  properties:
                    digest:
                      type: string
                    version:
                      type: string

Я не буду распаляться на метаинформацию, которая в принципе у всех сущностей в кубе одинаковая, и сразу приступлю к описанию спеки.

Group, scope и names

Имя группы это уникальное имя во всём кластере, которое используется чаще всего как раз при указании apiVersion. Если сравнивать это с базой Postgres, то это больше похоже на объявление непосредственно базы. Каждая группа, как база данных: содержит в себе несколько таблиц с разными табличными пространствами и таблицами. Очень рекомендуется писать здесь что-то уникальное, что будет определять назначение структуры ваших данных уже на этапе названия.

Scope определяет как данные будут храниться: в отдельных namespace'ах или на уровне всего кластера. По сути бывает всего два вида: Namespaced и Cluster. Говоря об аналогии Postgres, то на этом этапе вы определяете сможет ли ваша база иметь схемы данных или нет.

Names это отдельная эпопея. Тут вы определяете имена сущностей для будущего поля kind, а также в целом имя выделенной базы. Самое главное: то имя, которое вы задали в metadata, должно состоять из {spec.names.plural}.{spec.group}. Таковы условия, иначе CRD не создастся. Самый необязательный это shortNames. Это просто массив сокращений, который можно будет использовать, например, в kubectl командах.

Versions

В самом ближайшем приближении это схема данных. Массив версий позволяет плавно переводить с одного 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 ...

Описание хендлеров и логики

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

Код на 300 строк
"""
Copyright 2022 Sergey Klyuykov

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""

import base64
import dataclasses
import os
import random
import re
import typing as _t
import uuid
from collections import defaultdict
from contextlib import suppress
from copy import deepcopy

import kopf
import yaml
from kubernetes import client as k8s_client
from kubernetes.watch import Watch
from oras.client import OrasClient
from pkg_resources import parse_version

DISABLE_ANNOTATION = 'operator.werf.dev/disable-autoupdate'
JOB_LABEL = 'operator.werf.dev/deployment'
JOBS_TTL = int(os.getenv('WERF_OPERATOR_JOBS_TTL', '120'))


@dataclasses.dataclass(slots=True)
class RepoHandler:
    version_re = re.compile(r"^[v]?([\d]+)\.([\d\*]+)\.([\d\*]+)", re.MULTILINE)

    client: OrasClient
    repo: str
    values: _t.Optional[str] = None
    env: dict[str, str] = dataclasses.field(default_factory=lambda: {})
    version: _t.Pattern | str = re.compile('latest')
    secret_name: _t.Optional[str] = None
    namespace: _t.Optional[str] = None
    semver: bool = False

    def __post_init__(self):
        if isinstance(self.version, str):
            if self.version_re.match(self.version):
                self.version = re.compile(r'^' + self.version.replace('.', r'\.').replace('*', r'.+'), re.MULTILINE)
                self.semver = True
            else:
                self.version = re.compile(r'^' + self.version + r'$')

    def login(self, username, password):
        self.client.login(password=password, username=username)

    def get_required_tag(self):
        tags = list(filter(self.version.match, self.client.get_tags(self.repo)['tags']))

        if self.semver:
            tags.sort(key=parse_version)
            tags.reverse()
            return tags[0]

        return tags[0]

    def get_required_digest(self, tag):
        manifest = self.client.remote.get_manifest(f'{self.repo}:{tag}')
        return manifest['config']['digest']

    def get_latest_digest(self):
        return self.get_required_digest(self.get_required_tag())

    def deploy(self, version, name, namespace):
        return self.make_action(version, name, namespace, action='bundle apply')

    def dismiss(self, version, name, namespace):
        return self.make_action(version, name, namespace, action='dismiss')

    def make_action(self, version, name, namespace, action):
        env_variables = {
            "WERF_REPO": f'{self.client.remote.hostname}/{self.repo}',
            "WERF_TAG": version,
            "WERF_NAMESPACE": self.namespace or namespace,
            "WERF_RELEASE": name,
        }
        if self.env:
            env_variables.update({k: v for k, v in self.env.items() if k not in env_variables})

        if action == 'dismiss':
            command = f'helm uninstall {name} --namespace={self.namespace or namespace}'
        else:
            command = action

        image = f'registry.werf.io/werf/werf:{os.getenv("WERF_OPERATOR_TAG", "latest")}'

        volumes = [
            {'name': 'docker', 'emptyDir': {}},
        ]
        mounts = [
            {
                "mountPath": '/home/build/.docker',
                "name": "docker",
            },
        ]

        if action != dismiss and self.values:
            with suppress(Exception):
                api_client = k8s_client.CoreV1Api()
                data = api_client.read_namespaced_config_map(self.values, namespace=namespace).data
                volumes.append({'name': 'values', 'configMap': {"name": self.values}})
                mounts.append({'mountPath': '/home/build/.values', "name": 'values', "readOnly": True})
                env_variables.update({
                    f"WERF_VALUES_OPERATOR_{i}": f"/home/build/.values/{n}"
                    for i, n in enumerate(data)
                })

        exec_container = {
            "image": image,
            "name": f"{action.replace(' ', '-')}-bundle",
            "args": ['sh', '-ec', f'werf {command}'],
            "env": [
                {"name": key, "value": value}
                for key, value in env_variables.items()
            ],
            'volumeMounts': deepcopy(mounts),
        }

        result = {
            'apiVersion': 'batch/v1',
            'kind': 'Job',
            'metadata': {
                'name': f"werf-{action.replace(' ', '-')}-{uuid.uuid4()}",
                'annotations': {
                    JOB_LABEL: name,
                },
            },
            'spec': {
                'ttlSecondsAfterFinished': JOBS_TTL,
                'backoffLimit': 0,
                'template': {
                    'spec': {
                        'serviceAccount': 'werf',
                        'automountServiceAccountToken': True,
                        'volumes': volumes,
                        'containers': [exec_container],
                        'restartPolicy': 'Never',
                    },
                },
            },
        }
        if self.secret_name and action != 'dismiss':
            result['spec']['template']['spec']['initContainers'] = [{
                "image": image,
                "name": 'repo-auth',
                "args": ['sh', '-ec', f'werf cr login {self.client.remote.hostname}'],
                "env": [
                    {"name": 'WERF_USERNAME',
                     "valueFrom": {"secretKeyRef": {"name": self.secret_name, "key": "username"}}},
                    {"name": 'WERF_PASSWORD',
                     "valueFrom": {"secretKeyRef": {"name": self.secret_name, "key": "password"}}},
                ],
                'volumeMounts': deepcopy(mounts),
            }]
        return result

    @classmethod
    def from_spec(cls, spec: dict):
        fields = dataclasses.fields(cls)
        return cls(**{
            key: value
            for key, value in spec.items()
            if key in fields
        })


NAMESPACED_REPOS: dict[str, dict[str, RepoHandler]] = defaultdict(lambda: {})


def get_image_repo(namespace, registry, repo, auth, version='latest', project_namespace=None, **kwargs) -> RepoHandler:
    repo_handler = RepoHandler(
        client=OrasClient(hostname=registry),
        repo=repo,
        version=version,
        namespace=project_namespace,
        secret_name=auth,
        **kwargs,
    )

    if auth:
        v1 = k8s_client.CoreV1Api()
        sec: dict[str, str] = v1.read_namespaced_secret(auth, namespace).data  # type: ignore
        username = base64.b64decode(sec["username"]).decode('utf-8')
        password = base64.b64decode(sec["password"]).decode('utf-8')
        repo_handler.login(password=password, username=username)

    return repo_handler


@kopf.on.create('operator.werf.dev', 'v1', 'Bundle')
@kopf.on.resume('operator.werf.dev', 'v1', 'Bundle')
def ready(spec, namespace, name, **_):
    try:
        NAMESPACED_REPOS[namespace][name] = get_image_repo(namespace, **spec)
    except Exception as err:
        raise kopf.TemporaryError(f"The data is not yet ready. {err}", delay=5)
    return 1


@kopf.on.update('operator.werf.dev', 'v1', 'Bundle')  # type: ignore
def ready(spec, name, namespace, body, **_):  # noqa: F811
    try:
        NAMESPACED_REPOS[namespace][name] = get_image_repo(namespace, **spec)
    except Exception as err:
        kopf.exception(body, exc=err)
        return 0
    return 1


@kopf.on.delete('operator.werf.dev', 'v1', 'Bundle', when=lambda status, **_: status.get('ready'))
def dismiss(name, namespace, status, logger, **_):
    try:
        current_version = status.get('deploy', {}).get('version')
        if current_version:
            job = NAMESPACED_REPOS[namespace][name].dismiss(current_version, name, namespace)
            logger.debug(f"Dismiss Job:\n{yaml.dump(job)}")

            batch_client = k8s_client.BatchV1Api()
            batch_client.create_namespaced_job(namespace, job)
    except Exception as err:
        logger.error(err)


def check_if_has_bundle(name, namespace):
    with suppress(Exception):
        for handler_name, handler in NAMESPACED_REPOS[namespace].items():
            if handler.values == name:
                yield handler_name


@kopf.on.field(
    'configmap',
    field='data',
    when=(lambda name, namespace, **_: bool(next(check_if_has_bundle(name, namespace), 0))),
)
def update_bundle(name, namespace, **_):
    api_client = k8s_client.CustomObjectsApi()
    patch_body = {'status': {"forceUpdate": 1}}
    for handler_name in check_if_has_bundle(name, namespace):
        api_client.patch_namespaced_custom_object(
            'operator.werf.dev', 'v1', namespace, 'bundles',
            handler_name, patch_body,
        )


@kopf.timer(
    'operator.werf.dev', 'v1', 'Bundle',
    interval=int(os.getenv('WERF_OPERATOR_TIMER_INTERVAL', '600')),
    initial_delay=3,
    idle=int(os.getenv('WERF_OPERATOR_TIMER_IDLE', '10')),
    annotations={DISABLE_ANNOTATION: kopf.ABSENT},
    when=(lambda status, **_: status.get('ready')),
)
def update(name, namespace, status, body, patch, logger, **_):
    try:
        handler = NAMESPACED_REPOS[namespace][name]
    except KeyError as err:
        raise kopf.TemporaryError(f'Still initialize: {err}', delay=10)

    current_digest = status.get('deploy', {}).get('digest')
    try:
        latest_tag = handler.get_required_tag()
        latest_digest = handler.get_required_digest(latest_tag)
    except ValueError as err:
        kopf.exception(body, exc=err)
        raise kopf.TemporaryError(str(err))

    if current_digest == latest_digest and not status.get('forceUpdate'):
        return

    kopf.info(body, reason='Update', message='Initialize deploy application by digest update.')
    patch.status['target'] = {"digest": latest_digest, "version": latest_tag}
    job = handler.deploy(latest_tag, name, namespace)
    kopf.adopt(job)
    logger.debug(f"Deploy Job:\n{yaml.dump(job)}")

    batch_client = k8s_client.BatchV1Api()
    job_obj = batch_client.create_namespaced_job(namespace, job)
    job_body_dict = job_obj.to_dict()
    if 'apiVersion' not in job_body_dict:
        job_body_dict['apiVersion'] = job_body_dict['api_version']
    kopf.info(job_body_dict, reason='Nesting', message=f'Job created by werf operator bundle {name}')
    w = Watch()

    for event in w.stream(
            batch_client.list_namespaced_job,
            namespace=namespace,
            field_selector=f'metadata.name={job["metadata"]["name"]}',
    ):
        event_object = event["object"]
        if event_object.status.succeeded:
            w.stop()
            patch.status['deploy'] = {"digest": latest_digest, "version": latest_tag}
            kopf.info(body, reason='Update', message='Actual release has been deployed.')
        if not event_object.status.active and event_object.status.failed:
            w.stop()
            kopf.info(body, reason="ERROR", message="Cannot deploy release. Job failed.")

    if status['forceUpdate']:
        patch.status['forceUpdate'] = 0


@kopf.on.startup()
def configure(settings: kopf.OperatorSettings, **_):
    settings.peering.priority = random.randint(0, 32767)  # nosec

Самый первый хук - это kopf.on.create, который вызывается каждый раз, когда у нас появляется новый объект бандла. Я его использую для того, чтобы наполнить внутренний словарь с обработчиками репозиториев. Т.к. это in-memory хранилище, при возвращении оператора в строй, необходимо его наполнить снова. Также мы помечаем status.ready как активный, чтобы можно было включать таймер по событию. Результат каждого хендлера будет записан в одноимённое поле в статусе, если результат не None.

На обновление мы так же реагируем, чтобы обновить обработчик в нашей структуре.

Почему не kopf.index?

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

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

Суть логики примерно такая:

  1. Мы получаем обработчик и самые свежие имена манифестов, подходящих под маску (да, я реализовал своими силами semver-обновление).

  2. Сверяем контрольную сумму этого манифеста и последнего развёрнутого.

  3. Если версия отличается, то мы запускаем джобу по раскатке бандла, с монтированием конфигмапа в качестве списка файлов values.

  4. Ждём завершения джобы с тем или иным статусом.

  5. Записываем новую версию и контрольную сумму.

Так же я навесил хендлер на обновление развёрнутого приложения при изменении только тех ConfigMap'ов, которые используются нашим оператором. Мне достаточно было пропатчить один из параметров статуса с помощью kubernates client.

Дополнительно, пришлось заморочиться удалением релиза в случае удаления объекта. Благо достаточно подчистить релиз в helm, чтобы убрать почти все признаки приложения.

Используем оператор

В целом, всё сводится к тому, чтобы появился некоторый контейнер или сервис, который будет иметь подключение к кластеру куба (с применёнными crd) и запущен с нашим коротким файлом в аргументах. Чтобы не раздувать статью, я опущу процесс сборки и деплоя образа. На поверку, вместе с oras и kopf образ в несжатом виде имеет размер 196мб. Для маленького оператора, я считаю это довольно много, но для большого скорее всего выровняется по размерам с Go-реализацией, потому текст всегда меньше бинаря.

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

  • Создаём новый namespace, в котором у нас будут храниться настройки деплоя и само приложение (можно раздельно).

  • Создаём в оном сервис аккаунт по заветам werf.

  • Применяем там же наши тестовые бандлы.

  • Ждём, когда заполнятся все поля в статусе.

Чтобы можно было проверить semver-обновление, достаточно указать в одном из бандлов версию со звёздочкой в патче и вскоре, очередное обновление по расписанию раскатает его в кластере с последней версией. Если поменять параметры в созданной конфигмапе, то это тоже вызовет переустановку приложения.


Итог

Собственно, к чему это всё я? А к тому, что написать собственный оператор для kubernetes это довольно простая задача, если есть общее представление о предмете. Очень многие рутинные задачи по выкатке решаются теми или иными коробочными решениями, хотя порой можно написать решение под себя в 300 строк кода.

Конечно, я мог бы взять ArgoCD, пропатчить в нём возможность накатки бандлов и так сделать на каждом кластере. Но зачем? Ведь можно написать довольно простой оператор, который покроет эту простую и специфичную задачу. Более того, вместе с ArgoCD я получу кучу лишнего мусора в кластере, который мне совершенно не нужен (но бизнес за него заплатит полную цену).

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

P.S.: На написание оператора ушло в сумме 8-10 часов вместе с реализацией сборки и деплоя.

Ресурсы

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


  1. vkflare
    31.12.2022 10:19

    Интересно, как люди решают все эти типовые задачи, если платформа не предоставляет разрешения на использование CRD и установку операторов и контроллеров? Хочу наладить декларативный гитопс, при этом щас все висит на gitlabci + набор helm-команд в степах


    1. onegreyonewhite Автор
      31.12.2022 10:31

      Без CRD можно использовать конфигмапы и секреты для хранения настроек. Будет не так красиво, но решение рабочее. Запретить оператор невозможно в принципе. Это же просто сервис, который использует стандартное API. Всё до чего могут дотянуться ваши руки, можно настроить и для оператора.

      Но вообще можно и отдельный сервис реализовать. Но использование нативных возможностей всегда в приоритете.


  1. diafour
    31.12.2022 11:42
    +5

    Про размер можно не волноваться, бинарь на Го с
    подключенным client-go компилится где-то в 30-40мб. Питон под ansible около 100. Не то, чтобы драматическая разница в реалиях 2023.
    Теоретически уменьшить размер можно микропитоном, его пакет для ansible весит меньше bash'а. Но на практике мы пробовали на нём написать хук под shell-operator, оказалось, слишком урезанная версия питона, kopf на нём не заработает скорее всего.


    1. diafour
      31.12.2022 17:25
      +1

      s/ansible/alpine/ :facepalm:


    1. onegreyonewhite Автор
      01.01.2023 20:16

      В принципе, если собрать Python 3.11 из сырцов, без SQLite и некоторых бинарников, которые скорее всего не нужны для kopf и python-kubernetes, то думаю 20-30мб можно получить экономии. Alpine я бы не стал использовать. Есть прецеденты неадекватного поведения. Если ещё и базовый образ изначально полегче собрать (без кучи лишних утилит, которые не нужны оператору), то можно в принципе неплохо так сжать образ. Думаю я ещё много всяких оптимизаций смогу прикрутить, чтобы сделать базовый красивый образ под операторы на Python.