This article is more than one year old. Older articles may contain outdated content. Check that the information in the page has not become incorrect since its publication.

Как мы построили динамический Kubernetes API server для слоя агрегации API в Cozystack

Как мы построили динамический Kubernetes API server для слоя агрегации API в Cozystack

Привет! Я Андрей Квапиль, но в сообществах, посвящённых Kubernetes и cloud-native инструментам, вы можете знать меня как @kvaps. В этой статье я хочу рассказать, как мы реализовали собственный расширенный API server в open-source PaaS-платформе Cozystack.

Kubernetes по-настоящему восхищает меня своими мощными возможностями расширения. Вероятно, вы уже знакомы с концепцией контроллера и фреймворками вроде kubebuilder и operator-sdk, которые помогают его реализовать. Если коротко, они позволяют расширять кластер Kubernetes, определяя пользовательские ресурсы (CRD) и написав дополнительные контроллеры, которые реализуют вашу бизнес-логику для согласования и управления такими ресурсами. Этот подход хорошо задокументирован, и в интернете доступно множество информации о том, как разрабатывать собственные операторы.

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

В этой статье я рассмотрю слой агрегации API, типы задач, для которых он хорошо подходит, случаи, когда он может быть менее уместен, и то, как мы использовали эту модель для реализации собственного расширенного API server в Cozystack.

Что такое слой агрегации API?

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

Итак, слой агрегации позволяет написать собственный расширенный API server, легко интегрировать его в Kubernetes и напрямую обрабатывать запросы к ресурсам определённой группы. В отличие от механизма CRD, расширенный API регистрируется в Kubernetes как APIService, сообщая Kubernetes о необходимости учитывать этот новый API server и признавать, что он обслуживает определённые API.

Вы можете выполнить эту команду, чтобы вывести список всех зарегистрированных apiservice:

kubectl get apiservices.apiregistration.k8s.io

Пример APIService:

NAME                            SERVICE                     AVAILABLE   AGE
v1alpha1.apps.cozystack.io      cozy-system/cozystack-api    True      7h29m

Как только Kubernetes api-server получает запросы к ресурсам группы v1alpha1.apps.cozystack.io, он перенаправляет все эти запросы нашему расширенному API server, который может обрабатывать их на основе встроенной в него бизнес-логики.

Когда использовать слой агрегации API

Слой агрегации API помогает решить несколько задач, с которыми обычного механизма CRD может быть недостаточно. Давайте разберём их.

Императивная логика и подресурсы

Помимо обычных ресурсов, в Kubernetes есть так называемые подресурсы.

В Kubernetes подресурсы — это дополнительные действия или операции, которые можно выполнять над основными ресурсами (такими как Pod, Deployment, Service) через Kubernetes API. Они предоставляют интерфейсы для управления отдельными аспектами ресурсов, не затрагивая объект целиком.

Простой пример — status, который традиционно предоставляется как отдельный подресурс, доступный независимо от родительского объекта. Поле status не предназначено для изменения

Но помимо /status, у Pod в Kubernetes есть и такие подресурсы, как /exec, /portforward и /log. Что интересно, вместо привычных декларативных ресурсов Kubernetes они представляют собой конечные точки для императивных операций — таких как просмотр логов, проксирование соединений, выполнение команд в работающем контейнере и так далее.

Чтобы поддержать такие императивные команды в собственном API, нужно реализовать расширенный API и расширенный API server. Вот несколько хорошо известных примеров:

  • KubeVirt: дополнение для Kubernetes, расширяющее возможности его API для запуска традиционных виртуальных машин. Расширенный API server, созданный в составе KubeVirt, обрабатывает такие подресурсы виртуальных машин, как /restart, /console и /vnc.
  • Knative: дополнение для Kubernetes, расширяющее его возможности для бессерверных вычислений и реализующее подресурс /scale для настройки автомасштабирования своих типов ресурсов.

Кстати, хотя логика подресурсов в Kubernetes может быть императивной, управлять доступом к ним можно декларативно, используя стандартную модель RBAC Kubernetes.

Например, вот так можно управлять доступом к подресурсам /log и /exec объекта типа Pod:

kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: default
  name: pod-and-pod-logs-reader
rules:
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list"]
- apiGroups: [""]
  resources: ["pods/exec"]
  verbs: ["create"]

Вы не привязаны к использованию etcd

Обычно Kubernetes API server использует в качестве бэкенда etcd. Однако реализация собственного API server не обязывает вас использовать только etcd. Если хранить состояние вашего сервера в etcd не имеет смысла, вы можете хранить информацию в любой другой системе и генерировать ответы на лету. Вот несколько примеров для иллюстрации:

  • metrics-server — стандартное расширение для Kubernetes, которое позволяет просматривать метрики ваших узлов и подов в реальном времени. Оно определяет альтернативные типы Pod и Node в собственном API metrics.k8s.io. Запросы к этим ресурсам преобразуются в метрики напрямую из Kubelet. Поэтому, когда вы выполняете kubectl top node или kubectl top pod, metrics-server в реальном времени получает метрики из cAdvisor. Затем он возвращает эти метрики вам. Поскольку информация формируется в реальном времени и актуальна только в момент запроса, хранить её в etcd не нужно. Такой подход экономит ресурсы.
  • При необходимости можно использовать бэкенд, отличный от etcd. Вы даже можете реализовать для него Kubernetes-совместимый API. Например, если вы используете Postgres, можно создать прозрачное представление его сущностей в Kubernetes API. Скажем, базы данных, пользователи и права (grants) внутри Postgres будут отображаться как обычные ресурсы Kubernetes благодаря вашему расширенному API server. Управлять ими можно с помощью kubectl или любого другого Kubernetes-совместимого инструмента. В отличие от контроллеров, которые реализуют бизнес-логику через пользовательские ресурсы и методы согласования, расширенный API server избавляет от необходимости в отдельных контроллерах для каждого типа. Это означает, что вам не нужно синхронизировать состояние между Kubernetes API и вашим бэкендом.

Одноразовые ресурсы

  • В Kubernetes есть специальный API, предназначенный для предоставления пользователям информации об их правах. Он реализован с помощью API SelfSubjectAccessReview. Одна необычная деталь этих ресурсов в том, что их нельзя просмотреть с помощью глаголов get или list. Их можно только создавать (с помощью глагола create) и получать в ответ информацию о том, к чему у вас есть доступ в данный момент.
  • Если попытаться напрямую выполнить kubectl get selfsubjectaccessreviews, вы просто получите ошибку вроде такой:
Error from server (MethodNotAllowed): the server does not allow this method on the requested resource
  • Причина в том, что Kubernetes API server не поддерживает никакого другого взаимодействия с этим типом ресурсов (их можно только СОЗДАВАТЬ).
  • API SelfSubjectAccessReview поддерживает команды вроде kubectl auth can-i create deployments --namespace dev
  • Когда вы выполняете приведённую выше команду, kubectl создаёт SelfSubjectAccessReview через Kubernetes API. Это позволяет Kubernetes получить список возможных прав для вашего пользователя. Затем Kubernetes в реальном времени формирует персонализированный ответ на ваш запрос. Эта логика отличается от сценария, где такой ресурс просто хранится в etcd.
  • Аналогично, в расширении CDI (Containerized Data Importer) от KubeVirt, которое позволяет загружать файлы в PVC с локальной машины с помощью инструмента virtctl, перед началом процесса загрузки требуется специальный токен. Этот токен генерируется путём создания ресурса UploadTokenRequest через Kubernetes API. Kubernetes направляет (проксирует) все запросы на создание ресурса UploadTokenRequest расширенному API server CDI, который генерирует и возвращает токен в ответ.

Полный контроль над преобразованием, валидацией и форматированием вывода

  • Ваш собственный API server может обладать всеми возможностями стандартного Kubernetes API server. Ресурсы, которые вы создаёте в своём API server, могут проходить валидацию сразу на стороне сервера без дополнительных webhook’ов. Хотя CRD тоже поддерживают серверную валидацию с помощью Common Expression Language (CEL) для декларативной валидации и ValidatingAdmissionPolicies без необходимости в webhook’ах, собственный API server при необходимости позволяет реализовать более сложную и специализированную логику валидации.
  • Kubernetes позволяет обслуживать несколько версий API для каждого типа ресурса, традиционно v1alpha1, v1beta1 и v1. В качестве версии хранилища можно указать только одну версию. Все запросы к другим версиям должны автоматически преобразовываться в версию, указанную как версия хранилища. В случае CRD этот механизм реализуется с помощью conversion webhooks. Тогда как в расширенном API server вы можете реализовать собственный механизм преобразования, смешивать разные версии хранилища (один объект может сериализоваться как v1, другой — как v2) или полагаться на внешний обслуживающий API.
  • Прямая реализация Kubernetes API позволяет форматировать табличный вывод так, как вам угодно, и не заставляет следовать логике additionalPrinterColumns из CRD. Вместо этого вы можете написать собственный форматтер, который форматирует табличный вывод и пользовательские поля в нём. Например, при использовании additionalPrinterColumns можно отображать значения полей только по логике JSONPath. В собственном API server вы можете генерировать и вставлять значения на лету, форматируя табличный вывод как пожелаете.

Динамическая регистрация ресурсов

Ресурсы, обслуживаемые расширенным API server, не обязательно предварительно регистрировать как CRD. Как только ваш расширенный API server зарегистрирован через APIService, Kubernetes начинает опрашивать его для обнаружения API и ресурсов, которые он может обслуживать. Получив ответ discovery, Kubernetes API server автоматически регистрирует все доступные типы для этой группы API. Хотя это не считается распространённой практикой, вы можете реализовать логику, которая динамически регистрирует нужные вам типы ресурсов в кластере Kubernetes.

Когда не стоит использовать слой агрегации API

Существует несколько антипаттернов, при которых использовать слой агрегации API не рекомендуется. Давайте пройдёмся по ним.

Нестабильный бэкенд

Если ваш API server по какой-то причине перестанет отвечать — из-за недоступного бэкенда или других проблем, — это может заблокировать часть функциональности Kubernetes. Например, при удалении пространств имён Kubernetes будет ждать ответа от вашего API server, чтобы проверить, не остались ли какие-либо ресурсы. Если ответ не придёт, удаление пространства имён будет заблокировано.

Кроме того, вы могли сталкиваться с ситуацией, когда при недоступности metrics-server после каждого запроса к API (даже не связанного с метриками) в stderr появляется дополнительное сообщение о том, что metrics.k8s.io недоступен. Это ещё один пример того, как использование слоя агрегации API может приводить к проблемам, когда обрабатывающий запросы api-server недоступен.

Медленные запросы

Если вы не можете гарантировать мгновенный ответ на запросы пользователей, лучше рассмотреть использование CustomResourceDefinition и контроллера. Иначе вы можете сделать свой кластер менее стабильным. Многие проекты реализуют расширенный API server только для ограниченного набора ресурсов, в частности для императивной логики и подресурсов. Эта рекомендация также упоминается в официальной документации Kubernetes.

Зачем это понадобилось нам в Cozystack

Напомню, мы разрабатываем open-source PaaS-платформу Cozystack, которую также можно использовать как фреймворк для построения собственного частного облака. Поэтому возможность легко расширять платформу критически важна для нас.

Cozystack построен поверх FluxCD. Любое приложение упаковывается в собственный Helm-чарт, готовый к развёртыванию в пространстве имён арендатора. Развёртывание любого приложения на платформе выполняется путём создания ресурса HelmRelease с указанием имени чарта и параметров приложения. Всю остальную логику берёт на себя FluxCD. Этот паттерн позволяет нам легко расширять платформу новыми приложениями и даёт возможность создавать новые приложения, которые нужно лишь упаковать в соответствующий Helm-чарт.

Интерфейс платформы Cozystack

Итак, в нашей платформе всё настраивается через ресурсы HelmRelease. Однако мы столкнулись с двумя проблемами: ограничениями модели RBAC и необходимостью в публичном API. Давайте разберём их подробнее

Ограничения модели RBAC

Широко используемая система RBAC в Kubernetes не позволяет ограничивать доступ к списку ресурсов одного типа на основе меток или конкретных полей в spec. При создании роли вы можете ограничить доступ к ресурсам одного типа только путём указания конкретных имён ресурсов в resourceNames. Для глаголов вроде get или update это работает. Однако фильтрация по resourceNames с глаголом list работает иначе. Таким образом, вы можете ограничить вывод списка определённых ресурсов по типу, но не по имени.

  • В Kubernetes есть специальный API, предназначенный для предоставления пользователям информации об их правах. Он реализован с помощью API SelfSubjectAccessReview. Одна необычная деталь этих ресурсов в том, что их нельзя просмотреть с помощью глаголов get или list. Их можно только создавать (с помощью глагола create) и получать в ответ информацию о том, к чему у вас есть доступ в данный момент.

Поэтому мы решили ввести новые типы ресурсов на основе имён Helm-чартов, которые они используют, и генерировать список доступных типов динамически во время выполнения в нашем расширенном API server. Так мы можем повторно использовать стандартную модель RBAC Kubernetes для управления доступом к конкретным типам ресурсов.

Необходимость в публичном API

Поскольку наша платформа предоставляет возможности для развёртывания различных управляемых сервисов, мы хотим организовать публичный доступ к API платформы. Однако мы не можем позволить пользователям напрямую взаимодействовать с ресурсами вроде HelmRelease, так как это дало бы им возможность указывать произвольные имена и параметры для развёртывания Helm-чартов, потенциально компрометируя нашу систему.

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

  • kind: Kuberneteschart: kubernetes
  • kind: Postgreschart: postgres
  • kind: Redischart: redis
  • kind: VirtualMachinechart: virtual-machine

Более того, мы не хотим каждый раз добавлять новый тип в codegen и перекомпилировать наш расширенный API server при добавлении нового чарта, чтобы он начал обслуживаться. Обновление схемы должно происходить динамически или предоставляться администратором через ConfigMap.

Двустороннее преобразование

На данный момент у нас уже есть интеграции и панель управления, которые продолжают использовать ресурсы HelmRelease. На этом этапе мы не хотели терять возможность поддерживать этот API. Учитывая, что мы просто преобразуем один ресурс в другой, поддержка сохраняется и работает в обе стороны. Если вы создадите HelmRelease, вы получите пользовательский ресурс в Kubernetes, а если создадите пользовательский ресурс в Kubernetes, он также будет доступен как HelmRelease.

У нас нет никаких дополнительных контроллеров, которые синхронизируют состояние между этими ресурсами. Все запросы к ресурсам в нашем расширенном API server прозрачно проксируются в HelmRelease и наоборот. Это устраняет промежуточные состояния и необходимость писать контроллеры и логику синхронизации.

Реализация

Чтобы реализовать API агрегации, можно рассмотреть в качестве отправной точки следующие проекты:

  • apiserver-builder: в настоящее время находится в стадии alpha и не обновлялся два года. Он работает подобно kubebuilder, предоставляя фреймворк для создания расширенного API server и позволяя последовательно создавать структуру проекта и генерировать код для ваших ресурсов.
  • sample-apiserver: готовый пример реализованного API server на основе официальных библиотек Kubernetes, который можно использовать как основу для вашего проекта.

По практическим соображениям мы выбрали второй проект. Вот что нам нужно было сделать:

Отключить поддержку etcd

В нашем случае она не нужна, так как все ресурсы хранятся напрямую в Kubernetes API.

Отключить опции etcd можно, передав nil в RecommendedOptions.Etcd:

Сгенерировать общий тип ресурса

Мы назвали его Application, и выглядит он так:

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

Настроить загрузку конфигурации

Поскольку мы хотим настраивать наш расширенный API server через файл конфигурации, мы сформировали структуру конфигурации на Go:

Мы также изменили логику регистрации ресурсов так, чтобы создаваемые нами ресурсы регистрировались в scheme с разными значениями Kind:

В результате мы получили конфигурацию, в которой можно передать все возможные типы и указать, во что они должны отображаться:

Реализовать собственный реестр

Чтобы хранить состояние не в etcd, а напрямую преобразовывать его в ресурсы HelmRelease Kubernetes (и наоборот), мы написали функции преобразования из Application в HelmRelease и из HelmRelease в Application:

Мы реализовали логику фильтрации ресурсов по имени чарта, sourceRef и префиксу в имени HelmRelease:

Затем, используя эту логику, мы реализовали методы Get(), Delete(), List(), Create().

Полный пример можно посмотреть здесь:

В конце каждого метода мы устанавливаем правильный Kind и возвращаем объект unstructured.Unstructured{}, чтобы Kubernetes сериализовал объект корректно. Иначе он всегда сериализовал бы их с kind: Application, чего мы не хотим.

Чего мы достигли?

В Cozystack все наши типы из ConfigMap теперь доступны в Kubernetes как есть:

kubectl api-resources | grep cozystack
buckets                   apps.cozystack.io/v1alpha1      true        Bucket
clickhouses               apps.cozystack.io/v1alpha1      true        ClickHouse
etcds                     apps.cozystack.io/v1alpha1      true        Etcd
ferretdb                  apps.cozystack.io/v1alpha1      true        FerretDB
httpcaches                apps.cozystack.io/v1alpha1      true        HTTPCache
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

Мы можем работать с ними так же, как с обычными ресурсами Kubernetes.

Список S3-бакетов:

kubectl get buckets.apps.cozystack.io -n tenant-kvaps

Пример вывода:

NAME         READY   AGE    VERSION
foo          True    22h    0.1.0
testaasd     True    27h    0.1.0

Список кластеров Kubernetes:

kubectl get kuberneteses.apps.cozystack.io -n tenant-kvaps

Пример вывода:

NAME     READY   AGE    VERSION
abc      False   19h    0.14.0
asdte    True    22h    0.13.0

Список дисков виртуальных машин:

kubectl get vmdisks.apps.cozystack.io -n tenant-kvaps

Пример вывода:

NAME               READY   AGE    VERSION
docker             True    21d    0.1.0
test               True    18d    0.1.0
win2k25-iso        True    21d    0.1.0
win2k25-system     True    21d    0.1.0

Список экземпляров виртуальных машин:

kubectl get vminstances.apps.cozystack.io -n tenant-kvaps

Пример вывода:

NAME        READY   AGE    VERSION
docker      True    21d    0.1.0
test        True    18d    0.1.0
win2k25     True    20d    0.1.0

Мы можем создавать, изменять и удалять каждый из них, и любое взаимодействие с ними будет преобразовано в ресурсы HelmRelease с применением структуры ресурса и префикса в имени.

Чтобы увидеть все связанные Helm-релизы:

kubectl get helmreleases -n tenant-kvaps -l cozystack.io/ui

Пример вывода:

NAME                     AGE    READY
bucket-foo               22h    True
bucket-testaasd          27h    True
kubernetes-abc           19h    False
kubernetes-asdte         22h    True
redis-test               18d    True
redis-yttt               12d    True
vm-disk-docker           21d    True
vm-disk-test             18d    True
vm-disk-win2k25-iso      21d    True
vm-disk-win2k25-system   21d    True
vm-instance-docker       21d    True
vm-instance-test         18d    True
vm-instance-win2k25      20d    True

Дальнейшие шаги

Мы не собираемся останавливаться на достигнутом с нашим API. В будущем мы планируем добавить новые возможности:

  • Добавить валидацию на основе спецификации OpenAPI, сгенерированной напрямую из Helm-чартов.
  • Разработать контроллер, который собирает примечания к релизам (release notes) из развёрнутых релизов и показывает пользователям информацию о доступе к конкретным сервисам.
  • Переработать нашу панель управления для прямой работы с новым API.

Заключение

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

Вы можете сами протестировать API в open-source PaaS-платформе Cozystack, начиная с версии v0.18.