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
- Kubernetes Controllers Documentation — Documentação oficial sobre conceitos de controllers e reconciliação no Kubernetes
- Writing Controllers with client-go — Repositório oficial com exemplo prático de controller em Go usando client-go
- Operator SDK Documentation — Guia oficial do Operator SDK para construção de operadores Kubernetes
- Kubernetes Controller Manager Deep Dive — Artigo técnico detalhando a implementação de controllers no Kubernetes
- Reconciliation Loop in Kubernetes — Explicação clara do loop de reconciliação e seus componentes
- Client-go Informers and Caching — Tutorial sobre uso de Informers e estratégias de cache em controllers Kubernetes
- Building Operators with Ansible — Documentação para criação de operadores Kubernetes usando Ansible via Operator SDK