Construindo um Operador Kubernetes com Go




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: /  

Enter fullscreen mode

Exit fullscreen mode




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:

  1. Observar o cluster (ficar “de olho” nos recursos).
  2. Decidir o que precisa ser feito (comparar estado desejado com estado atual).
  3. Agir criando, atualizando ou removendo objetos.
    Dessa forma, ele automatiza operações que antes precisariam ser feitas manualmente.

[imagemdofluxodocontroller]

reconciliationflow




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
Enter fullscreen mode

Exit fullscreen mode



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
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

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 
Enter fullscreen mode

Exit fullscreen mode



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"`
}
Enter fullscreen mode

Exit fullscreen mode



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
Enter fullscreen mode

Exit fullscreen mode



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
Enter fullscreen mode

Exit fullscreen mode

Esse passo é importante porque o Kubebuilder vai:

  • Atualizar o CRD (CustomResourceDefinition) com os novos campos definidos no AppSpec e AppStatus.
  • 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.

app_controller.go

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
    }

Enter fullscreen mode

Exit fullscreen mode

O que está acontecendo:

  • Cria um logger para registrar as operações
  • Tenta buscar o recurso App no 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")
    }

Enter fullscreen mode

Exit fullscreen mode




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
}
Enter fullscreen mode

Exit fullscreen mode

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
}
Enter fullscreen mode

Exit fullscreen mode



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
}
Enter fullscreen mode

Exit fullscreen mode



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
}

Enter fullscreen mode

Exit fullscreen mode




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 App e 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",
    }
}

Enter fullscreen mode

Exit fullscreen mode



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
}


Enter fullscreen mode

Exit fullscreen mode



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
}

Enter fullscreen mode

Exit fullscreen mode



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
}

Enter fullscreen mode

Exit fullscreen mode



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)
}
Enter fullscreen mode

Exit fullscreen mode



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:

  1. Captura o evento de update: Quando um objeto App é atualizado no cluster, o predicate recebe o estado antigo (appOld) e o novo (appNew)
  2. Valida os objetos: Faz o type assertion para garantir que são objetos do tipo App válidos
   appOld, oldok := e.ObjectOld.(*platformv1alpha1.App)
   appNew, newok := e.ObjectNew.(*platformv1alpha1.App)
   if !oldok || !newok {
       return false  // Se inválido, ignora o evento
   }
Enter fullscreen mode

Exit fullscreen mode

  1. Compara apenas o Spec: Usa a biblioteca cmp para comparar a spec atual com a anterior
   specsChanged := !cmp.Equal(appOld.Spec, appNew.Spec)
Enter fullscreen mode

Exit fullscreen mode

  1. 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)
   }
Enter fullscreen mode

Exit fullscreen mode

  1. Retorna o resultado: Se a spec mudou, retorna true sinalizando para o controller iniciar a reconciliação. Se não mudou (por exemplo, apenas o .status foi atualizado), retorna false e 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
Enter fullscreen mode

Exit fullscreen mode

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

Enter fullscreen mode

Exit fullscreen mode

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}

Enter fullscreen mode

Exit fullscreen mode



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: /
Enter fullscreen mode

Exit fullscreen mode

# em outro terminal, aplicar o sample
kubectl apply -f config/samples/platform_v1alpha1_app.yaml

Enter fullscreen mode

Exit fullscreen mode



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'

Enter fullscreen mode

Exit fullscreen mode

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/"}
Enter fullscreen mode

Exit fullscreen mode



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

Enter fullscreen mode

Exit fullscreen mode

$ kubectl get app app-sample -o jsonpath="{.status}" | jq

{
  "availableReplicas": 2,
  "url": "http://minha-app.local/",
  "observedGeneration": 1
}

Enter fullscreen mode

Exit fullscreen mode




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:

  1. Cria o Deployment do NGINX.
  2. Cria o Service apontando para o Deployment.
  3. 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?



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *