← К программе курса

Модуль 16 / 19

Расширение API: CRD, controllers и operator pattern

KUBERNETES

Содержание
01

1. Цели и результат модуля

Вы расширите Kubernetes API собственным типом, реализуете один reconciliation cycle и проследите spec, generation, status, ownerReferences и finalizer.

  • CRD добавляет новый API resource
  • custom resource хранит desired state
  • controller наблюдает и согласует состояние
  • status сообщает наблюдаемый результат
  • operator объединяет domain knowledge с control loop
02

2. Теория: расширение API

CRD регистрирует group, versions, scope, names и OpenAPI schema. После Established ресурс участвует в discovery, RBAC, watch, admission и storage API Server. CRD сам не создаёт Deployment и не выполняет business logic.

Version с served доступна клиентам, storage определяет формат в etcd. Изменение версий требует conversion strategy и совместимости данных; v1alpha1 явно сообщает нестабильность контракта.

03

3. Schema, status и printer columns

Structural OpenAPI schema валидирует и pruning неизвестных полей согласно настройкам. status subresource отделяет записи controller от user-owned spec. additionalPrinterColumns улучшают get, но не заменяют Conditions.

Status должен быть наблюдением, а не вторым desired state. observedGeneration показывает, к какой версии spec относится status.

04

4. Reconciliation loop

Controller watch/list получает события, кладёт keys в work queue и многократно приводит actual state к desired state. Reconcile обязан быть идемпотентным: повторный вызов после timeout или duplicate event даёт тот же результат.

Production controller учитывает retries/backoff, caches, conflicts, rate limits, leader election, shutdown и Conditions. Shell-скрипт лаборатории выполняет один цикл и нужен только для прозрачности механики.

05

5. ownerReferences и garbage collection

Namespaced owner может владеть namespaced dependent в том же namespace. UID защищает от совпадения имён после пересоздания. controller:true обозначает управляющего owner; blockOwnerDeletion участвует в foreground deletion.

Owner reference не означает reconciliation и не подходит для внешних ресурсов. Garbage collector работает только с Kubernetes objects и корректными scope/UID.

06

6. Finalizers и удаление

Delete объекта с finalizer устанавливает deletionTimestamp, но сохраняет объект, пока controller не завершит cleanup и не удалит свой finalizer. Finalizer — opaque key, а не выполняемый hook.

Забытый finalizer оставляет Terminating object. Удалять его вручную можно только после проверки, что внешний cleanup выполнен или осознанно принят риск утечки.

07

7. Методические указания

  • Проектируйте API contract до controller code
  • Добавляйте schema и status subresource
  • Сравнивайте generation с observedGeneration
  • Делайте reconcile идемпотентным
  • Используйте UID в ownerReferences
  • Обрабатывайте update conflicts
  • Не храните бесконечные данные в status
  • Не снимайте finalizer без проверки cleanup
08

8. Лабораторная работа: CRD и custom resource

Зарегистрируйте namespaced CourseApp, дождитесь Established и исследуйте schema через discovery/explain. Затем создайте desired state.

terminal
mkdir -p /tmp/k8s-m16
cat > /tmp/k8s-m16/crd.yaml <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: courseapps.training.goretskiy.pro}
spec:
  group: training.goretskiy.pro
  scope: Namespaced
  names: {plural: courseapps, singular: courseapp, kind: CourseApp, shortNames: [capp]}
  versions:
  - name: v1alpha1
    served: true
    storage: true
    subresources: {status: {}}
    additionalPrinterColumns:
    - {name: Ready, type: string, jsonPath: .status.readyReplicas}
    - {name: Observed, type: integer, jsonPath: .status.observedGeneration}
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            required: [image, replicas]
            properties:
              image: {type: string, minLength: 1}
              replicas: {type: integer, minimum: 1, maximum: 5}
          status:
            type: object
            properties:
              observedGeneration: {type: integer, format: int64}
              readyReplicas: {type: integer, format: int32}
              url: {type: string}
EOF
kubectl apply --dry-run=server -f /tmp/k8s-m16/crd.yaml
kubectl apply -f /tmp/k8s-m16/crd.yaml
kubectl wait --for=condition=Established crd/courseapps.training.goretskiy.pro --timeout=60s
kubectl api-resources --api-group=training.goretskiy.pro
kubectl explain courseapp.spec
terminal
kubectl create namespace k8s-m16
cat > /tmp/k8s-m16/instance.yaml <<'EOF'
apiVersion: training.goretskiy.pro/v1alpha1
kind: CourseApp
metadata: {name: demo, namespace: k8s-m16}
spec: {image: nginx:1.29-alpine, replicas: 2}
EOF
kubectl apply --dry-run=server -f /tmp/k8s-m16/instance.yaml
kubectl apply -f /tmp/k8s-m16/instance.yaml
kubectl -n k8s-m16 get courseapp demo -o yaml
09

9. Лабораторная работа: один reconciliation cycle

Скрипт читает CourseApp, server-side применяет дочерние Deployment/Service с ownerReferences и записывает status через отдельный subresource.

