Kubernetes Operators: estendendo a API do K8s

1. Introdução aos Kubernetes Operators

Kubernetes Operators representam uma evolução significativa na forma como gerenciamos aplicações complexas no ecossistema Kubernetes. Eles surgiram da necessidade de superar as limitações dos controllers nativos, que embora eficientes para cargas de trabalho padrão, não conseguem lidar com a complexidade operacional de aplicações stateful como bancos de dados, sistemas de mensageria e ferramentas de monitoramento.

O conceito central por trás dos Operators é a "aplicação como código" — transformar o conhecimento operacional humano em software automatizado. Isso permite que tarefas repetitivas como instalação, upgrade, backup e recuperação sejam executadas de forma consistente e confiável.

Na perspectiva DevOps, os Operators reduzem drasticamente o toil — trabalho manual, repetitivo e automatizável. Em vez de um engenheiro executar procedimentos complexos de 15 passos para atualizar um cluster etcd, um Operator pode fazer isso com uma simples alteração no manifesto YAML.

2. Fundamentos: Controllers vs Operators

Controllers nativos como Deployment, StatefulSet e DaemonSet gerenciam o ciclo de vida básico de pods e serviços. Eles garantem que o estado atual corresponda ao estado desejado, mas não entendem o comportamento específico da aplicação.

Operators estendem esse conceito através de Custom Resource Definitions (CRDs). Enquanto um Deployment sabe apenas criar e destruir pods, um Operator para PostgreSQL sabe como:
- Inicializar um novo banco com configurações otimizadas
- Realizar backups incrementais
- Executar upgrades com zero downtime
- Recuperar de falhas de nó automaticamente

O padrão Operator implementa um loop de reconciliação contínuo:

func (r *MyOperator) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. Observa o estado atual
    currentState := r.observeCurrentState(req)

    // 2. Compara com o estado desejado
    desiredState := r.getDesiredState(req)

    // 3. Calcula as ações necessárias
    actions := r.calculateActions(currentState, desiredState)

    // 4. Aplica as alterações
    for _, action := range actions {
        err := r.executeAction(ctx, action)
        if err != nil {
            return ctrl.Result{RequeueAfter: time.Second}, err
        }
    }

    // 5. Retorna para continuar monitorando
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

3. Custom Resource Definitions (CRDs)

CRDs são a espinha dorsal dos Operators. Eles permitem definir novos tipos de recursos no Kubernetes, com schemas validados usando OpenAPI v3.

Exemplo de um CRD básico para gerenciar bancos de dados:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.mycompany.io
spec:
  group: mycompany.io
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                engine:
                  type: string
                  enum: [postgres, mysql]
                version:
                  type: string
                storage:
                  type: string
                  pattern: '^[0-9]+Gi$'
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 5
              required: [engine, version]
  scope: Namespaced
  names:
    plural: databases
    singular: database
    kind: Database
    shortNames:
    - db

Para gerenciar versões e migrações, podemos implementar conversion webhooks:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.mycompany.io
spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1", "v2"]
      clientConfig:
        service:
          namespace: mycompany-system
          name: mycompany-webhook-service
          path: /convert

4. Construindo um Operator na prática

Frameworks como Operator SDK e Kubebuilder simplificam drasticamente a criação de Operators. Vamos criar um Operator simples para gerenciar um banco de dados usando Go.

Inicializando o projeto:

# Instalar o Operator SDK
curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.32.0/operator-sdk_linux_amd64
chmod +x operator-sdk_linux_amd64
sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

# Criar novo projeto
operator-sdk init --domain=mycompany.io --repo=github.com/mycompany/database-operator

# Criar API
operator-sdk create api --group=database --version=v1 --kind=Database --resource=true --controller=true

Implementando a lógica de reconciliação:

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)

    // Buscar a instância do Database
    database := &mycompanyv1.Database{}
    if err := r.Get(ctx, req.NamespacedName, database); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Verificar se o StatefulSet existe
    sts := &appsv1.StatefulSet{}
    err := r.Get(ctx, types.NamespacedName{
        Name:      database.Name,
        Namespace: database.Namespace,
    }, sts)

    if err != nil && apierrors.IsNotFound(err) {
        // Criar StatefulSet para o banco
        sts = r.buildStatefulSet(database)
        if err := r.Create(ctx, sts); err != nil {
            return ctrl.Result{}, err
        }
        log.Info("StatefulSet criado", "name", sts.Name)
    }

    // Atualizar status do Database
    database.Status.Ready = sts.Status.ReadyReplicas == *sts.Spec.Replicas
    if err := r.Status().Update(ctx, database); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

5. Ciclo de vida e reconciliação

O loop de reconciliação segue o padrão watch, diff, apply. O Operator monitora mudanças no CRD, calcula diferenças entre estado atual e desejado, e aplica as correções necessárias.

Para lidar com estados complexos como backup e rollback:

