В предыдущей части статьи мы разобрались, как построить платформу для развертывания управляемых приложений с единым API и UI. Сегодня мы сделаем следующий шаг — дополним стандартный API Kubernetes своим API-сервером для синхронизации состояния. Рассказываем по порядку, это сделать.

Перед тем, как мы начнем, я бы хотел выразить огромную благодарность Нику Волынкину, который очень сильно доработал и расширил мой изначальный доклад, превратив его в полноценную статью. Фактически, он стал соавтором материала.

Что такое 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.

Способы расширения Kubernetes

Данные API-сервера Kubernetes хранятся в etcd. Сервер работает с ресурсами (подами, нодами и т. д.), сгруппированными по API-группам (к примеру, за Deployments и StatefulSets отвечает группа apps). Менеджер контроллеров следит за согласованностью этих ресурсов (выполняет реконсиляцию).

Чаще всего Kubernetes расширяют через Custom Resource Definitions (CRD). Вы просто создаете API-группу для своих ресурсов (скажем, Databases), а дальше в дело вступает оператор. Через механизм реконсиляции он следит за тем, чтобы текущее состояние кластера соответствовало желаемому.

Например, KubeVirt добавляет ресурс VirtualMachine в API-группу kubevirt.io, а его оператор отвечает за реконсиляцию. Flux CD аналогично поступает с ресурсом HelmRelease, который согласовывается через Helm Controller. С точки зрения Kubernetes, HelmRelease — это просто еще один ресурс.

Но есть и другой путь — посмотрите на metrics-server. Он регистрирует API-группу metrics.k8s.io  и отдает метрики подов и нодов, проксируя запросы к kubelet’у. При обращении к этим ресурсам API-сервер перенаправляет запрос в metrics-server, который собирает актуальные данные напрямую с kubelet’ов.

В Cozystack мы используем аналогичный подход: регистрируем собственную группу apps.cozystack.io и внедряем агрегированный API-сервер для ресурсов вроде Database, VirtualMachine и Kubernetes. Когда вы обращаетесь к ним, запрос попадает на наш API-сервер. Тот считывает соответствующие объекты HelmRelease в кластере и преобразует их в высокоуровневые ресурсы. Например, при запросе списка баз данных в Databases наш сервер находит нужные HelmReleases и форматирует ответ соответствующим образом.

Что такое API Aggregation Layer?

Свой API-сервер мы построили на базе API Aggregation Layer. Эта технология позволяет расширять возможности Kubernetes весьма гибким способом.

Посмотрите на пример ниже. Наш API работает со множеством ресурсов, но соответствующие им CRD в кластере отсутствуют. Эти ресурсы отдаются напрямую нашим API-сервером, запущенным в внутри кластера.

#  kubectl get crd | grep apps.cozystack.io
NAME                                                           CREATED AT

#  kubectl api-resources | grep apps.cozystack.io
NAME                APIVERSION                      NAMESPACED   KIND
                    
bootboxes           apps.cozystack.io/v1alpha1      true         BootBox
buckets             apps.cozystack.io/v1alpha1      true         Bucket
clickhouses         apps.cozystack.io/v1alpha1      true         ClickHouse
etcds               apps.cozystack.io/v1alpha1      true         Etcd
ferretdbs           apps.cozystack.io/v1alpha1      true         FerretDB
httpcaches          apps.cozystack.io/v1alpha1      true         HTTPCache
infos               apps.cozystack.io/v1alpha1      true         Info
ingresses           apps.cozystack.io/v1alpha1      true         Ingress
kafkas              apps.cozystack.io/v1alpha1      true         Kafka
kuberneteses        apps.cozystack.io/v1alpha1      true         Kubernetes
monitorings         apps.cozystack.io/v1alpha1      true         Monitoring
mysqls              apps.cozystack.io/v1alpha1      true         MySQL
natses              apps.cozystack.io/v1alpha1      true         NATS
postgreses          apps.cozystack.io/v1alpha1      true         Postgres
rabbitmqs           apps.cozystack.io/v1alpha1      true         RabbitMQ
redises             apps.cozystack.io/v1alpha1      true         Redis
seaweedfses         apps.cozystack.io/v1alpha1      true         SeaweedFS
tcpbalancers        apps.cozystack.io/v1alpha1      true         TCPBalancer
tenants             apps.cozystack.io/v1alpha1      true         Tenant
virtualmachines     apps.cozystack.io/v1alpha1      true         VirtualMachine
vmdisks             apps.cozystack.io/v1alpha1      true         VMDisk
vminstances         apps.cozystack.io/v1alpha1      true         VMInstance
vpns                apps.cozystack.io/v1alpha1      true         VPN

Как это работает без CRD? Весь секрет в ресурсе APIService. В примере ниже мы зарегистрировали APIService и можем видеть соответствующие Service и Deployment, которые обрабатывают эти запросы.

# kubectl get apiservices.apiregistration.k8s.io | grep apps.cozystack.io
NAME                                       SERVICE                                            AVAILABLE   AGE
v1alpha1.apps.cozystack.io                 cozy-system/cozystack-api                          True        127d

# kubectl get svc -n cozy-system cozystack-api
NAME            TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
cozystack-api   ClusterIP   10.96.253.49   <none>        443/TCP   127d

# kubectl get deployment -n cozy-system cozystack-api
NAME            READY   UP-TO-DATE   AVAILABLE   AGE
cozystack-api   2/2     2            2           127d

Сценарии использования API Aggregation Layer

Рассмотрим области, в которых этот механизм наиболее эффективен.

Во-первых, он позволяет создавать императивные ресурсы. Команды вроде kubectl exec, kubectl logs и kubectl port-forward используют императивную логику. Благодаря этому вы можете зайти в контейнер, прочитать логи или пробросить порты. При этом все эти операции обрабатываются декларативными объектами через API Aggregation Layer. Например, с помощью RBAC можно гибко разграничивать доступ к логам для конкретного пользователя. Когда он запрашивает логи, API-сервер проксирует запрос к kubelet’у, который выполняет необходимые действия и передает ответ.

Во-вторых, можно использовать альтернативные бэкенды вместо etcd. Уровень агрегации позволяет хранить данные API в Postgres, Prometheus или в любом другом кастомном хранилище.

В-третьих, он позволяет реализовать эфемерные ресурсы, существующие только в рамках запроса. Отличный пример — команда kubectl auth whoami, которая создает временный объект SelfSubjectReview:

kind: SelfSubjectReview
apiVersion: authentication.k8s.io/v1
metadata:
  creationTimestamp: null
status:
  userInfo: {}

Сервер аутентификации отвечает на такой запрос, добавляя нужную информацию в этот объект:

kind: SelfSubjectReview
apiVersion: authentication.k8s.io/v1
metadata:
  creationTimestamp: "2025-08-30T15:45:47Z"
status:
  userInfo:
    username: admin
    groups:
      - system:masters
      - system:authenticated

А kubectl приводит данные из него к следующему виду:

# kubectl auth whoami
ATTRIBUTE                                           VALUE
Username                                            admin
Groups                                              [system:masters system:authenticated]

Наконец, такой слой агрегации обеспечивает полный контроль над конвертацией, валидацией и форматированием вывода.

Однако у API Aggregation Layer есть две характерные проблемы: нестабильные бэкенды и долгие ответы. Если его бэкенд «приляжет», API Kubernetes начнет возвращать ошибки. Чтобы этого избежать, лучше использовать паттерн «контроллер»: пусть контроллер сам следит за внешней системой и постоянно сообщает об ее состоянии в Kubernetes, обеспечивая надежную работу API даже при временной недоступности.

Собираем API-сервер для Cozystack

Вернемся к основной задаче: нам требовалась динамическая регистрация ресурсов, позволяющая клиентам развертывать Custom Resources через RBAC. API Aggregation Layer оказался здесь как нельзя кстати. Насколько нам известно, Cozystack — первый проект, адаптировавший этот паттерн под такие нужды.

В то время как большинство проектов на Go полагаются на кодогенерацию, мы предпочли полностью динамическую реализацию API-сервера. Это избавило нас от необходимости перекомпилировать код при добавлении новых типов (kind) ресурсов — они генерируются сервером на лету. Создать такой сервер на Go довольно сложно, но гибкость решения оправдала все усилия.

Чтобы быстро запустить свой API-сервер, можно взять за основу один из двух проектов:

  • apiserver-builder-alpha: всё еще в альфе, устаревший и заброшенный.

  • sample-apiserver: живой пример API-сервера с хранилищем на etcd.

