Я много лет мечтал построить свою облачную платформу — и если раньше я пытался реализовать эту мечту в рамках нескольких компаний и проектов, то в последние годы я запустил собственный проект, Cozystack. В своей статье я расскажу о нашем опыте и о том, как, на мой взгляд, стоит подходить к созданию современной инфраструктурной платформы, в основе которой — Kubernetes и Kubernetes API. Я разберу платформенный подход: что такое платформы, как они работают, кому нужны и как построить свою. А также сравню разные архитектуры для платформ, расскажу, почему мы остановились именно на K8s как на ключевой технологии, и покажу, как мы собрали на его основе production-решение.

Это первая часть из цикла «Platformize it!» Прочитав весь цикл, вы сможете построить собственную надежную и современную платформу — используя описанные мной паттерны, просто оглядываясь на них или даже полностью их отвегнув. В любом случае это будет проще, чем строить платформу с нуля — да и всегда просто интересно заглянуть во внутреннюю кухню других проектов и понять логику их построения.
Перед тем, как мы начнем, я бы хотел выразить огромную благодарность Нику Волынкину, который очень сильно доработал и расширил мой изначальный доклад, превратив его в полноценную статью. Фактически, он стал соавтором материала.
Что такое Cozystack
Cozystack — это Open Source-платформа, которая позволяет строить облако на bare metal для быстрого развертывания managed Kubernetes, database as a service, applications as a service и виртуальных машин на базе KubeVirt. В рамках платформы можно по клику разворачивать Kafka, FerretDB, PostgreSQL, Cilium, Grafana, Victoria Metrics и другие сервисы. Кроме того, платформа поддерживает работу с GPU в виртуальных машинах и K8s-кластерах. Cozystack — проект CNCF Sandbox, существует под лицензией Apache 2.0.
Клиенты и их managed-приложения
У Cozystack — много адоптеров, и каждый из них хочет запускать собственные наборы приложений: базы данных, очереди, кеши, — и пользователи хотят получить их в виде управляемых сервисов. В результате возникает довольно длинный список того, что нужно поддерживать: PostgreSQL, Kafka, Redis, ClickHouse, S3-бакеты и многое другое. Вдобавок ко всему, всем нужны управляемые кластеры Kubernetes и виртуальные машины (ВМ) для развертывания собственных приложений.
Именно отталкиваясь от этих требований, я когда-то и начал создавать комплексное решение — платформу, которая «из коробки» предоставляет заранее настроенный набор управляемых сервисов.

Проблема выбора технологического стека
Прежде чем приступить к созданию платформенных сервисов, нужно подготовить для них инфраструктуру. Необходимо позаботиться о таких базовых вещах, как хранилище, заказ серверов, сети, виртуализация и мониторинг. А поскольку эти сервисы хранят состояние (stateful), понадобятся еще и надежные механизмы автоматического развертывания и отказоустойчивости на случай сбоев. По факту, вы начинаете с инфраструктурного стека, затем переходите к платформенному, и только потом добираетесь до стека клиентских приложений.
В итоге вырастает целая «башня» из технологий:

К тому же, создание и поддержка собственной инфраструктуры/платформы требует крутой экспертизы в широком спектре технологий. Придется поддерживать все компоненты, при этом каждый новый слой добавляет своих багов, сложностей и рисков при обновлениях. На поддержку всего этого уйдет уйма сил, иначе вся башня просто развалится.
Как этого избежать? Можно отдать часть слоев стека на аутсорс, используя модель «как услуга» (as-a-service). На схеме ниже — разные варианты того, как можно поделить ответственность за платформенный стек.

On-site: Самый сложный вариант, где вы отвечаете за всё — от инфраструктуры до сервисов. Это то, с чего мы начинали.
Infrastructure as a service (IaaS): Провайдер берет на себя сети, хранилище, серверы и виртуализацию. Вы работаете с виртуальными машинами, но всё ещё отвечаете за ОС и инфраструктурные сервисы.
Platform as a service (PaaS): Управление инфраструктурой скрыто; можно сфокусироваться на своих приложениях и данных.
Software as a service (SaaS): Не нужно управлять приложениями — вы получаете готовый продукт (например, Google Drive или Slack). Часто это и есть конечная цель для клиентов.
Однако большинство клиентов хочет третий вариант: платформу, где они могут разворачивать свои приложения, а вы, как вендор, разбираетесь со всей сложностью базовой инфраструктуры и ее поддержкой.
Варианты платформенного стека
Теперь нужно позаботиться об инфраструктуре и самой платформе. Давайте разберем, из чего можно построить платформу, и какие у каждого варианта есть плюсы и минусы.
OpenStack часто приходит на ум первым. Но когда начинаешь с ним работать, оказывается, что поддерживать его не так просто из-за сложной архитектуры и кучи асинхронных API. Для этого понадобится целая команда инженеров.

