Custom Resource Definitions (CRDs): estendendo o Kubernetes

1. Introdução às CRDs: Por que estender a API do Kubernetes?

O Kubernetes nativo oferece recursos poderosos como Pods, Services e Deployments, mas esses recursos genéricos nem sempre capturam as necessidades específicas de uma aplicação. Por exemplo, gerenciar um backup de banco de dados com Deployments e CronJobs exige múltiplos manifestos e lógica externa complexa. As Custom Resource Definitions (CRDs) resolvem esse problema permitindo que você crie seus próprios recursos na API do Kubernetes, como se fossem recursos nativos.

As CRDs funcionam como extensões da API REST do Kubernetes. Você define um novo tipo de recurso (ex: Backup), e o Kubernetes automaticamente cria endpoints REST para ele. A principal diferença entre CRDs e Aggregated APIs é que as CRDs não exigem um servidor de API externo — elas são implementadas diretamente pelo kube-apiserver. Aggregated APIs são recomendadas quando você precisa de lógica de validação ou autorização complexa no servidor de API.

2. Anatomia de uma CRD: Estrutura e Especificação

Uma CRD é definida usando um schema OpenAPI v3 que descreve a estrutura do spec e do status. O spec representa o estado desejado pelo usuário, enquanto o status é preenchido pelo controller com o estado atual.

As CRDs podem ser namespaced (isoladas por namespace) ou cluster-scoped (globais). Para recursos como backups, o escopo namespaced é mais comum, pois cada equipe gerencia seus próprios backups.

O versionamento é crítico: você pode definir múltiplas versões (ex: v1alpha1, v1beta1, v1) e especificar qual versão é usada para armazenamento (storage) e qual é servida para leitura/escrita.

3. Criando e Instalando sua Primeira CRD

Vamos criar uma CRD para um recurso Backup que gerencia backups de um banco de dados PostgreSQL.

# backup-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: backups.mycompany.com
spec:
  group: mycompany.com
  names:
    kind: Backup
    plural: backups
    singular: backup
    shortNames:
      - bk
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                databaseName:
                  type: string
                  minLength: 1
                retentionDays:
                  type: integer
                  minimum: 1
                  maximum: 30
                schedule:
                  type: string
                  pattern: "^[0-9*/, -]+$"
              required:
                - databaseName
                - retentionDays
            status:
              type: object
              properties:
                phase:
                  type: string
                  enum:
                    - Pending
                    - Running
                    - Completed
                    - Failed
                lastBackupTime:
                  type: string
                  format: date-time
                message:
                  type: string

Instale a CRD e crie uma instância:

kubectl apply -f backup-crd.yaml
kubectl get crd backups.mycompany.com

# Criando uma instância (Custom Resource)
kubectl apply -f - <<EOF
apiVersion: mycompany.com/v1
kind: Backup
metadata:
  name: meu-backup-postgres
  namespace: production
spec:
  databaseName: mydb
  retentionDays: 7
  schedule: "0 2 * * *"
EOF

kubectl get backups -n production
kubectl describe backup meu-backup-postgres -n production

Teste a validação: tente omitir databaseName ou definir retentionDays: 100 — o Kubernetes rejeitará a criação.

4. Controllers e Reconciliação: Dando Vida à CRD

Uma CRD sem controller é apenas uma estrutura de dados. O padrão de reconciliação é um loop contínuo no qual o controller lê o spec, compara com o estado atual (via status) e executa ações para convergir os dois.

Abaixo, um controller mínimo em Python usando a biblioteca kubernetes:

# controller.py
import time
import kubernetes
from kubernetes import client, config, watch

config.load_incluster_config()
api = client.CustomObjectsApi()
batch_api = client.BatchV1Api()

group = "mycompany.com"
version = "v1"
plural = "backups"
namespace = "production"