Мы взяли за основу sample-apiserver, форкнули его и создали общий ресурс kind под названием Application.

type Application struct {
  AppVersion string
  metav1.TypeMeta
  metav1.ObjectMeta
  
  Spec   *apiextensionsv1.JSON
  Status ApplicationStatus
}

Далее заменили etcd-хранилище на клиент k8s.io/client-go/rest. Это дало нашему серверу возможность взаимодействовать с кластером напрямую, используя ресурсы Kubernetes в качестве хранилища данных.

Также мы разработали собственный загрузчик конфигураций. API-сервер считывает конфигурационный файл, который сопоставляет kind’ы ресурсов верхнего уровня с Helm-чартами. Например, kind: Postgres превращается в чарт postgres из репозитория, указанного в sourceRef. Все созданные при этом объекты HelmRelease получают префикс postgres-.

resources:
  - application:
        kind: Postgres
        singular: postgres
        plural: postgreses
    release:
        prefix: postgres-
        labels:
          cozystack.io/ui: "true"
        chart:
          name: postgres
          sourceRef:
            kind: HelmRepository
            name: cozystack-apps
            namespace: cozy-public

Последним препятствием стала генерация OpenAPI-схем. Стандартные инструменты Go для обработки OpenAPI-спецификаций ресурсов не работают, когда ресурсы являются динамическими.

Проблему удалось решить с помощью функции PostProcessSpec. С ее помощью мы дополнили спецификацию данными о кастомных типах, которые API-сервер считывает из файла конфигурации:

OpenAPIConfig.PostProcessSpec =
  func(swagger *spec.Swagger) (*spec.Swagger, error) {}

Проверить, что всё работает, можно простой командой kubectl get --raw /openapi/v2. Она вернет спецификацию, начало которой выглядит так:

{
  "swagger": "2.0",
  "info": {
    "title": "Kubernetes",
    "version": "1.33"
  },
  "paths": {
    "/api/": {
      "get": {
        "description": "get available API versions",
        "consumes": [
          "application/json",
          "application/yaml",
          "application/vnd.kubernetes.protobuf"
        ],
        "produces": [
          "application/json",
          "application/yaml",
          "application/vnd.kubernetes.protobuf"
        ],
        "responses": {
          "200": { },
          "401": { }
        }
      }
    }
  }
}

Организуем доступ к приложениям

Единый API для деплоя — это только половина дела. Пользователям еще нужно как-то получать учетные данные для подключения к своим сервисам. Кому-то нужен kubeconfig для нового кластера, кому-то — логины-пароли и IP-адреса для работы с БД.

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

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: kubernetes-example-dashboard-resources
rules:
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    resourceNames:
      - kubernetes-example
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames:
      - kubernetes-example-admin-kubeconfig
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["services"]
    resourceNames:
      - kubernetes-example
    verbs: ["get", "list", "watch"]
  - apiGroups: ["cozystack.io"]
    resources: ["workloadmonitors"]
    resourceNames:
      - kubernetes-example
      - kubernetes-example-md0
    verbs: ["get", "list", "watch"]

Единый формат представления

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

Проблема в том, что операторы Kubernetes сильно отличаются и создают поды по-разному. Чтобы в интерфейсе все выглядело одинаково, мы внедрили Workloads — универсальную абстракции, у которой есть свои type и kind.

Кроме того, мы реализовали контроллер WorkloadMonitor. По принципу работы он схож с ServiceMonitor или PodMonitor: ищет поды через labelSelector, классифицируя рабочие нагрузки по spec.type и spec.kind. Таким образом, одно приложение может иметь несколько типов нагрузок — например, реплики и сентинелы для Redis или узлы control-plane и worker для Kubernetes.

WorkloadMonitor следит за минимальным количеством реплик, необходимых для работы сервиса. Он находит нужные поды и транслирует их состояние пользователю. Вот как выглядит описание WorkloadMonitor для узлов control-plane и worker Kubernetes:

apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
  name: kubernetes-example
spec:
  kind: kubernetes
  type: control-plane
  minReplicas: 1
  replicas: 2
  selector:
    kamaji.clastix.io/component: deployment
    kamaji.clastix.io/name: kubernetes-example
  version: 0.15.2
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
  name: kubernetes-example-md0