Docker — другой популярный вариант. У него огромная библиотека готовых образов, которые легко запускаются командой docker run <image>. Но это хорошо работает для одиночных сервисов, а вот инструментов для управления приложениями сразу на нескольких узлах и обеспечения их высокой доступности в Docker нет. А ведь именно этого и ждут клиенты от managed-платформы.
Cloud Foundry был очень популярен, пока не появился Kubernetes. Сегодня им мало кто пользуется, а значит, и инженеров, которые в нем разбираются (или хотят разбираться), тоже немного. Поэтому делать на него ставку рискованно — найти специалистов будет непросто.
Наконец, Kubernetes. Он предлагает кучу фичей почти под любые платформенные задачи, став де-факто стандартом для запуска рабочих нагрузок. В нем реализованы современные подходы — например, цикл согласования (reconciliation loop). А проекты вроде KubeVirt позволяют управлять ВМ в стиле Kubernetes — речь, в том числе, о миграции при падении ноды. К тому же, его понятный RESTful API — отличная основа для создания кастомных платформенных сервисов.
И вишенка на торте — для Kubernetes есть много готовых операторов. Оператор — это, по сути, контроллер внутри Kubernetes, который управляет жизненным циклом прил��жения. Операторы берут на себя всю рутинную работу: развертывание, обновление, репликацию, восстановление из бэкапа и прочее. Причем делают они это самым правильным способом, потому что в них заложен опыт разработчиков конкретного софта. Пользователям больше не нужно возиться с низкоуровневыми задачами — они просто работают с удобным декларативным API. Пользователь описывает желаемое состояние, а оператор приводит в него систему.

Вот несколько операторов, которые мы используем в Cozystack. Каждый из них — оператор как минимум 3-го уровня:
Kubernetes можно расширять через Custom Resource Definitions (CRD), давая операторам возможность управлять всем жизненным циклом приложения. KubeVirt из списка выше — оператор для виртуалок — отличный тому пример.
В общем, на наш взгляд, Kubernetes — лучший выбор для платформенного стека, поэтому мы и взяли его за основу в Cozystack.
Управляем операторами Kubernetes: проблемы и решения
Далее клиентам нужно дать возможность самим создавать нужную им инфраструктуру без помощи администратора платформы. А для этого необходим унифицированный и понятный API.
И тут мы упираемся в классический парадокс Kubernetes: с одной стороны, у него крутые возможности, с другой — это целый зоопарк технологий. Каждый разработчик пишет свой оператор как ему вздумается, и в итоге получаем хаос из разношерстных API.
Посмотрите на примеры спецификаций для Kafka, Postgres, MariaDB и ClickHouse ниже. Каждый оператор отлично справляется со своей задачей, но их API-схемы не имеют ничего общего:
Kafka
apiVersion: kafka.strimzi.io/v1beta2 kind: Kafka metadata: name: kafka-example spec: kafka: version: 3.9.0 replicas: 3 config: offsets.topic.replication.factor: 3 transaction.state.log.replication.factor: 3 transaction.state.log.min.isr: 2 default.replication.factor: 3 min.insync.replicas: 2 inter.broker.protocol.version: "3.9" zookeeper: { } entityOperator: { }
Cloud-Native PostgreSQL
apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: postgres-example spec: instances: 3 postgresql: parameters: max_worker_processes: "60" pg_hba: - host all all all md5 primaryUpdateStrategy: unsupervised storage: size: 1Gi
MariaDB
apiVersion: k8s.mariadb.com/v1alpha1 kind: Database metadata: name: mariadb-example spec: mariaDbRef: name: mariadb characterSet: utf8 collate: utf8_general_ci cleanupPolicy: Delete requeueInterval: 30s retryInterval: 5s
ClickHouse
apiVersion: clickhouse.altinity.com/v1 kind: ClickHouseInstallation metadata: name: clickhouse-example spec: configuration: clusters: - name: "shard1-repl2" layout: shardsCount: 1 replicasCount: 2
Отсюда вытекает типичная для всех платформ проблема: API у операторов разные, своих интерфейсов у них нет, и расширять их затруднительно. Что еще хуже — давать пользователям доступ к «сырым» Custom Resources операторов попросту опасно.
Задача создателя платформы — дать пользователям возможность заказывать сервисы через Custom Resources, которые подхватят операторы. Но при этом как-то нужно контролировать, какие поля эти пользователи могут менять. Как вы понимаете, разрешить пользователям подменять базовые образы или менять лимиты ресурсов — плохое решение
Давайте разберемся, что можно с этим сделать.
Один из вариантов — настроить политики через Kyverno или OPA. Но это решит проблему лишь отчасти: можно запретить менять конкретные поля, но более тонкая настройка слишком сложна и ненадежна.
Другой вариант — создать свои, более высокоуровневые API-объекты и написать отдельный оператор для них. Но тогда придется делать CRD под каждый тип ресурсов, настраивать кодогенерацию и писать логику для синхронизации состояний. Объем работы на этом этапе становится просто космическим — ее слишком много.
Также можно использовать Helm-чарты. Их создавать проще, чем операторы, потому что они просто упаковывают готовые декларативные объекты Kubernetes, и для развертывания приложения не нужно писать код. Пользователи получают простые и понятные чарты с единым интерфейсом. Однако у Helm нет полноценного управления жизненным циклом. Он способен развернуть ресурсы в кластере, но не может гарантировать их самовосстановление — например, не поднимет реплику PostgreSQL, если нода упадет.
Как видите, идеального решения нет, и без операторов на бэкенде всё равно не обойтись.
Гибридный подход к управлению приложениями
Cozystack исповедует гибридный подход: Helm выступает в качестве API для пользователя, а операторы управляют полным жизненным циклом на бэкенде. Пользователи задают параметры и развертывают Helm-чарты в Kubernetes. Эти чарты, в свою очередь, создают ресурсы различных типов (kind:), которыми управляют операторы. Файл values.yaml выступает в качестве API для пользователя, открывая только те параметры, которые разрешено изменять тенанту.

