Introdução
Se você já administra aplicações em Kubernetes, provavelmente sabe o quanto pode ser trabalhoso manter tudo funcionando corretamente. Criar Deployments, Services, Ingresses, aplicar patches, gerenciar atualizações… a lista não acaba.
Ferramentas como o Helm ajudam a organizar e versionar YAMLs, mas mas não lidam com lógica operacional complexa. Afinal, o Helm não “pensa”: ele ‘apenas‘ aplica templates. E se pudéssemos ensinar o Kubernetes a fazer esse trabalho sozinho, reagindo a mudanças e tomando decisões automaticamente?
Os Operadores do Kubernetes são como extensões que deixam o cluster mais inteligente. Eles permitem que você descreva, de forma simples, o que precisa, e o operador se encarrega de criar e manter todos os recursos necessários para que isso aconteça. Assim, em vez de depender de scripts ou de aplicar vários YAMLs manualmente, você trabalha com recursos customizados que o próprio Kubernetes entende — ampliando o que o cluster consegue fazer de forma nativa.
Neste post, quero mostrar de forma prática o que os Operadores do Kubernetes são capazes de fazer e por que podem ser tão úteis. Para isso, vou usar um exemplo em Go: um operador que implanta uma aplicação simples e já cria Deployment, Service e Ingress automaticamente. Mais do que o código em si, a ideia é demonstrar como operadores pode ser utilizados para transformam tarefas repetitivas em algo muito mais simples, deixando o Kubernetes trabalhar por você.
O Que Vamos Construir
Para ilustrar o poder dos operadores, vamos criar um simples operador nos permitirá aplicar um recurso customizado App que automaticamente gera:
- Deployment – Roda a aplicação (imagem, replicas, porta)
- Service – Expõe internamente no cluster
- Ingress – Expõe externamente via HTTP
- Status – Mostra estado atual (replicas disponíveis, URL)
apiVersion: platform.example.com/v1alpha1
kind: App
metadata:
name: app-sample
spec:
deploy:
image: nginx:1.27
replicas: 2
containerPort: 8080
service:
port: 9093
ingress:
host: minha-app.local
path: /
O que são Operadores no Kubernetes?
De forma simples: um Operador é um programa que estende o Kubernetes para entender novos tipos de recurso.
No dia a dia, usamos objetos nativos como Pod, Service e Deployment. Mas com um operador, podemos criar objetos totalmente novos — por exemplo, Database, App ou BackupJob — e ensinar o Kubernetes a entender o que fazer com eles.
Um operador segue o mesmo ciclo de vida que um SRE ou DevOps humano faria:
- Observar o cluster (ficar “de olho” nos recursos).
- Decidir o que precisa ser feito (comparar estado desejado com estado atual).
-
Agir criando, atualizando ou removendo objetos.
Dessa forma, ele automatiza operações que antes precisariam ser feitas manualmente.
[imagemdofluxodocontroller]
Quando faz sentido usar Operadores?
Você pode estar se perguntando: “mas se já tenho Helm, por que precisaria de um operador?”.
Aqui estão alguns casos típicos em que operadores fazem toda a diferença:
-
Aplicações com lógica operacional complexa: bancos de dados, caches e sistemas distribuídos geralmente exigem muito mais do que um simples
kubectl apply. - Automação sob medida: quando Helm não consegue lidar com a lógica de negócios ou requisitos específicos do seu time.
- Integrações externas: operadores podem conversar com APIs, sistemas de monitoramento, provedores de nuvem etc.
- Menos erro humano: nada de esquecer de aplicar um YAML ou rodar um script — o operador garante o estado correto.
Um exemplo prático:
- Com Helm, você pode instalar o PostgreSQL.
- Com um operador, além de instalar, o Kubernetes pode automaticamente criar backups, restaurar em caso de falha, gerenciar usuários e até escalar o banco.
Operadores vs Helm
| Helm | Operador |
|---|---|
| Trabalha com templates de YAML | Trabalha observando e reagindo a eventos do cluster |
| Ótimo para instalar aplicações | Ótimo para operar aplicações |
| Não tem lógica de negócio embutida | Pode conter inteligência operacional (backup, failover, integração externa) |
Execução pontual (helm install/upgrade) |
Execução contínua (reconciliação automática) |
Resumindo: Helm é como um “instalador de pacotes”, enquanto operadores são como “administradores automatizados”.
Mãos à Obra: Criando Nosso Operador
Pré-requisitos
- Go 1.23+
- kubebuilder instalado
- Cluster Kubernetes(local)
# 1. Instalar Go 1.23+
go version # Deve mostrar go1.23 ou superior
# 2. Instalar Kubebuilder
curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/{% katex inline %}
(go env GOOS)/
{% endkatex %}(go env GOARCH)
chmod +x kubebuilder && mv kubebuilder /usr/local/bin/
# 3. Cluster Kubernetes local
kind create cluster --name operator-demo
# OU
minikube start
Estrutura do Projeto
# Criar projeto
kubebuilder init --domain platform.example.com --repo github.com/seu-usuario/app-operator
# Criar API
kubebuilder create api --group platform --version v1alpha1 --kind App --resource --controller
Estrutura gerada:
app-operator/
├── api/v1alpha1/
│ └── app_types.go # ← Define o CRD (estrutura do YAML)
├── internal/controller/
│ └── app_controller.go # ← Lógica do operador (reconciliation)
├── config/
│ ├── crd/ # ← Manifests do CRD
│ ├── rbac/ # ← Permissões
│ └── samples/ # ← Exemplos de uso
└── main.go
Utilizaremos uma função chamada cmp, para comparar as alterações nos recursos. Então precisamos trazer essa biblioteca para o nosso projeto, para isso utilizamos.
go get github.com/google/go-cmp/cmp
Definindo o CRD (Custom Resource Definition)
Nosso objetivo é que, ao criar um recurso no Kubernetes como o exemplo acima, o operador seja capaz de gerar automaticamente os objetos necessários (Deployment, Service e Ingress)
Para que isso aconteça é necessário realizar as alterações necessárias no arquivo de types do projeto gerado pelo kubebuilder.
Editando o arquivo api/v1alpha1/app_types.go
Abra o arquivo e defina as structs:
Dentro dele, vamos criar a struct AppSpec, que conterá os campos definidos no spec do CRD. Esses campos serão utilizados pelo operador para criar os recursos no cluster.
type AppSpec struct {
Deploy AppDeploy `json:"deploy"`
Service AppService `json:"service"`
Ingress AppIngress `json:"ingress"`
}
type AppDeploy struct {
Image string `json:"image"`
Replicas *int32 `json:"replicas,omitempty"`
ContainerPort *int32 `json:"containerPort"`
}
type AppService struct {
Port *int32 `json:"port"`
}
type AppIngress struct {
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
}
type AppStatus struct {
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
URL string `json:"url,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
Entendendo os Campos
Nosso CRD tem três seções principais que definem como a aplicação será implantada:
1. Deploy – Define o container:
-
image: Qual imagem Docker usar (ex:nginx:1.27) -
replicas: Quantas cópias rodar (padrão: 1) -
containerPort: Porta que o container expõe (padrão: 8080)
2. Service – Define como expor internamente:
-
port: Porta do Service no cluster (ex: 9093)
3. Ingress – Define acesso externo:
-
host: Domínio para acessar (ex:minha-app.local) -
path: Caminho da URL (padrão:/)
4. Status – Estado atual (preenchido automaticamente):
-
availableReplicas– número de réplicas do Deployment que estão realmente disponíveis no momento. -
url– URL de acesso completa -
observedGeneration– Versão reconciliada
Editando o arquivo internal/controller/app_controller.go
Como estamos criando um operador que ira lidar com objetos nativos do kubernetes tambem precisamos fazer com que ele possa lidar com esse tipo de objeto entao precisamos aplicar as permissões necessárias as clusterrole e clusterrolebinding do kubenetes o kubebuilder tambem lida com isso de forma nativa pra isso precisamos adicionar algumas tags no arquivo
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
Gerando e instalando os artefatos
Após realizarmos essas alterações nos arquivos app_types.go e app_controller.go, precisamos executar o comando:
make manifests
make install
Esse passo é importante porque o Kubebuilder vai:
- Atualizar o CRD (CustomResourceDefinition) com os novos campos definidos no
AppSpeceAppStatus. - Ajustar as permissões de RBAC necessárias para que o operador consiga gerenciar os recursos.
- Aplicar o crd e a clusterrole e clusterrolebidinig
Assim, garantimos que o cluster Kubernetes já reconheça o novo formato do nosso recurso personalizado.
Implementando o Controller
O próximo passo é editar o arquivo internal/controller/app_controller.go
para incluirmos a logica do que queremos que o operador faça.
A função Reconcile é onde toda a mágica acontece. É ela que o Kubernetes chama automaticamente sempre que:
- Um recurso
Appé criado, modificado ou deletado - Algum recurso gerenciado pelo operador (Deployment, Service, Ingress) muda
- O operador precisa garantir que o estado atual corresponde ao estado desejado
Vamos entender o código em blocos:
func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Info("Starting reconciliation", "app", req.NamespacedName)
app := &platformv1alpha1.App{}
if err := r.Get(ctx, req.NamespacedName, app); err != nil {
if apierrors.IsNotFound(err) {
logger.Info("App resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
logger.Error(err, "Failed to get App resource")
return ctrl.Result{}, err
}
O que está acontecendo:
- Cria um logger para registrar as operações
- Tenta buscar o recurso
Appno cluster usando o nome e namespace recebidos - Se o recurso não for encontrado (foi deletado), retorna sem erro
- Se houver outro tipo de erro, retorna o erro para tentar novamente
Para manter o código mais organizado, vamos dividi-lo em funcoes 4 funções de suporte para tarefas distintas: uma será responsável por reconciliar uma parte da nossa app.
- Uma função com o nome reconcileDeployment
- Responsável por reconciliar o nosso deployment
- Uma função com o nome reconcileService
- Responsável por reconciliar o nosso service
- Uma função com o nome reconcileIngress
- Responsável por reconciliar o nosso o ingress
- Uma função com o nome updateStatus
- Atualizar o Status do objeto
dessa maneira
if err := r.reconcileDeployment(ctx, app); err != nil {
logger.Error(err, "Failed to reconcile Deployment")
return ctrl.Result{}, err
}
if err := r.reconcileService(ctx, app); err != nil {
logger.Error(err, "Failed to reconcile Service")
return ctrl.Result{}, err
}
if err := r.reconcileIngress(ctx, app); err != nil {
logger.Error(err, "Failed to reconcile Ingress")
return ctrl.Result{}, err
}
if err := r.updateStatus(ctx, app); err != nil {
logger.Error(err, "Failed to update App status")
}
Funções de Reconciliação
Função reconcileDeployment() – Gerencia o Deployment
func (r *AppReconciler) reconcileDeployment(ctx context.Context, app *platformv1alpha1.App) error {
logger := log.FromContext(ctx)
deployment := &appsv1.Deployment{}
err := r.Get(ctx, types.NamespacedName{
Name: app.Name,
Namespace: app.Namespace,
}, deployment)
if err != nil && apierrors.IsNotFound(err) {
desiredDeployment, err := r.deploymentForApp(app)
if err != nil {
return fmt.Errorf("failed to generate deployment spec: %w", err)
}
logger.Info("Creating new Deployment",
"namespace", desiredDeployment.Namespace,
"name", desiredDeployment.Name)
if err := r.Create(ctx, desiredDeployment); err != nil {
return fmt.Errorf("failed to create deployment: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("failed to get deployment: %w", err)
}
desiredDeployment, err := r.deploymentForApp(app)
if err != nil {
return fmt.Errorf("failed to generate deployment spec: %w", err)
}
if !cmp.Equal(deployment.Spec.Template.Spec.Containers[0].Image, desiredDeployment.Spec.Template.Spec.Containers[0].Image) ||
!cmp.Equal(deployment.Spec.Replicas, desiredDeployment.Spec.Replicas) ||
!cmp.Equal(deployment.Spec.Template.Spec.Containers[0].Ports, desiredDeployment.Spec.Template.Spec.Containers[0].Ports) {
logger.Info("Updating existing Deployment",
"namespace", deployment.Namespace,
"name", deployment.Name)
deployment.Spec.Template.Spec.Containers[0].Image = desiredDeployment.Spec.Template.Spec.Containers[0].Image
deployment.Spec.Template.Spec.Containers[0].Ports = desiredDeployment.Spec.Template.Spec.Containers[0].Ports
deployment.Spec.Replicas = desiredDeployment.Spec.Replicas
if err := r.Update(ctx, deployment); err != nil {
return fmt.Errorf("failed to update deployment: %w", err)
}
}
return nil
}
O que essa função faz:
- Busca o Deployment no cluster
- Se não existe: Cria usando
deploymentForApp() - Se existe: Compara com estado desejado
- Se diferente: Atualiza imagem, replicas ou portas
- Se houver erro na criação , registra e retorna o erro se não cria o objeto e retorna sem erro
Foi criada uma função de suporte chamada deploymentForApp() (veja detalhes na seção Funções Auxiliares) onde passamos o a app e ela nos retorna um objeto do tipo deployment de Kubernetes.
Padrão das funções reconcile: As funções reconcileService() e reconcileIngress() seguem a mesma lógica:
Buscar → Se não existe, criar → Se existe, comparar → Se diferente, atualizar
Veja o código completo dessas funções na seção Funções de Reconciliação.
Função reconcileService() – Gerencia o Service
func (r *AppReconciler) reconcileService(ctx context.Context, app *platformv1alpha1.App) error {
logger := log.FromContext(ctx)
serviceName := fmt.Sprintf("service-%s", app.Name)
service := &corev1.Service{}
err := r.Get(ctx, types.NamespacedName{
Name: serviceName,
Namespace: app.Namespace,
}, service)
if err != nil && apierrors.IsNotFound(err) {
desiredService, err := r.serviceForApp(app)
if err != nil {
return fmt.Errorf("failed to generate service spec: %w", err)
}
logger.Info("Creating new Service",
"namespace", desiredService.Namespace,
"name", desiredService.Name)
if err := r.Create(ctx, desiredService); err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
desiredService, err := r.serviceForApp(app)
if err != nil {
return fmt.Errorf("failed to generate service spec: %w", err)
}
if !cmp.Equal(service.Spec.Ports, desiredService.Spec.Ports) ||
!cmp.Equal(service.Spec.Selector, desiredService.Spec.Selector) {
logger.Info("Updating existing Service",
"namespace", service.Namespace,
"name", service.Name)
desiredService.Spec.ClusterIP = service.Spec.ClusterIP
service.Spec = desiredService.Spec
if err := r.Update(ctx, service); err != nil {
return fmt.Errorf("failed to update service: %w", err)
}
}
return nil
}
Função reconcileIngress() – Gerencia o Ingress
func (r *AppReconciler) reconcileIngress(ctx context.Context, app *platformv1alpha1.App) error {
logger := log.FromContext(ctx)
ingressName := fmt.Sprintf("ingress-%s", app.Name)
ingress := &networkingv1.Ingress{}
err := r.Get(ctx, types.NamespacedName{
Name: ingressName,
Namespace: app.Namespace,
}, ingress)
if err != nil && apierrors.IsNotFound(err) {
desiredIngress, err := r.ingressForApp(app)
if err != nil {
return fmt.Errorf("failed to generate ingress spec: %w", err)
}
logger.Info("Creating new Ingress",
"namespace", desiredIngress.Namespace,
"name", desiredIngress.Name)
if err := r.Create(ctx, desiredIngress); err != nil {
return fmt.Errorf("failed to create ingress: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("failed to get ingress: %w", err)
}
desiredIngress, err := r.ingressForApp(app)
if err != nil {
return fmt.Errorf("failed to generate ingress spec: %w", err)
}
if !cmp.Equal(ingress.Spec, desiredIngress.Spec) {
logger.Info("Updating existing Ingress",
"namespace", ingress.Namespace,
"name", ingress.Name)
ingress.Spec = desiredIngress.Spec
if err := r.Update(ctx, ingress); err != nil {
return fmt.Errorf("failed to update ingress: %w", err)
}
}
return nil
}
Função updateStatus() – Atualiza o Status do App
func (r *AppReconciler) updateStatus(ctx context.Context, app *platformv1alpha1.App) error {
logger := log.FromContext(ctx)
deployment := &appsv1.Deployment{}
if err := r.Get(ctx, types.NamespacedName{
Name: app.Name,
Namespace: app.Namespace,
}, deployment); err != nil {
return fmt.Errorf("failed to get deployment for status update: %w", err)
}
url := fmt.Sprintf("http://%s%s", app.Spec.Ingress.Host, app.Spec.Ingress.Path)
statusChanged := false
if app.Status.AvailableReplicas != deployment.Status.AvailableReplicas {
app.Status.AvailableReplicas = deployment.Status.AvailableReplicas
statusChanged = true
}
if app.Status.URL != url {
app.Status.URL = url
statusChanged = true
}
if app.Status.ObservedGeneration != app.Generation {
app.Status.ObservedGeneration = app.Generation
statusChanged = true
}
if statusChanged {
logger.Info("Updating App status",
"availableReplicas", app.Status.AvailableReplicas,
"url", app.Status.URL)
if err := r.Status().Update(ctx, app); err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
}
return nil
}
Funções Auxiliares
A função getLabelForApp foi criada pra facilitar a adição de labels nos objetos criados pelo controller, ela é utilizada em todos os objetos.
Todas as funções auxiliares (deploymentForApp, serviceForApp, ingressForApp) usam ctrl.SetControllerReference().
O que isso faz?
- Cria um vínculo de propriedade entre o recurso
Appe os objetos criados (Deployment, Service, Ingress) - Quando o
Appé deletado, o Garbage Collector do Kubernetes remove automaticamente todos os recursos associados - Isso evita “recursos órfãos” no cluster
Sem isso: Você teria que deletar manualmente cada Deployment, Service e Ingress.
Com isso: Um único kubectl delete app app-sample limpa tudo!
getLabelsForApp() – Labels Padronizadas
Retorna um mapa/dicionario com as labels definidas
func getLabelsForApp(app *platformv1alpha1.App) map[string]string {
return map[string]string{
"app.kubernetes.io/name": app.Name,
"app.kubernetes.io/managed-by": "platform-app-operator",
}
}
deploymentForApp() – Constrói o Deployment()
Ao iniciar essa função ela realiza algumas validações como:
- Se a porta foi passada na spec da app, senão ele define a porta padrao para o container (8080)
- Se o numero de replicas foram definidas. senão ele define a quantidade minima de replicas para 1
Depois inicia-se a criação do deployment, utilizando como nome e namespace do deploy os mesmo das app e para as labels utilizamos a getLabelsForApp()
// deploymentForApp returns a App Deployment object
func (r *AppReconciler) deploymentForApp(app *platformv1alpha1.App) (*appsv1.Deployment, error) {
replicas := int32(1)
if app.Spec.Deploy.Replicas != nil {
replicas = *app.Spec.Deploy.Replicas
}
port := int32(8080)
if app.Spec.Deploy.ContainerPort != nil {
port = *app.Spec.Deploy.ContainerPort
}
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
Labels: getLabelsForApp(app),
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: getLabelsForApp(app),
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: getLabelsForApp(app),
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "app",
Image: app.Spec.Deploy.Image,
Ports: []corev1.ContainerPort{
{
Name: "http",
ContainerPort: port,
Protocol: corev1.ProtocolTCP,
},
},
},
},
},
},
},
}
err := ctrl.SetControllerReference(app, dep, r.Scheme)
if err != nil {
return nil, err
}
return dep, nil
}
serviceForApp() – Constrói o Service
Ao iniciar essa função ela realiza algumas validações como:
- Se a porta foi passada na spec da app, senão ele define a porta padrao para o container (8080)
Depois inicia-se a criação do service, utilizando como nome e namespace do service os mesmos da app e para as labels utilizamos a função getLabelsForApp().
essa funcão retorna as labels para os objetos criados pelo controller.
// serviceForApp returns a App service object
func (r *AppReconciler) serviceForApp(app *platformv1alpha1.App) (*corev1.Service, error) {
serviceName := fmt.Sprintf("service-%s", app.Name)
port := int32(8080)
if app.Spec.Service.Port == nil {
port = *app.Spec.Deploy.ContainerPort
}
if app.Spec.Service.Port != nil {
port = *app.Spec.Service.Port
}
targetPort := int32(8080)
if app.Spec.Deploy.ContainerPort != nil {
targetPort = *app.Spec.Deploy.ContainerPort
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: serviceName,
Namespace: app.Namespace,
Labels: getLabelsForApp(app),
},
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Name: "app",
Port: port,
TargetPort: intstr.FromInt32(targetPort),
Protocol: "TCP",
},
},
Selector: getLabelsForApp(app),
},
}
err := ctrl.SetControllerReference(app, svc, r.Scheme)
if err != nil {
return nil, err
}
return svc, nil
}
ingressForApp() – Constrói o Ingress
func (r *AppReconciler) ingressForApp(app *platformv1alpha1.App) (*networkingv1.Ingress, error) {
ingressName := fmt.Sprintf("ingress-%s", app.Name)
pathTypeImplementationSpecific := networkingv1.PathTypeImplementationSpecific
path := app.Spec.Ingress.Path
if path == "" {
path = "https://dev.to/"
}
ingress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: ingressName,
Namespace: app.Namespace,
Labels: getLabelsForApp(app),
},
Spec: networkingv1.IngressSpec{
Rules: []networkingv1.IngressRule{
networkingv1.IngressRule{
Host: app.Spec.Ingress.Host,
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
networkingv1.HTTPIngressPath{
Path: path,
PathType: &pathTypeImplementationSpecific,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "service-" + app.Name,
Port: networkingv1.ServiceBackendPort{
Name: "app",
},
},
},
},
},
},
},
},
},
},
}
err := ctrl.SetControllerReference(app, ingress, r.Scheme)
if err != nil {
return nil, err
}
return ingress, nil
}
Função SetupWithManager
O que a função SetupWithManager faz: ela registra um controller no cluster de Kubernetes e também é responsável por configurar qual recurso ele vai monitorar, quando ele vai reagir e quais recursos ele gerencia.
// SetupWithManager sets up the controller with the Manager.
func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&platformv1alpha1.App{}, builder.WithPredicates(predicate.Funcs{
UpdateFunc: func(e event.TypedUpdateEvent[client.Object]) bool {
log := ctrl.Log.WithName("Predicate Update")
appOld, oldok := e.ObjectOld.(*platformv1alpha1.App)
appNew, newok := e.ObjectNew.(*platformv1alpha1.App)
if !oldok || !newok {
return false
}
specsChanged := !cmp.Equal(appOld.Spec, appNew.Spec)
if specsChanged {
diffSpecs := cmp.Diff(appOld.Spec, appNew.Spec)
log.Info("platform specs update", "app", appNew.Name, "specs", diffSpecs)
}
return specsChanged
},
})).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Owns(&networkingv1.Ingress{}).
Named("app").
Complete(r)
}
Evitando Reconciliações Desnecessárias
Para evitar reconciliações desnecessárias, o predicate UpdateFunc faz com que o controller só processe alterações no campo .spec do recurso App.
Como funciona:
-
Captura o evento de update: Quando um objeto
Appé atualizado no cluster, o predicate recebe o estado antigo (appOld) e o novo (appNew) -
Valida os objetos: Faz o type assertion para garantir que são objetos do tipo
Appválidos
appOld, oldok := e.ObjectOld.(*platformv1alpha1.App)
appNew, newok := e.ObjectNew.(*platformv1alpha1.App)
if !oldok || !newok {
return false // Se inválido, ignora o evento
}
-
Compara apenas o Spec: Usa a biblioteca
cmppara comparar a spec atual com a anterior
specsChanged := !cmp.Equal(appOld.Spec, appNew.Spec)
- Loga as diferenças: Se houver mudanças, registra no log exatamente o que foi alterado
if specsChanged {
diffSpecs := cmp.Diff(appOld.Spec, appNew.Spec)
log.Info("platform specs update", "app", appNew.Name, "specs", diffSpecs)
}
-
Retorna o resultado: Se a spec mudou, retorna
truesinalizando para o controller iniciar a reconciliação. Se não mudou (por exemplo, apenas o.statusfoi atualizado), retornafalsee evita processamento desnecessário
Gerenciando Recursos Dependentes
O método Owns() declara quais recursos Kubernetes este controller gerencia:
Owns(&appsv1.Deployment{}). // Gerencia os Deployments criados pela App
Owns(&corev1.Service{}). // Gerencia os Services criados pela App
Owns(&networkingv1.Ingress{}). // Gerencia os Ingresses criados pela App
Quando qualquer um desses recursos é modificado ou deletado, o controller é notificado e pode reconciliar o estado desejado.
Testando o Operador
Build e Deploy
# gerar/manter manifests
make manifests
# instalar CRD no cluster atual
make install
# rodar o controller localmente (usa kubeconfig atual)
make run
O que você verá:
2025-01-15T10:30:00.000Z INFO controller-runtime.metrics Metrics server is starting to listen {"addr": ":8080"}
2025-01-15T10:30:00.001Z INFO setup starting manager
2025-01-15T10:30:00.002Z INFO Starting server {"kind": "health probe", "addr": "[::]:8081"}
2025-01-15T10:30:00.003Z INFO Starting EventSource {"controller": "app", "source": "kind source: *v1alpha1.App"}
2025-01-15T10:30:00.104Z INFO Starting Controller {"controller": "app"}
2025-01-15T10:30:00.204Z INFO Starting workers {"controller": "app", "worker count": 1}
Criar um Exemplo de App
Edite config/samples/platform_v1alpha1_app.yaml:
apiVersion: platform.example.com/v1alpha1
kind: App
metadata:
name: app-sample
namespace: default
spec:
deploy:
image: nginx:1.27
replicas: 2
containerPort: 8080
service:
targetPort: 8080
port: 9093
ingress:
host: minha-app.local
path: /
# em outro terminal, aplicar o sample
kubectl apply -f config/samples/platform_v1alpha1_app.yaml
Verificando os Recursos Criados
# verificar recursos
kubectl get app,deploy,svc,ing -n default -o wide
kubectl get app minha-app -n default -o jsonpath='{.status}\n'
Logs do operador:
2025-01-15T10:35:00.000Z INFO Starting reconciliation {"app": "default/app-sample"}
2025-01-15T10:35:00.050Z INFO Creating new Deployment {"namespace": "default", "name": "app-sample"}
2025-01-15T10:35:00.150Z INFO Creating new Service {"namespace": "default", "name": "service-app-sample"}
2025-01-15T10:35:00.250Z INFO Creating new Ingress {"namespace": "default", "name": "ingress-app-sample"}
2025-01-15T10:35:00.350Z INFO Updating App status {"availableReplicas": 0, "url": "http://minha-app.local/"}
Output Esperado
$ kubectl get app,deploy,svc,ing -n default
NAME
app.platform.example.com/app-sample
NAME READY UP-TO-DATE AVAILABLE
deployment.apps/app-sample 2/2 2 2
NAME TYPE CLUSTER-IP PORT(S)
service/service-app-sample ClusterIP 10.96.123.45 9093/TCP
NAME HOSTS PORTS
ingress.networking.k8s.io/ingress-app-sample minha-app.local 80
$ kubectl get app app-sample -o jsonpath="{.status}" | jq
{
"availableReplicas": 2,
"url": "http://minha-app.local/",
"observedGeneration": 1
}
O que aprendemos com esse exemplo
Esse operador é propositalmente simples, mas já mostra o poder da abordagem:
Ao aplicar o manifesto de app, o operador entra em ação:
- Cria o Deployment do NGINX.
- Cria o Service apontando para o Deployment.
- Cria o Ingress configurado para o host definido.
Ou seja: em vez de escrever 3 YAMLs diferentes, bastou um único recurso customizado.
- Ele reduz YAML — de três arquivos para um só.
- Ele se atualiza sozinho: se você trocar a imagem ou o host, os recursos são reconciliados automaticamente.
O código completo do operador esta nesse repositorio
Próximos passos e melhorias
- TLS automático com cert-manager (adicionar annotations e emissão de certificados).
- Annotations e classes de Ingress: permitir configurações avançadas de roteamento.
- Probes e HPA (readiness/liveness, HorizontalPodAutoscaler).
- Configuração via ConfigMaps/Secrets e variáveis de ambiente.
- Finalizers para limpeza/rotinas antes do delete.
- Webhooks para validações/mutações avançadas (ex: defaults dinâmicos, políticas internas).
Conclusão
Operadores são uma forma poderosa de ensinar o Kubernetes a trabalhar por você. Eles vão além do que Helm pode oferecer, porque conseguem reagir a mudanças, aplicar lógica de negócio e integrar com sistemas externos.
O exemplo que mostrei é apenas uma porta de entrada. A ideia não é que você use exatamente esse operador em produção, mas que veja como é possível começar pequeno e, aos poucos, evoluir para casos mais complexos.
Se você já se sente confortável escrevendo manifestos ou usando Helm, o próximo passo natural é experimentar escrever o seu próprio operador. Quem sabe aquele processo chato e repetitivo que você faz todos os dias não pode virár um CRD com automação total?


