Итак, некоторое время назад я писал статью о том, как мы переехали на 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. Во-вторых, таймер выключается для бандлов, где присутствует определённая аннотация. Это необходимо, чтобы иметь возможность приостановить обновление, когда это необходимо.
Суть логики примерно такая:
Мы получаем обработчик и самые свежие имена манифестов, подходящих под маску (да, я реализовал своими силами 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 - сам проект оператора.
Комментарии (5)
diafour
31.12.2022 11:42+5Про размер можно не волноваться, бинарь на Го с
подключенным client-go компилится где-то в 30-40мб. Питон под ansible около 100. Не то, чтобы драматическая разница в реалиях 2023.
Теоретически уменьшить размер можно микропитоном, его пакет для ansible весит меньше bash'а. Но на практике мы пробовали на нём написать хук под shell-operator, оказалось, слишком урезанная версия питона, kopf на нём не заработает скорее всего.onegreyonewhite Автор
01.01.2023 20:16В принципе, если собрать Python 3.11 из сырцов, без SQLite и некоторых бинарников, которые скорее всего не нужны для kopf и python-kubernetes, то думаю 20-30мб можно получить экономии. Alpine я бы не стал использовать. Есть прецеденты неадекватного поведения. Если ещё и базовый образ изначально полегче собрать (без кучи лишних утилит, которые не нужны оператору), то можно в принципе неплохо так сжать образ. Думаю я ещё много всяких оптимизаций смогу прикрутить, чтобы сделать базовый красивый образ под операторы на Python.
vkflare
Интересно, как люди решают все эти типовые задачи, если платформа не предоставляет разрешения на использование CRD и установку операторов и контроллеров? Хочу наладить декларативный гитопс, при этом щас все висит на gitlabci + набор helm-команд в степах
onegreyonewhite Автор
Без CRD можно использовать конфигмапы и секреты для хранения настроек. Будет не так красиво, но решение рабочее. Запретить оператор невозможно в принципе. Это же просто сервис, который использует стандартное API. Всё до чего могут дотянуться ваши руки, можно настроить и для оператора.
Но вообще можно и отдельный сервис реализовать. Но использование нативных возможностей всегда в приоритете.