За пользовательскую часть в этой схеме отвечает Flux CD. Это Helm-оператор для Kubernetes, который добавляет свой Custom Resource под названием HelmRelease. Пользователи описывают свои развертывания в HelmRelease, а Flux CD доносит эти изменения до кластера.
Например, чтобы развернуть виртуальную машину, пользователь создает ресурс kind: HelmRelease. Flux CD применяет соответствующий чарт, а тот уже создает ресурс kind: VirtualMachine, которым управляет оператор KubeVirt. С Kafka та же история: начинаете с kind: HelmRelease, а получаете kind: Kafka под управлением оператора Kafka. Так мы получаем единый API и простой и предсказуемый процесс развертывания, который не зависит от конкретного приложения:

Рассмотрим два примера ниже: один для Redis и один — для MySQL. Обратите внимание на пару моментов — что у них общего и чем они отличаются:
apiVersionиkind: HelmReleaseговорят нам, что это Custom Resources от Flux CD.Каждый ресурс указывает на свой чарт в поле
spec.chart.spec.chart.Оба ссылаются на один и тот же репозиторий в
spec.chart.spec.sourceRef— это репозиторий по умолчанию для приложений Cozystack, доступный по HTTP.В обоих есть блок
spec.values. Его содержимое — это, по сути, значения изvalues.yamlконкретного чарта.
Redis
apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: redis-some spec: chart: spec: chart: redis reconcileStrategy: Revision sourceRef: kind: HelmRepository name: cozystack-apps namespace: cozy-public values: authEnabled: true external: false replicas: 2 resourcesPreset: nano size: 1Gi storageClass: ""
MySQL
apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: mysql-some spec: chart: spec: chart: mysql reconcileStrategy: Revision sourceRef: kind: HelmRepository name: cozystack-apps namespace: cozy-public values: external: false replicas: 2 resourcesPreset: nano size: 10Gi storageClass: ""
Пользовательский интерфейс и API
Итак, гибридная архитектура дала нам единый API для развертывания приложений, хотя изначально у их операторов были совершенно разные API-схемы. Мы разобрались, что там под капотом. Теперь давайте посмотрим, как с этим работать. Единый API позволяет свести все приложения в общий дашборд — с названием, метаданными и иконкой для каждого Helm-чарта. Раньше в Cozystack для этого использовали Kubeapps, сейчас же переезжаем на новый фронтенд (об этом расскажем в следующих постах).

