Kubernetes Operators e CRDs: Como Ensinar o Cluster a Operar sua Aplicação
Guia técnico de Operators e CRDs: como estender a API do Kubernetes, o loop de reconciliação, finalizers, RBAC e as armadilhas que derrubam controllers em produção.
Neste artigo
Existe um momento na vida de todo time que roda Kubernetes em que a plataforma para de resolver o problema sozinha. Um Deployment sobe sua API sem esforço: você declara três réplicas, o cluster mantém três réplicas, e pronto. Mas experimente subir um banco de dados replicado. Agora "manter no ar" significa eleger um líder, promover uma réplica quando o primário cai, reconfigurar quem replica de quem, rodar um backup consistente antes de qualquer coisa destrutiva e nunca, jamais, aceitar duas instâncias se declarando primárias ao mesmo tempo. Nada disso cabe num Deployment. Esse conhecimento vive na cabeça de quem opera o banco — e é exatamente esse conhecimento que o padrão Operator existe para codificar.
Um Operator é, em uma frase, um operador humano transformado em software. Ele empacota a expertise de "como cuidar dessa aplicação específica" dentro de um controlador que roda no cluster e age sozinho, o tempo todo. E o mecanismo que torna isso possível são as CRDs, as Custom Resource Definitions, que permitem inventar novos tipos de objeto na API do Kubernetes como se eles sempre tivessem existido. Este guia mostra como as duas peças se encaixam, o que acontece por dentro do loop que faz tudo funcionar, e onde os controllers costumam falhar de formas silenciosas e caras.
O Kubernetes é uma API declarativa, não um instalador#
Antes de falar de Operators, vale relembrar o que o Kubernetes realmente é. Ele não é um script que "instala coisas". É um banco de dados de estado desejado (o etcd, atrás do API Server) cercado de controladores que passam a vida inteira comparando o que você pediu com o que existe e agindo para fechar a diferença. Você escreve "quero três réplicas"; o Deployment controller observa que há duas; ele cria mais uma. Você nunca diz como criar a réplica. Você diz o quê, e um controlador cuida do como. Isso se chama modelo declarativo, e é o coração da plataforma.
A sacada do padrão Operator é que essa maquinaria toda é aberta. O conjunto de tipos que a API entende — Pod, Service, Deployment, ConfigMap — não é fechado. Com uma CRD, você registra um tipo novo, digamos PostgresCluster, e a partir daí o kubectl get postgrescluster funciona, o objeto é validado, versionado e persistido no etcd exatamente como um recurso nativo. O que falta é alguém que dê sentido a ele. Esse alguém é o controller. CRD sem controller é um formulário bonito que ninguém lê; controller sem CRD não tem o que observar. Juntos, formam um Operator.
Anatomia de uma CRD#
Uma CRD é, ela própria, um objeto do Kubernetes. Você a aplica no cluster e ela ensina a API a reconhecer um novo kind. Um esqueleto mínimo se parece com isto:
```yaml apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: postgresclusters.db.exemplo.io spec: group: db.exemplo.io scope: Namespaced names: kind: PostgresCluster plural: postgresclusters singular: postgrescluster shortNames: [pgc] versions:
- name: v1
served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: replicas: type: integer minimum: 1 version: type: string required: [replicas, version] ```
Note o schema com validação OpenAPI embutida: o minimum: 1 faz a própria API rejeitar um cluster com zero réplicas, antes de qualquer controller entrar em cena. Isso importa muito na prática — quanto mais validação você declara na CRD, menos casos absurdos o seu código precisa tratar. Depois de aplicada, um usuário cria uma instância do tipo novo:
``yaml apiVersion: db.exemplo.io/v1 kind: PostgresCluster metadata: name: pedidos spec: replicas: 3 version: "16" ``
A partir daqui, o objeto existe no etcd, aparece no kubectl get pgc, mas nada acontece com ele. Ele é só um registro de intenção. Quem transforma intenção em Pods, Services e volumes reais é o controller.
O loop de reconciliação, por dentro#
O controller de um Operator roda um laço eterno que, informalmente, faz sempre a mesma coisa: "olhe o estado desejado, olhe o estado observado, e tome uma ação que aproxime um do outro." Essa é a reconciliação, e entendê-la bem é o que separa um Operator que você confia de um que te acorda de madrugada.
Um ponto que confunde muita gente: o controller não é acionado por "eventos" no sentido de "criou → faça X, deletou → faça Y". Ele é level-triggered, não edge-triggered. Ou seja, ele não reage à transição; ele reage ao nível atual. Quando algo muda, o único efeito é o objeto entrar numa fila para ser reconciliado. Dentro do Reconcile, o código não sabe nem se importa com o que mudou — ele lê o estado completo do mundo naquele instante e decide o que fazer a partir do zero. Isso é o que dá robustez: se o controller ficou fora do ar por dez minutos, ele não "perdeu eventos". Ao voltar, ele reconcilia o nível atual e conserta tudo de uma vez.
Um esboço conceitual do laço, em pseudo-Go no estilo do controller-runtime:
```go func (r *Reconciler) Reconcile(ctx context.Context, req Request) (Result, error) { var desejado PostgresCluster if err := r.Get(ctx, req.NamespacedName, &desejado); err != nil { // NotFound = objeto foi deletado; nada a fazer. return Result{}, client.IgnoreNotFound(err) }
// Observa o mundo real. atual := r.lerEstadoAtual(ctx, &desejado)
// Age para fechar a diferença. Cada passo é IDEMPOTENTE. if err := r.garantirStatefulSet(ctx, &desejado, atual); err != nil { return Result{}, err // erro => requeue automático com backoff } if err := r.garantirServicos(ctx, &desejado); err != nil { return Result{}, err }
// Publica o que sabe de volta no .status do objeto. desejado.Status.ProntasReplicas = atual.replicasProntas return Result{}, r.Status().Update(ctx, &desejado) } ```
A palavra mais importante desse trecho é idempotente. Cada garantir... precisa poder rodar mil vezes e produzir o mesmo resultado: se o StatefulSet já existe e está correto, não faz nada; se falta, cria; se está errado, ajusta. Reconcile não é "criar", é "garantir". Um controller que assume "sou chamado uma vez na criação" está condenado, porque o Kubernetes vai chamá-lo dezenas de vezes — por resync periódico, por mudança em objetos filhos, por reinício do processo. Escrever reconciliação como se cada chamada fosse a primeira é a habilidade central de quem constrói Operators.
Watches, informers e a fila de trabalho#
Como o controller sabe quando reconciliar sem varrer a API o tempo todo? Através de informers: um cache local, alimentado por um watch de longa duração sobre a API, que mantém uma cópia atualizada dos objetos de interesse e dispara callbacks quando algo muda. Esses callbacks não executam a lógica pesada; eles apenas enfileiram a chave do objeto numa workqueue. Um pool de workers puxa dessa fila e chama Reconcile. A fila deduplica (se o mesmo objeto muda cinco vezes antes de ser processado, ele é reconciliado uma vez) e faz backoff exponencial em caso de erro. Ler a partir do cache do informer, e não direto da API, é o que permite um controller escalar sem esmagar o API Server.
Ownership, finalizers e o ciclo de vida#
Dois mecanismos do Kubernetes são indispensáveis para um Operator não fazer sujeira. O primeiro é a ownerReference: quando o controller cria um StatefulSet a partir de um PostgresCluster, ele carimba o StatefulSet como "de propriedade" daquele cluster. O ganho é duplo. Primeiro, o garbage collector do Kubernetes apaga automaticamente os filhos quando o pai some — você deleta o PostgresCluster e os Pods, Services e o StatefulSet desaparecem junto, sem código nenhum. Segundo, o controller-runtime usa essas relações para saber que uma mudança num Pod filho deve enfileirar a reconciliação do pai.
O segundo mecanismo é o finalizer. Nem toda limpeza é feita pelo garbage collector. Se o seu Operator provisiona algo fora do cluster — um bucket, um registro de DNS, um snapshot final antes de destruir dados — você precisa interceptar a deleção. Um finalizer é uma string na lista metadata.finalizers do objeto. Enquanto ela estiver lá, a API não apaga o objeto de verdade; ela apenas marca deletionTimestamp. O controller vê esse timestamp, faz a limpeza externa, e só então remove o finalizer — liberando a deleção real. Esquecer de remover o finalizer é uma das causas mais clássicas de "não consigo deletar esse objeto, ele fica travado em Terminating para sempre".
RBAC: o Operator é um usuário poderoso#
Um controller age em nome de ninguém e de todos: ele cria e destrói Pods, lê Secrets, atualiza Services. Isso significa que ele roda com uma ServiceAccount cujas permissões precisam ser tratadas com o mesmo cuidado de um acesso administrativo. O princípio é o do menor privilégio: conceda exatamente os verbos (get, list, watch, create, update, patch, delete) sobre exatamente os recursos que o controller toca, e nada além. Um Operator que ganha cluster-admin "para simplificar" é uma porta dos fundos esperando ser explorada — comprometer o processo do controller passa a valer comprometer o cluster inteiro. A conta do Operator merece a mesma disciplina que qualquer identidade de alto privilégio.
Quando escrever um Operator — e quando não#
Nem todo problema pede um Operator, e aqui mora um erro comum: reduzir tudo a prego porque você aprendeu a usar o martelo. A pergunta certa é sobre o Day 2. Se a sua aplicação só precisa de instalação e configuração — subir, receber variáveis, escalar horizontalmente — um chart de template resolve, e um Operator seria peso morto. O Operator ganha valor quando existe operação contínua e específica de domínio: failover, backup consistente, upgrade coordenado de versão, rebalanceamento, recuperação de estado. Quanto mais o "cuidar disso no ar" depende de conhecimento que não cabe num YAML estático, mais um Operator se paga.
Existe até um vocabulário para medir isso, os capability levels, que vão do nível 1 (instalação básica) ao nível 5 (piloto automático: o Operator faz tuning, escala e cura sozinho com base em métricas). O Prometheus Operator é um exemplo elegante de nível intermediário: você declara um ServiceMonitor e ele reconfigura o Prometheus para raspar aquele alvo, sem você tocar em arquivo de configuração. Operators de banco de dados maduros, como os de PostgreSQL, chegam aos níveis altos porque codificam failover e backup. Estudar esses projetos consolidados ensina mais do que qualquer tutorial.
O ferramental: não escreva o laço do zero#
A boa notícia é que ninguém constrói informers, workqueues e caches na mão. O controller-runtime é a biblioteca que fornece toda essa fundação; você escreve basicamente a função Reconcile e registra quais recursos observar. Em cima dele, o Kubebuilder e o Operator SDK geram o scaffold do projeto: definem a CRD a partir de structs Go anotadas, criam o manifesto de RBAC a partir de marcadores no código, e montam o binário do manager. Para casos mais simples, dá até para escrever Operators declarativamente com ferramentas baseadas em Helm ou Ansible, sem uma linha de Go — troca-se flexibilidade por velocidade de entrega.
Armadilhas que derrubam controllers em produção#
O primeiro e mais cruel dos problemas é a reconciliação infinita. Acontece quando cada passagem pelo Reconcile provoca uma mudança que dispara outra reconciliação, num loop sem fim que consome CPU e martela o API Server. A causa quase sempre é falta de idempotência: o controller "atualiza" um objeto filho toda vez porque compara mal o desejado com o atual — por exemplo, reescrevendo um campo que o próprio Kubernetes normaliza, gerando uma diferença fantasma eterna. A cura é comparar apenas o que importa e só chamar Update quando há mudança real.
O segundo é confundir spec com status. O spec é o que o usuário pede e o controller lê; o status é o que o controller observa e escreve. Um controller nunca deve escrever no spec de um objeto que o usuário controla — isso apaga a intenção dele. Toda informação que o Operator descobre (réplicas prontas, fase atual, último backup) vai no status, num subrecurso separado que não conflita com edições do usuário.
O terceiro é tratar erro como sucesso. Se um passo da reconciliação falha e o controller engole o erro e retorna sucesso, ele desiste silenciosamente e o estado nunca converge. O padrão correto é retornar o erro: o controller-runtime reenfileira com backoff e tenta de novo. Erros transitórios (a API está ocupada, o Pod ainda subindo) devem virar requeue, não desistência. Do outro lado, é preciso não confundir "ainda não terminou" com "falhou" — esperar um Pod ficar pronto é reconciliação normal, e o objeto volta para a fila até o mundo alcançar o desejado.
Por fim, cuidado com a contenção de versões da CRD. À medida que a API do seu Operator evolui, você adiciona v1beta1, v1, e precisa de webhooks de conversão para migrar objetos antigos. Ignorar versionamento cedo cria uma dívida dolorosa: um dia você precisa mudar o schema e descobre que há centenas de objetos gravados no formato velho que ninguém consegue ler.
Fechando o ciclo#
Operators e CRDs são a forma que o Kubernetes oferece de dizer: "minha aplicação é especial, e a plataforma deveria saber cuidar dela como eu cuidaria." A CRD dá o vocabulário — um tipo novo, validado e persistido na API. O controller dá o comportamento — um laço de reconciliação idempotente e level-triggered que persegue o estado desejado sem descanso. Quando você acerta a idempotência, respeita a fronteira entre spec e status, trata erro como requeue e limpa direito com finalizers e ownerReferences, você ganha algo raro: uma aplicação com estado que se opera sozinha, dentro das mesmas regras declarativas que já governam o resto do cluster.
Comece pequeno. Escreva uma CRD com um schema honesto, um controller que reconcilia um único objeto filho de forma idempotente, e observe-o convergir. A partir daí, cada pedaço de conhecimento operacional que hoje mora numa página de wiki ou na cabeça de uma pessoa é candidato a virar código dentro do seu Operator — e código, ao contrário de gente cansada às três da manhã, não esquece o passo do backup antes de destruir o volume.