Модуль 16 / 19
Расширение API: CRD, controllers и operator pattern
Содержание
1. Цели и результат модуля
Вы расширите Kubernetes API собственным типом, реализуете один reconciliation cycle и проследите spec, generation, status, ownerReferences и finalizer.
- CRD добавляет новый API resource
- custom resource хранит desired state
- controller наблюдает и согласует состояние
- status сообщает наблюдаемый результат
- operator объединяет domain knowledge с control loop
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 явно сообщает нестабильность контракта.
3. Schema, status и printer columns
Structural OpenAPI schema валидирует и pruning неизвестных полей согласно настройкам. status subresource отделяет записи controller от user-owned spec. additionalPrinterColumns улучшают get, но не заменяют Conditions.
Status должен быть наблюдением, а не вторым desired state. observedGeneration показывает, к какой версии spec относится status.
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-скрипт лаборатории выполняет один цикл и нужен только для прозрачности механики.
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.
6. Finalizers и удаление
Delete объекта с finalizer устанавливает deletionTimestamp, но сохраняет объект, пока controller не завершит cleanup и не удалит свой finalizer. Finalizer — opaque key, а не выполняемый hook.
Забытый finalizer оставляет Terminating object. Удалять его вручную можно только после проверки, что внешний cleanup выполнен или осознанно принят риск утечки.
7. Методические указания
- Проектируйте API contract до controller code
- Добавляйте schema и status subresource
- Сравнивайте generation с observedGeneration
- Делайте reconcile идемпотентным
- Используйте UID в ownerReferences
- Обрабатывайте update conflicts
- Не храните бесконечные данные в status
- Не снимайте finalizer без проверки cleanup
8. Лабораторная работа: CRD и custom resource
Зарегистрируйте namespaced CourseApp, дождитесь Established и исследуйте schema через discovery/explain. Затем создайте desired state.
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.speckubectl 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 yaml9. Лабораторная работа: один reconciliation cycle
Скрипт читает CourseApp, server-side применяет дочерние Deployment/Service с ownerReferences и записывает status через отдельный subresource.
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. Лабораторная работа: generation и drift
После изменения spec generation опережает observedGeneration до следующего reconcile. Затем ручной scale создаёт drift, который повторный идемпотентный цикл исправляет.
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 demo11. Диагностика: validation, finalizer и deletion
replicas=0 отклоняется API и не меняет stored desired state. Ручной drift Deployment виден при сравнении CR и child.
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 дочерних объектов.
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 || true12. Контроль, критерии освоения и cleanup
- Что делает CRD без controller?
- served против storage version?
- Зачем status subresource?
- Что доказывает observedGeneration?
- Почему reconcile идемпотентен?
- Зачем owner UID?
- Удаляет ли GC внешний DNS?
- Что делает deletionTimestamp?
- Finalizer является hook-скриптом?
- Чем operator отличается от общего controller?
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.