Когда пользователь хочет развернуть приложение, он создает один YAML-файл с нужными параметрами следующего вида:
# virtual machine running: true systemDisk: image: ubuntu storage: 5Gi storageClass: replicated resources: cpu: 1 sockets: 1 memory: 1024M # ...
Дашборд может даже отрисовать эти параметры в визуальном редакторе, так как они соответствуют спецификации OpenAPI.
Итак, давайте кратко резюмируем ключевые моменты гибридной архитектуры, которую мы разработали для Cozystack:
Для описания приложений используются Helm-чарты.
Жизненным циклом приложений управляют операторы Kubernetes.
Единый дашборд обеспечивает единообразное визуальное представление для всех приложений.
Такая система идеально вписалась в наш типичный кейс: интернет-провайдер, который строит бэкенд для своих сервисов. У таких компаний обычно есть сайт, биллинг и куча других приложений. Главное здесь то, что провайдеры — единственные пользователи своей же платформы, а это классический случай single-тенанта.
Открываем доступ обычным пользователям
В ранних версиях Cozystack доступ был ограничен только администраторами. Однако по мере развития проекта возник запрос на изменения: клиенты хотели, чтобы и у конечных пользователей был доступ к платформе. Возникли две новые проблемы:
Доступ на основе ролей (RBAC). Как позволить пользователям развертывать приложения, не предоставляя им административных привилегий?
Понятное и удобное представление ресурсов. Пользователи хотят видеть привычные kind’ы Kubernetes вроде
PostgresилиRedis, а не какой-то непонятный HelmRelease.
Когда пользователей много, давать всем доступ к управлению кластером нельзя. Проблема в том, что чарты разные, но тип у них один и тот же — kind: HelmRelease. То есть RBAC-правила не могут отличить HelmRelease для виртуалки от такого же ресурса для базы Postgres. А провайдерам часто нужна именно такая гибкая, гранулярная изоляция.
В итоге мы добавили высокоуровневые API-объекты, которые «копируют» kind'ы базовых ресурсов (Redis, Postgres, VirtualMachine, Kubernetes и т. д.). Теперь можно настроить гранулярные политики RBAC, которые, например, разрешают доступ к Redis и Postgres, но запрещают его для VirtualMachine и Kubernetes.

Синхронизация состояния
Теперь нужно было каким-то образом синхронизировать состояние высокоуровневых API-объектов с теми HelmRelease, которые всё это деплоят. Давайте разберем два варианта, которые рассматривались.
Стандартный путь для Kubernetes — написать контроллер или оператор, который хранит настройки в Custom Resources на их основе создает HelmRelease. Но здесь есть два минуса:
Появляется stateful-компонент, а это лишняя сложность и потенциальная точка отказа. Нам же хотелось, чтобы система была максимально простой.
Данные ходят только в одну сторону: если вручную поправить HelmRelease, изменения не подтянутся в стоящий над ним API-объект.
Вместо этого мы решили построить stateless API-сервер. Он использует уже созданные в кластере HelmRelease'ы как базу данных и «на лету» собирает из них высокоуровневые ресурсы. Эти ресурсы живут только в момент обращения, но для пользователя они выглядят и работают как обычные объекты Kubernetes. Так уходят проблемы первого способа:
Система остается простой, так как сервер не хранит состояние.
Работает двусторонняя синхронизация: изменения в ресурсах и в HelmRelease'ах видны сразу.
Конечно, написать свой API-сервер — задача непростая. О том, как расширять Kubernetes API и что именно мы сделали, расскажем в следующей статье цикла. А пока давайте посмотрим, как все это выглядит для пользователя.