func (r *DatabaseReconciler) handleBackup(ctx context.Context, db *mycompanyv1.Database) error {
    if !db.Spec.Backup.Enabled {
        return nil
    }

    // Verificar último backup
    lastBackup := &batchv1.Job{}
    err := r.Get(ctx, types.NamespacedName{
        Name:      fmt.Sprintf("%s-backup-%s", db.Name, time.Now().Format("20060102")),
        Namespace: db.Namespace,
    }, lastBackup)

    if err != nil && apierrors.IsNotFound(err) {
        // Criar job de backup
        backupJob := r.buildBackupJob(db)
        return r.Create(ctx, backupJob)
    }

    return nil
}

Para tratamento de falhas com backoff:

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // Lógica de reconciliação...

    if err != nil {
        // Requeue com backoff exponencial
        return ctrl.Result{
            RequeueAfter: time.Duration(math.Pow(2, float64(retryCount))) * time.Second,
        }, err
    }

    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

6. Operações avançadas com Operators

Operators para sistemas complexos como etcd ou Prometheus implementam lógicas sofisticadas:

# Exemplo de recurso etcd gerenciado por Operator
apiVersion: etcd.database.coreos.com/v1beta2
kind: EtcdCluster
metadata:
  name: my-etcd-cluster
spec:
  size: 3
  version: "3.5.0"
  pod:
    resources:
      requests:
        cpu: 200m
        memory: 512Mi
  backup:
    backupIntervalInSecond: 3600
    maxBackups: 5
  TLS:
    static:
      member:
        peerSecret: etcd-peer-tls
        serverSecret: etcd-server-tls

Integração com Service Mesh para comunicação segura entre serviços gerenciados pelo Operator:

apiVersion: v1
kind: Service
metadata:
  annotations:
    sidecar.istio.io/inject: "true"
  labels:
    app: postgres
  name: postgres
spec:
  ports:
  - port: 5432
    name: postgres
  selector:
    app: postgres

7. Operators e GitOps

A integração com GitOps através de ArgoCD ou Flux permite gerenciar Operators de forma declarativa:

# Aplicação ArgoCD para o Operator
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: database-operator
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/mycompany/database-operator
    targetRevision: HEAD
    path: config/default
  destination:
    server: https://kubernetes.default.svc
    namespace: database-operator-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Para gerenciar instâncias do banco de dados via GitOps:

# Instância do banco no repositório Git
apiVersion: mycompany.io/v1
kind: Database
metadata:
  name: production-db
  namespace: production
spec:
  engine: postgres
  version: "14"
  storage: "100Gi"
  replicas: 3
  backup:
    enabled: true
    schedule: "0 2 * * *"

8. Boas práticas e considerações finais

Testes são cruciais para Operators. Utilize testes unitários para lógica de reconciliação:

func TestDatabaseReconciler(t *testing.T) {
    // Configurar ambiente de teste
    scheme := runtime.NewScheme()
    _ = mycompanyv1.AddToScheme(scheme)
    _ = appsv1.AddToScheme(scheme)
    _ = corev1.AddToScheme(scheme)

    // Criar reconciler mock
    reconciler := &DatabaseReconciler{
        Client: fake.NewClientBuilder().WithScheme(scheme).Build(),
        Scheme: scheme,
    }

    // Testar criação de banco
    db := &mycompanyv1.Database{
        ObjectMeta: metav1.ObjectMeta{
            Name:      "test-db",
            Namespace: "default",
        },
        Spec: mycompanyv1.DatabaseSpec{
            Engine:   "postgres",
            Version:  "14",
            Storage:  "10Gi",
            Replicas: 3,
        },
    }

    req := ctrl.Request{
        NamespacedName: types.NamespacedName{
            Name:      "test-db",
            Namespace: "default",
        },
    }

    result, err := reconciler.Reconcile(context.Background(), req)
    assert.NoError(t, err)
    assert.NotNil(t, result)
}

Monitoramento do próprio Operator é essencial:

# Métricas Prometheus expostas pelo Operator
# HELP database_operator_reconcile_total Total de reconciliações
# TYPE database_operator_reconcile_total counter
database_operator_reconcile_total{status="success"} 1245
database_operator_reconcile_total{status="error"} 23

# HELP database_operator_reconcile_duration_seconds Duração da reconciliação
# TYPE database_operator_reconcile_duration_seconds histogram
database_operator_reconcile_duration_seconds_bucket{le="0.1"} 890
database_operator_reconcile_duration_seconds_bucket{le="0.5"} 1150
database_operator_reconcile_duration_seconds_bucket{le="1"} 1230

Quando usar Operators vs soluções prontas:
- Use Operators quando precisar de automação complexa de ciclo de vida
- Prefira Helm charts para deploys simples e parametrizáveis
- Operators são ideais para aplicações stateful que exigem conhecimento operacional profundo

Os Kubernetes Operators transformam operações manuais em software confiável e escalável, permitindo que equipes DevOps automatizem tarefas complexas e reduzam significativamente o toil operacional.

Referências