В предыдущей части статьи мы разобрались, как построить платформу для развертывания управляемых приложений с единым 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, набор специализированных бэкендов и удобство работы пользователей без риска для всей системы.
Присоединяйтесь к нашему комьюнити
Читайте также
Platformize it! Часть 1: Платформенный подход, ядро современной платформы и API
How Cozystack Was Born: The Philosophy Behind Its Architecture
Как мы создавали динамический Kubernetes API server для API Aggregation Layer в Cozystack
Азбука: FluxCD — перенастраиваем kubernetes с одного репозитория на другой