spec:
  kind: kubernetes
  type: worker
  minReplicas: 0
  replicas: 2
  selector:
    cluster.x-k8s.io/cluster-name: kubernetes-example
    cluster.x-k8s.io/deployment-name: kubernetes-example-md0
    cluster.x-k8s.io/role: worker
  version: 0.15.2

Посмотреть на эти ресурсы в консоли можно с помощью kubectl get workloadmonitors. Команда покажет сводную информацию по каждому workload’у: какой у него kind, type, версия, сколько реплик активно (и сколько необходимо), а также статус:

$ kubectl get workloadmonitors   
NAME                     KIND         TYPE            VERSION   REPLICAS   MINREPLICAS   AVAILABLE   OBSERVED   OPERATIONAL
kubernetes-example       kubernetes   control-plane   0.18.1    2          1             2           2          true
kubernetes-example-md0   kubernetes   worker          0.18.1               0             2           2          true
...

На основе WorkloadMonitors создаются и сами ресурсы Workloads (посмотреть их можно с помощью похожей команды). Для каждого пода отображается его kind, type, потребление ресурсов и статус готовности в поле operational.

$ kubectl get workloads | grep kubernetes      
NAME                                                         KIND         TYPE            CPU     MEMORY       OPERATIONAL                                                                
pod-kubernetes-example-67b885b9bc-htwff                      kubernetes   control-plane   1250m   1280Mi       true
pod-kubernetes-example-67b885b9bc-kfnlv                      kubernetes   control-plane   1250m   1280Mi       true
...

Реализуем Billing API в Cozystack

Каждому провайдеру необходим надежный механизм тарификации управляемых сервисов. Однако биллинг по определению сложен: приложения развертываются по-разному и состоят из множества компонентов, но при этом должны тарифицироваться единообразно.

Поскольку у нас уже были данные из Workloads, грех было ими не воспользоваться. Мы разработали контроллер, который берет эти данные и превращает их в метрики VictoriaMetrics. Этот контроллер и есть наш Billing API: пользователи теперь могут напрямую обращаться к Kubernetes API и получать данные о потреблении ресурсов за определенный период.

Вот как он работает. Ресурсы API находятся в группе billing.aenix.io:

$ kubectl api-resources | grep billing.aenix.io
NAME                             APIVERSION                                 NAMESPACED   KIND
usagereports                     billing.aenix.io/v1alpha1                  false        UsageReport

В этой группе зарегистрирован APIService:

$ kubectl get apiservices.apiregistration.k8s.io | grep billing.aenix.io
NAME                             SERVICE                                            AVAILABLE   AGE
v1alpha1.billing.aenix.io        aenix-enterprise-system/billing-apiserver          True        106d

В соответствующем неймспейсе работают сервис и поды, обрабатывающие задачи биллинга. Вот как в кластере выглядят APIServer, контроллер сбора данных и хранилище VictoriaMetrics:

$  kubectl get svc -n aenix-enterprise-system billing-apiserver
NAME                TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
billing-apiserver   ClusterIP   10.96.2.230   <none>        443/TCP   106d

$ kubectl get pod -n aenix-enterprise-system
NAME                                  READY   STATUS    RESTARTS      AGE
billing-apiserver-588d45b7fb-kkhfp    1/1     Running   0             29d
billing-apiserver-588d45b7fb-nbksq    1/1     Running   0             13d
billing-controller-6669b8ccfc-jg4kk   1/1     Running   0             9d
billing-controller-6669b8ccfc-ng276   1/1     Running   0             9d
billing-vmsingle-0                    1/1     Running   0             13d
billing-vmsingle-1                    1/1     Running   0             29d
vmagent-billing-c79ccd9cf-hhvd4       2/2     Running   1 (32d ago)   43d
vmagent-billing-c79ccd9cf-j6nn5       2/2     Running   3 (32d ago)   43d

Чтобы узнать, сколько ресурсов они потребили, пользователи создают UsageReport — эфемерный объект, существующий только в рамках одного запроса.

В блоке query задаются временные рамки, тенант (namespace) и, опционально, фильтр по workload’ам:

apiVersion: billing.aenix.io/v1alpha1
kind: UsageReport
query:
  tenant: tenant-internal
  workload: ""
  endTimestamp: "2025-03-19T20:25:38Z"
  startTimestamp: "2025-03-19T19:25:38Z"

Как только запрос улетает в Kubernetes, наш API-сервер запрашивает данные из VictoriaMetrics и возвращает тот же объект с заполненным разделом report. В нем отражено потребление vCPU, диска и памяти в виде почасовых метрик. Так как данные разбиты по типам нагрузки, провайдеры получают гибкие возможности тарификации — например, выставлять разные счета за использование БД Postgres и обычных нод Kubernetes.

apiVersion: billing.aenix.io/v1alpha1
kind: UsageReport
query:
  tenant: tenant-internal
  workload: ""
  endTimestamp: "2025-03-19T20:25:38Z"
  startTimestamp: "2025-03-19T19:25:38Z"
report:
  consumers:
    - kind: clickhouse
      type: clickhouse
      tenant: tenant-internal
      workload: chi-clickhouse-clickhouse-clickhouse-0-0-0
      endTimestamp: "2025-03-19T20:25:38Z"
      startTimestamp: "2025-03-19T19:25:38Z"
      consumptions:
      - quantity: 0.6
        resourceType: vCPUHours
      - quantity: 2
        resourceType: EphemeralStorageGiBHours
      - quantity: 0.1875
        resourceType: MemoryGiBHours
    - kind: kubernetes
      ...

Заключение

В этой второй статье мы перешли от теории к практике и показали, как работает расширение Kubernetes. С помощью API Aggregation Layer можно делать полноценные API даже без CRD, просто превращая обычные объекты кластера в удобные высокоуровневые ресурсы. В Cozystack мы по этой схеме собрали два API-сервера:

  • Сервер для деплоя. Это единая точка входа для развертывания managed-приложений. Пользователи работают с привычными сущностями вроде Postgres, Redis, Kubernetes, VirtualMachine. Внутри сервер превращает их в объекты HelmRelease, передавая всю работу по управлению жизненным циклом операторам, но при этом следит за синхронизацией высокоуровневых ресурсов и HelmRelease’ов.

  • Сервер для биллинга. Он собирает телеметрию из Workloads и сохраняет ее в VictoriaMetrics в виде временных рядов. Дальше через UsageReport платформенные команды получают статистику по потреблению ресурсов в разрезе тенантов и конкретных нагрузок за нужный период.

Такой подход принес нам три важных результата:

  • Единая панель управления. Работа ведется со стандартными kind’ами ресурсов, которые API-сервер превращает в HelmReleases. Операторы управляют жизненным циклом, а мы получаем надежный API с понятными параметрами.

  • Stateless-синхронизация. Текущее состояние кластера выводится по запросу — нет нужды держать параллельную базу данных. Это исключает риск рассинхронизации контроллеров и обеспечивает двустороннюю прозрачность: любые обновления ресурсов сразу видны.

  • Единые стандарты мониторинга и биллинга. WorkloadMonitor и Workloads унифицируют статусы от разных операторов, а Billing API формирует отчеты о потреблении ресурсов с привязкой к тенантам и нагрузкам. Эти данные доступны через стандартные verb’ы Kubernetes, RBAC и инструменты.

Следующий чек-лист поможет понять, подходит ли вам API Aggregation Layer:

  • Требуется ли вам нативный интерфейс Kubernetes (kubectl, RBAC, аудит) для данных, не хранящихся в etcd?

  • Получится ли сделать API-сервер stateless и быстрым, переложив основную нагрузку на внешние компоненты?

  • Поможет ли единый интерфейс API снизить когнитивную нагрузку на пользователей, работающих с разнородными бэкендами?

  • Реально ли реализовать защитные механизмы (схемы, значения по умолчанию, admission-контроль), чтобы тенанты могли безопасно работать с вашей платформой?

Если на большинство пунктов ответили «да» — смело берите Aggregation Layer! Мы в Cozystack так и сделали: получили стройный API, переложили сложную работу на операторов и организовали прозрачный биллинг. Нам не пришлось править код операторов или придумывать параллельный (ненужный) control plane. В этом и состоит суть «платформизации» Kubernetes: единый стабильный API, набор специализированных бэкендов и удобство работы пользователей без риска для всей системы.

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

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