Controller pattern: reconciliação e loops de controle

1. Fundamentos do Controller Pattern no Kubernetes

O Controller Pattern é o coração do modelo declarativo do Kubernetes. Um controller é um loop infinito que continuamente observa o estado atual do cluster, compara com o estado desejado declarado pelo usuário e executa ações para aproximar os dois estados. Esse processo é conhecido como reconciliação.

Diferente de sistemas reativos tradicionais (event-driven), onde ações são disparadas por eventos específicos, a reconciliação no Kubernetes é baseada em loops contínuos. O controller não espera por eventos — ele constantemente verifica se o estado atual corresponde ao desejado. Essa abordagem garante resiliência: mesmo que eventos sejam perdidos ou o controller reinicie, o loop de reconciliação eventualmente corrigirá qualquer desvio.

O etcd e o API Server formam a fonte da verdade. O estado desejado é armazenado no etcd e exposto via API Server. O controller lê esse estado desejado, observa o estado real do cluster e decide quais ações tomar.

2. Arquitetura do Loop de Reconciliação

O loop de reconciliação típico possui três componentes principais:

  • Informer: observa mudanças em recursos do Kubernetes via watches
  • Work Queue: fila de trabalho que armazena itens a serem reconciliados
  • Reconciler: função que executa a lógica de negócio para cada item

O fluxo básico é:

Watch → Delta (mudança detectada) → Queue (item enfileirado) → Reconcile (lógica executada) → Compare (estado atual vs. desejado) → Apply (ações corretivas)

Exemplo de estrutura básica de um reconciler em Go:

package main

import (
    "context"
    "fmt"
    "time"

    "k8s.io/client-go/util/workqueue"
    "k8s.io/apimachinery/pkg/util/wait"
)

type MyReconciler struct {
    queue workqueue.RateLimitingInterface
}

func (r *MyReconciler) Reconcile(ctx context.Context, key string) error {
    // 1. Obter estado desejado do API Server
    // 2. Obter estado atual do cluster
    // 3. Comparar e determinar ações necessárias
    // 4. Aplicar mudanças

    fmt.Printf("Reconciliando: %s\n", key)
    return nil
}

func (r *MyReconciler) Run(ctx context.Context, workers int) {
    for i := 0; i < workers; i++ {
        go wait.Until(func() {
            for r.processNextItem(ctx) {
            }
        }, time.Second, ctx.Done())
    }
}

func (r *MyReconciler) processNextItem(ctx context.Context) bool {
    key, quit := r.queue.Get()
    if quit {
        return false
    }
    defer r.queue.Done(key)

    err := r.Reconcile(ctx, key.(string))
    if err != nil {
        r.queue.AddRateLimited(key)
        return true
    }
    r.queue.Forget(key)
    return true
}

3. Mecanismos de Observação e Cache

Para reduzir a carga no API Server, os controllers utilizam Informers e Listers. Informers mantêm um cache local dos recursos observados, sincronizado via watches. Listers fornecem acesso a esse cache sem consultar o API Server.

// Exemplo de criação de Informer
import (
    "k8s.io/client-go/informers"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/cache"
)

clientset, _ := kubernetes.NewForConfig(config)
factory := informers.NewSharedInformerFactory(clientset, 10*time.Minute)
informer := factory.Core().V1().Pods().Informer()

informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc: func(obj interface{}) {
        key, _ := cache.MetaNamespaceKeyFunc(obj)
        queue.Add(key)
    },
    UpdateFunc: func(old, new interface{}) {
        key, _ := cache.MetaNamespaceKeyFunc(new)
        queue.Add(key)
    },
    DeleteFunc: func(obj interface{}) {
        key, _ := cache.DeletionHandlingMetaNamespaceKeyFunc(obj)
        queue.Add(key)
    },
})

Estratégias de resync periódico (ex: a cada 10 minutos) garantem que eventos perdidos sejam recuperados. O Informer força uma reconciliação completa de todos os itens no cache, mesmo sem mudanças detectadas.

4. Estratégias de Reconciliação

Existem duas abordagens principais:

Reconciliação completa (full reconciliation): o controller processa todos os recursos periodicamente, independentemente de mudanças. É mais simples mas menos eficiente.

Reconciliação incremental: apenas recursos que sofreram mudanças são processados. Mais eficiente, mas requer tratamento cuidadoso de eventos perdidos.

Rate limiting e backoff são essenciais para evitar sobrecarga no cluster:

// Configuração de rate limiting
queue := workqueue.NewRateLimitingQueue(
    workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second),
)

Idempotência é crucial: aplicar a mesma reconciliação múltiplas vezes deve produzir o mesmo resultado. Isso permite retentativas seguras em caso de falhas transitórias.

5. Integração com Recursos Customizados (CRDs)

CRDs permitem estender o Kubernetes com novos tipos de recursos. Um controller personalizado gerencia o ciclo de vida desses recursos.

Exemplo de CRD MyApp:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.example.com
spec:
  group: example.com
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicas:
                  type: integer
                image:
                  type: string
                port:
                  type: integer
            status:
              type: object
              properties:
                availableReplicas:
                  type: integer

Controller que gerencia esse recurso:

func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // Obter o recurso MyApp
    myApp := &examplev1.MyApp{}
    if err := r.Get(ctx, req.NamespacedName, myApp); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Obter estado atual (Pods, Services)
    // Comparar com estado desejado
    // Criar/atualizar Deployment se necessário
    // Atualizar status do MyApp

    myApp.Status.AvailableReplicas = atualReplicas
    r.Status().Update(ctx, myApp)

    return ctrl.Result{}, nil
}

O status subresource separa o estado desejado (spec) do estado observado (status), permitindo que o controller atualize o status sem modificar o spec.

6. Boas Práticas para Operadores e Controladores

  • Separação de responsabilidades: a lógica de negócio deve ser independente da lógica de reconciliação. Use interfaces e injeção de dependência.
  • Finalizers: garantem limpeza controlada antes da remoção de recursos. Impedem que um recurso seja deletado até que operações de cleanup sejam concluídas.
  • Monitoramento: exponha métricas de reconciliação (sucessos, falhas, duração), use logs estruturados e implemente health checks (liveness/readiness probes).

7. Exemplo Prático Passo a Passo

Passo 1: Configurar o projeto Go

go mod init my-controller
go get k8s.io/client-go@latest
go get k8s.io/apimachinery@latest

Passo 2: Criar o controller simples

package main

import (
    "flag"
    "k8s.io/client-go/informers"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/util/workqueue"
)

func main() {
    kubeconfig := flag.String("kubeconfig", "~/.kube/config", "caminho para kubeconfig")
    flag.Parse()

    config, _ := clientcmd.BuildConfigFromFlags("", *kubeconfig)
    clientset, _ := kubernetes.NewForConfig(config)

    factory := informers.NewSharedInformerFactory(clientset, 0)
    informer := factory.Core().V1().Pods().Informer()

    queue := workqueue.NewRateLimitingQueue(
        workqueue.DefaultControllerRateLimiter(),
    )

    reconciler := &MyReconciler{queue: queue}

    informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc: func(obj interface{}) {
            key, _ := cache.MetaNamespaceKeyFunc(obj)
            queue.Add(key)
        },
    })

    stopCh := make(chan struct{})
    defer close(stopCh)

    go factory.Start(stopCh)
    go reconciler.Run(context.Background(), 2)

    <-stopCh
}

Passo 3: Deploy como Pod

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-controller
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-controller
  template:
    metadata:
      labels:
        app: my-controller
    spec:
      serviceAccountName: my-controller-sa
      containers:
      - name: controller
        image: myregistry/my-controller:latest
        args: ["--kubeconfig", "/etc/kubernetes/config"]

Passo 4: Testar com um recurso customizado

kubectl apply -f myapp-crd.yaml
kubectl apply -f myapp-instance.yaml
kubectl get myapp
kubectl edit myapp myapp-sample  # alterar replicas
kubectl delete myapp myapp-sample

8. Comparação com Padrões Vizinhos

Controller Pattern vs. Admission Controllers: Admission controllers interceptam requisições ao API Server antes da persistência, validando ou mutando recursos. Controllers agem após a persistência, mantendo o estado desejado. Use admission controllers para validação/mutação inicial e controllers para reconciliação contínua.

Controller Pattern vs. Serverless (Knative): Knative usa loops de reconciliação para gerenciar serviços serverless, mas com foco em escalar para zero e execução sob demanda. Controllers tradicionais mantêm recursos continuamente ativos.

Operator SDK: abstrai o loop de reconciliação, permitindo implementar operadores em Go ou Ansible. Fornece scaffolding, testes e métricas prontas.

# Exemplo com Operator SDK (Go)
operator-sdk init --domain example.com --repo github.com/example/my-operator
operator-sdk create api --group example --version v1 --kind MyApp --resource --controller

O SDK gera automaticamente o reconciler, informers e handlers, permitindo que o desenvolvedor foque na lógica de negócio.


O Controller Pattern é fundamental para a operação confiável de clusters Kubernetes. Entender seus mecanismos — loops de reconciliação, caching, rate limiting e idempotência — permite construir operadores robustos que mantêm o estado desejado mesmo em cenários de falha.

Referências