Как наш API выглядит на практике
Итак, как же работает наш новый API? Давайте разберем процесс развертывания инстанса Redis. Пользователь создает ресурс, структура которого определяется чартом (в первом манифесте). Он автоматически транслируется в HelmRelease (во втором манифесте). Обратите внимание на общие закономерности:
Тип
kind: Redisсопоставляется сspec.chart.spec.*, где указаны имя чарта и репозиторий.Имя из
metadata.name: example превращается вredis-example.Версия приложения из
appVersionидет в версию чартаspec.chart.spec.version.Все параметры из
specпопадают прямиком вspec.values.
Пример с Redis
Ресурс, созданный пользователем:
apiVersion: apps.cozystack.io/v1alpha1 appVersion: 0.6.0 kind: Redis metadata: name: example spec: authEnabled: true external: false replicas: 2 resourcesPreset: nano size: 1Gi storageClass: ""
Получившийся HelmRelease:
apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: redis-example spec: chart: spec: chart: redis reconcileStrategy: Revision sourceRef: kind: HelmRepository name: cozystack-apps namespace: cozy-public version: 0.6.0 values: authEnabled: true external: false replicas: 2 resourcesPreset: nano size: 1Gi storageClass: ""
Один и тот же паттерн применяется ко всему, даже к Kubernetes-кластерам тенантов. В Helm-чарты упакованы все манифесты для запуска по схеме «Kubernetes-в-Kubernetes». Пользователи просто создают высокоуровневый ресурс kind: Kubernetes, а он уже генерирует HelmRelease для Flux CD. Тот развертывает всё остальное — никакие дополнительные контроллеры не нужны.
Пример с виртуальной машиной
Тот же процесс, но уже для VirtualMachine:
Ресурс, созданный пользователем:
apiVersion: apps.cozystack.io/v1alpha1 appVersion: 0.7.0 kind: VirtualMachine metadata: name: example spec: instanceProfile: ubuntu instanceType: u1.xlarge running: true sshKeys: - ssh-rsa AAAAAA... systemDisk: image: ubuntu storage: 110Gi storageClass: replicated
Получившийся HelmRelease:
apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: virtual-machine-example spec: chart: spec: chart: virtual-machine reconcileStrategy: Revision sourceRef: kind: HelmRepository name: cozystack-apps namespace: cozy-public version: 0.7.0 values: instanceProfile: ubuntu instanceType: u1.xlarge running: true sshKeys: - ssh-rsa AAAAAA... systemDisk: image: ubuntu storage: 110Gi storageClass: replicated
Пример с Kubernetes
И, наконец, пример с Kubernetes-кластером тенанта:
Ресурс, созданный пользователем:
apiVersion: apps.cozystack.io/v1alpha1 appVersion: 0.15.2 kind: Kubernetes metadata: name: example spec: host: "" nodeGroups: md0: minReplicas: 0 maxReplicas: 10 ephemeralStorage: 20Gi instanceType: u1.medium role: - ingress-nginx storageClass: "replicated"
Получившийся HelmRelease:
apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: kubernetes-example spec: chart: spec: chart: kubernetes reconcileStrategy: Revision sourceRef: kind: HelmRepository name: cozystack-apps namespace: cozy-public version: 0.15.2 values: host: "" nodeGroups: md0: minReplicas: 0 maxReplicas: 10 ephemeralStorage: 20Gi instanceType: u1.medium role: - ingress-nginx storageClass: "replicated"
Заключение
Давайте вернемся к задаче, с которой все начиналось — той, с которой мы столкнулись в Cozystack и с которой сталкивается любой сервис-провайдер. Мы хотели создать платформу с унифицированным API и пользовательским интерфейсом для развертывания и управления приложениями. Этой платформе также требовался надежный бэкенд для управления жизненным циклом, четкая обратная связь о состоянии рабочих нагрузок и механизмы защиты на основе RBAC.
Вот как наше решение на базе Kubernetes и Helm справляется с этими задачами:
Helm-чарты описывают приложения как пакеты: в них собраны нужные Custom Resources и все объекты Kubernetes. После деплоя за дело берутся операторы и управляют жизненным циклом.
Высокоуровневые Custom Resources дают пользователям понятный и безопасный интерфейс. В них уже встроены средства мониторинга, «защита от ошибок» и единая структура данных.
Единый UI и API. В дашборде все приложения выглядят одинаково: у каждого есть метаданные, иконка и статус в реальном времени. К этим же ресурсам можно обращаться через kubectl и Kubernetes REST API. То есть привычный (типичный для Kubernetes) интерфейс получают и люди, и скрипты автоматизации.
В следующей статье этого цикла мы расскажем о том, как написали свой API-сервер для синхронизации состояния между Helm-чартами и ресурсами HelmRelease. Также разберем вопросы расширения возможностей Kubernetes через API Aggregation Layer. Будет интересно!
Присоединяйтесь к нашему комьюнити:
Читайте также:
