← До програми курсу

Модуль 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.