Я много лет мечтал построить свою облачную платформу — и если раньше я пытался реализовать эту мечту в рамках нескольких компаний и проектов, то в последние годы я запустил собственный проект, 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. Будет интересно!

Присоединяйтесь к нашему комьюнити:

Читайте также: