← Kubernetes: дальше
8 мин · средне · Урок 1 из 3

CRD: свой тип ресурса в кластере

Как кластер узнаёт про новый kind, чем определение типа отличается от объекта и почему без контроллера CRD остаётся просто записью в базе.

Зачем нужен свой тип ресурса

Встроенных типов в Kubernetes конечное число: Pod, Service, ConfigMap и ещё несколько десятков. Когда приложению нужен объект, которого в этом списке нет, его добавляют через CustomResourceDefinition.

CustomResourceDefinition (CRD) - это определение нового типа ресурса. После применения CRD кластер начинает понимать новый kind, и с ним работают те же kubectl get, kubectl apply, kubectl describe, та же RBAC-модель и тот же формат манифеста.

Так устроен почти весь сторонний софт для Kubernetes. cert-manager добавляет тип Certificate, Prometheus Operator - ServiceMonitor, Argo CD - Application. Ни один из них не патчит исходники Kubernetes. Все они регистрируют свои CRD, и API кластера расширяется на лету.

Проверить, какие типы кластер знает прямо сейчас, можно одной командой:

kubectl api-resources
kubectl api-resources --api-group=cert-manager.io

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

CRD и CR - разные вещи

Два понятия легко перепутать, а разница принципиальная.

Аналогия из программирования: CRD - это class, CR - объект класса. Сначала регистрируют тип, потом создают объекты. Разными будут и права: на создание CRD нужен доступ ко всему кластеру, на создание Widget хватит прав в одном namespace.

Анатомия CRD

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: widgets.demo.example.com
spec:
  group: demo.example.com
  scope: Namespaced
  names:
    plural: widgets
    singular: widget
    kind: Widget
    shortNames: [wd]
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:
                  type: integer
                color:
                  type: string

group

group - пространство имён API, к которому относится тип. У встроенных ресурсов это либо пустая строка (Pod, Service), либо короткое имя (apps у Deployment). Для своих типов берут доменное имя, чтобы не столкнуться с чужими: demo.example.com.

name и names

metadata.name обязан быть в формате <plural>.<group>, то есть widgets.demo.example.com. Если написать иначе, apiserver вернёт ошибку и CRD не создастся.

Блок names задаёт, как тип называется в командах:

scope

scope определяет, где живут объекты этого типа.

Сам CRD всегда cluster-scoped: он меняет API всего кластера. Поэтому создать его может только тот, у кого есть права на весь кластер, и поэтому в managed-кластерах установка операторов обычно идёт от администратора, а не от команды разработки.

kubectl delete crd уносит с собой все объекты этого типа во всех namespace сразу. Перед удалением смотрят kubectl get widgets -A.

versions и schema

versions - список версий API типа. Ровно одна из них помечена storage: true: в этом формате объект лежит в etcd. served: true означает, что версия отдаётся через API. Когда версий несколько, apiserver конвертирует объект между ними, а для нетривиальных случаев подключают conversion webhook.

schema.openAPIV3Schema описывает поля объекта. Без схемы apiserver не примет CRD: в apiextensions.k8s.io/v1 она обязательна. Схема работает как валидация: если объявить size как integer, строка туда не запишется.

Объект нового типа

После применения CRD кластеру нужно несколько секунд, чтобы зарегистрировать тип. Готовность видна по условию Established:

kubectl get crd widgets.demo.example.com
kubectl wait --for=condition=Established crd/widgets.demo.example.com --timeout=30s

Объект, созданный сразу после применения CRD, получает no matches for kind "Widget". Тип ещё не Established, и помогает как раз kubectl wait.

Дальше объект создаётся как обычный манифест. apiVersion собирается из group и version, kind берётся из names.kind:

apiVersion: demo.example.com/v1
kind: Widget
metadata:
  name: my-widget
spec:
  size: 3
  color: blue
kubectl apply -f widget.yaml
kubectl get widgets
kubectl get widget my-widget -o jsonpath='{.spec.size}'

Что ещё умеет схема

Схема нужна не только для валидации, из неё вырастает вся остальная работа с типом.

kubectl explain читает ту же схему и объясняет поля кастомного типа так же, как поля Pod:

kubectl explain widget.spec
kubectl explain widget.spec.size

Поля, которых в схеме нет, apiserver вырезает при записи. Это называется pruning и включено по умолчанию в apiextensions.k8s.io/v1. Симптом характерный: kubectl apply проходит без ошибки, а после kubectl get -o yaml поля в объекте нет. Если произвольные ключи нужны намеренно, их разрешают через x-kubernetes-preserve-unknown-fields: true.

Колонки в выводе kubectl get тоже настраиваются. По умолчанию показывается только имя и возраст, но additionalPrinterColumns внутри версии добавляет свои:

versions:
  - name: v1
    served: true
    storage: true
    additionalPrinterColumns:
      - name: Size
        type: integer
        jsonPath: .spec.size
      - name: Color
        type: string
        jsonPath: .spec.color

spec, status и subresources

У встроенных ресурсов есть разделение: spec - чего хочет человек, status - что реально происходит. Для кастомных типов это разделение включают явно:

subresources:
  status: {}

После этого status живёт отдельным подресурсом. Запись в основной объект не меняет status, а запись в status не трогает spec. Практический смысл: человек правит spec через kubectl apply, контроллер пишет status через отдельный вызов, и они не затирают работу друг друга.

Второй подресурс, scale, позволяет применять к кастомному типу kubectl scale и HorizontalPodAutoscaler, если в схеме есть поля с числом реплик.

Контроллер: что превращает запись в действие

spec чего хочет человек факт что в кластере сейчас шаги сократить разницу status что получилось и снова, на каждом проходе level-triggered: пропущенное событие цикл не ломает
Цикл не заканчивается: каждый проход снова сверяет spec с фактом.

Сам по себе CRD только хранит данные. Объект Widget лежит в etcd, и без внешнего кода на его появление ничего не происходит.

Действия добавляет контроллер - процесс, который подписан на события по объектам своего типа и приводит кластер к описанному состоянию. Работает он циклом согласования (reconcile):

  1. Прочитать желаемое состояние из spec объекта.
  2. Посмотреть на фактическое состояние в кластере.
  3. Сделать шаги, которые сокращают разницу.
  4. Записать результат в status.

Важное свойство этого цикла: он level-triggered, то есть смотрит на текущее состояние, а не на список произошедших событий. Одно пропущенное событие ничего не ломает, следующий проход всё равно увидит расхождение. Отсюда требование к reconcile: он должен быть идемпотентным. Повторный запуск на неизменившемся объекте не должен ничего создавать заново.

Такой контроллер обычно ещё вешает на объект finalizer - запись в metadata.finalizers, которая не даёт удалить объект окончательно, пока контроллер не приберёт за собой внешние ресурсы. Пока finalizer на месте, объект после kubectl delete остаётся в состоянии Terminating.

Оператор

Связку «CRD + контроллер» называют оператором. Пример - cert-manager: он регистрирует Certificate, контроллер видит новый объект, идёт в Let's Encrypt, кладёт сертификат в Secret и пишет в status дату истечения. Пишут операторы поверх каркасов controller-runtime, Kubebuilder, Operator SDK, которые снимают рутину и оставляют разработчику функцию reconcile.

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

Что обычно ломается

Домашка

Теорию закрепляет задание на своём кластере: 2 шага с проверкой. Кластер поднимается по треку Kubernetes локально, задания лежат в репозитории k8s-practice.

./run.sh start k8s-crd     # развернуть стартовое состояние
./run.sh verify k8s-crd    # проверить текущий шаг

Коротко

Прошли материал до конца? Отметьте урок пройденным.