terminal
cat > /tmp/k8s-m16/reconcile.sh <<'EOF'
#!/bin/sh
set -eu
ns=${1:-k8s-m16}; name=${2:-demo}
uid=$(kubectl -n "$ns" get courseapp "$name" -o jsonpath='{.metadata.uid}')
generation=$(kubectl -n "$ns" get courseapp "$name" -o jsonpath='{.metadata.generation}')
image=$(kubectl -n "$ns" get courseapp "$name" -o jsonpath='{.spec.image}')
replicas=$(kubectl -n "$ns" get courseapp "$name" -o jsonpath='{.spec.replicas}')
cat > /tmp/k8s-m16/children.yaml <<YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: $name
  namespace: $ns
  ownerReferences:
  - {apiVersion: training.goretskiy.pro/v1alpha1, kind: CourseApp, name: $name, uid: $uid, controller: true, blockOwnerDeletion: true}
spec:
  replicas: $replicas
  selector: {matchLabels: {app: $name}}
  template:
    metadata: {labels: {app: $name}}
    spec:
      containers:
      - {name: nginx, image: $image, ports: [{name: http, containerPort: 80}]}
---
apiVersion: v1
kind: Service
metadata:
  name: $name
  namespace: $ns
  ownerReferences:
  - {apiVersion: training.goretskiy.pro/v1alpha1, kind: CourseApp, name: $name, uid: $uid, controller: true, blockOwnerDeletion: true}
spec:
  selector: {app: $name}
  ports: [{name: http, port: 80, targetPort: http}]
YAML
kubectl apply --server-side --field-manager=courseapp-controller -f /tmp/k8s-m16/children.yaml
kubectl -n "$ns" rollout status "deployment/$name" --timeout=120s
ready=$(kubectl -n "$ns" get deployment "$name" -o jsonpath='{.status.readyReplicas}')
kubectl -n "$ns" patch courseapp "$name" --subresource=status --type=merge \
  -p "{"status":{"observedGeneration":$generation,"readyReplicas":${ready:-0},"url":"http://$name.$ns.svc"}}"
EOF
chmod +x /tmp/k8s-m16/reconcile.sh
/tmp/k8s-m16/reconcile.sh
kubectl -n k8s-m16 get courseapp,deployment,service,pod
kubectl -n k8s-m16 get deployment demo -o jsonpath='{.metadata.ownerReferences}{"\n"}'
10

10. Лабораторная работа: generation и drift

После изменения spec generation опережает observedGeneration до следующего reconcile. Затем ручной scale создаёт drift, который повторный идемпотентный цикл исправляет.

terminal
kubectl -n k8s-m16 patch courseapp demo --type=merge -p '{"spec":{"replicas":3}}'
kubectl -n k8s-m16 get courseapp demo -o jsonpath='before: generation={.metadata.generation} observed={.status.observedGeneration}{"\n"}'
/tmp/k8s-m16/reconcile.sh
kubectl -n k8s-m16 get courseapp demo -o jsonpath='after: generation={.metadata.generation} observed={.status.observedGeneration} ready={.status.readyReplicas}{"\n"}'
kubectl -n k8s-m16 get deployment demo
11

11. Диагностика: validation, finalizer и deletion

replicas=0 отклоняется API и не меняет stored desired state. Ручной drift Deployment виден при сравнении CR и child.

terminal
kubectl -n k8s-m16 patch courseapp demo --type=merge -p '{"spec":{"replicas":0}}' || true
kubectl -n k8s-m16 get courseapp demo -o jsonpath='desired={.spec.replicas} generation={.metadata.generation}{"\n"}'

kubectl -n k8s-m16 scale deployment demo --replicas=1
kubectl -n k8s-m16 get courseapp,deployment
/tmp/k8s-m16/reconcile.sh
kubectl -n k8s-m16 get courseapp,deployment

Добавьте finalizer: delete только ставит deletionTimestamp. После имитации cleanup снимите finalizer и наблюдайте garbage collection дочерних объектов.

terminal
kubectl -n k8s-m16 patch courseapp demo --type=merge \
  -p '{"metadata":{"finalizers":["training.goretskiy.pro/cleanup"]}}'
kubectl -n k8s-m16 delete courseapp demo --wait=false
kubectl -n k8s-m16 get courseapp demo -o jsonpath='deletionTimestamp={.metadata.deletionTimestamp}{" finalizers="}{.metadata.finalizers}{"\n"}'
kubectl -n k8s-m16 get deployment,service
# Имитируем завершение внешнего cleanup контроллером.
kubectl -n k8s-m16 patch courseapp demo --type=merge -p '{"metadata":{"finalizers":null}}'
kubectl -n k8s-m16 wait --for=delete deployment/demo --timeout=60s
kubectl -n k8s-m16 get courseapp,deployment,service 2>&1 || true
12

12. Контроль, критерии освоения и cleanup

  • Что делает CRD без controller?
  • served против storage version?
  • Зачем status subresource?
  • Что доказывает observedGeneration?
  • Почему reconcile идемпотентен?
  • Зачем owner UID?
  • Удаляет ли GC внешний DNS?
  • Что делает deletionTimestamp?
  • Finalizer является hook-скриптом?
  • Чем operator отличается от общего controller?
terminal
kubectl delete namespace k8s-m16 --ignore-not-found --wait=true
kubectl delete crd courseapps.training.goretskiy.pro --wait=true
rm -rf /tmp/k8s-m16
kubectl api-resources --api-group=training.goretskiy.pro
kubectl get crd courseapps.training.goretskiy.pro 2>&1 || true

Модуль освоен, если вы расширяете API, исправляете drift повторным reconcile и безопасно проводите объект через finalizer и garbage collection.