def reconcile(backup):
    name = backup["metadata"]["name"]
    spec = backup.get("spec", {})
    status = backup.get("status", {"phase": "Pending"})

    if status.get("phase") == "Completed":
        return

    # Criar um Job de backup
    job_name = f"backup-{name}-{int(time.time())}"
    job = {
        "apiVersion": "batch/v1",
        "kind": "Job",
        "metadata": {"name": job_name, "namespace": namespace},
        "spec": {
            "template": {
                "spec": {
                    "containers": [{
                        "name": "backup",
                        "image": "postgres:15",
                        "command": ["pg_dump", f"--dbname={spec['databaseName']}"]
                    }],
                    "restartPolicy": "Never"
                }
            },
            "backoffLimit": 2
        }
    }

    batch_api.create_namespaced_job(namespace, job)

    # Atualizar status
    patch = {
        "status": {
            "phase": "Running",
            "message": f"Job {job_name} criado",
            "lastBackupTime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        }
    }
    api.patch_namespaced_custom_object_status(
        group, version, namespace, plural, name, patch
    )

# Loop de reconciliação
w = watch.Watch()
for event in w.stream(api.list_namespaced_custom_object, group, version, namespace, plural):
    if event["type"] in ["ADDED", "MODIFIED"]:
        reconcile(event["object"])

O controller atualiza o status.phase para refletir o progresso — essencial para observabilidade.

5. Admission Controllers e Validação Customizada

O schema OpenAPI v3 já valida tipos e formatos, mas regras de negócio complexas exigem webhooks. Um ValidatingAdmissionWebhook pode, por exemplo, verificar se o campo retentionDays não excede 30 dias (já feito no schema, mas podemos adicionar lógica mais complexa, como verificar permissões).

Exemplo de webhook de validação (simplificado):

# webhook-server.py
from flask import Flask, request, jsonify
import json

app = Flask(__name__)

@app.route("/validate", methods=["POST"])
def validate():
    body = request.json
    obj = body["request"]["object"]

    # Validar regra de negócio: retentionDays <= 30
    if obj["spec"]["retentionDays"] > 30:
        return jsonify({
            "response": {
                "allowed": False,
                "status": {
                    "message": "retentionDays cannot exceed 30"
                }
            }
        })

    return jsonify({"response": {"allowed": True}})

if __name__ == "__main__":
    app.run(port=443, ssl_context=("cert.pem", "key.pem"))

Webhooks de mutação podem preencher defaults automaticamente, como definir retentionDays: 7 se o campo estiver vazio.

6. Operadores e o Operator SDK: Automatizando Ciclos de Vida

Um operador combina CRDs, controllers e lógica de ciclo de vida para automatizar tarefas complexas. O Operator SDK (Go) simplifica a criação de operadores.

Exemplo de operador para backups usando Operator SDK (Go):

// backup_controller.go (trecho)
func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var backup mycompanyv1.Backup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Verificar se o backup já foi executado hoje
    if backup.Status.Phase == "Completed" && isToday(backup.Status.LastBackupTime) {
        return ctrl.Result{RequeueAfter: 1 * time.Hour}, nil
    }

    // Criar Job de backup
    job := createBackupJob(&backup)
    if err := r.Create(ctx, job); err != nil {
        return ctrl.Result{}, err
    }

    // Atualizar status
    backup.Status.Phase = "Running"
    backup.Status.LastBackupTime = metav1.Now()
    if err := r.Status().Update(ctx, &backup); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: 24 * time.Hour}, nil
}

Boas práticas incluem:
- Finalizers: impedem a deleção da CR até que o backup real seja limpo.
- Garbage collection: deletar Jobs antigos automaticamente.
- Reconciliação idempotente: o mesmo evento pode ser processado múltiplas vezes sem efeitos colaterais.

7. Boas Práticas, Versionamento e Segurança

Versionamento semântico: mantenha versões antigas servidas (served: true) enquanto a nova versão amadurece. Use storage: true apenas na versão estável. Para conversão entre versões (ex: v1alpha1v1), implemente um Conversion Webhook.

Segurança:
- RBAC mínimo: conceda apenas os recursos que o controller realmente precisa.
- Escopo de namespaces: evite CRDs cluster-scoped a menos que necessário.
- Proteção contra deleção acidental: use kubectl annotate crd backups.mycompany.com "helm.sh/resource-policy=keep".

8. Monitoramento e Observabilidade de Recursos Customizados

Exponha métricas do Prometheus a partir do status das CRs:

# metrics.py
from prometheus_client import Gauge, start_http_server

backup_phase = Gauge('backup_phase', 'Phase of backup resources', ['name', 'phase'])

def update_metrics():
    backups = api.list_namespaced_custom_object(group, version, namespace, plural)
    for bk in backups["items"]:
        phase = bk["status"].get("phase", "Unknown")
        backup_phase.labels(name=bk["metadata"]["name"], phase=phase).set(1)

start_http_server(8000)
while True:
    update_metrics()
    time.sleep(30)

Crie dashboards no Grafana para visualizar backups pendentes vs concluídos, tempo desde o último backup e taxas de falha. Logs estruturados no controller (JSON) facilitam a correlação com métricas.

Referências