Boas Práticas de Operators em Kubernetes: Construindo Controladores Customizados para Times Ágeis Já leu

Introdução: O que são Operators em Kubernetes Um Kubernetes Operator é um padrão de design que encapsula conhecimento operacional complexo em código executável. Ele estende as capacidades nativas do Kubernetes através de Custom Resources (CRs) e controladores customizados, permitindo automatizar tarefas repetitivas e complexas de gerenciamento de aplicações stateful. Enquanto recursos nativos como Deployments e StatefulSets lidam bem com aplicações stateless ou com estado simples, aplicações complexas como bancos de dados, sistemas de mensageria ou plataformas de análise exigem lógica sofisticada: provisioning, backup, failover, scaling inteligente e updates coordenados. Um Operator codifica essa expertise, tornando-a reutilizável e escalável. Fundamentos: Custom Resources e CRDs O que é uma Custom Resource Definition Uma Custom Resource Definition (CRD) é um mecanismo que permite estender a API do Kubernetes com novos tipos de recursos. Sem uma CRD, você só pode trabalhar com resources nativos (Pods, Services, etc.). Com uma CRD, você define a estrutura e validação de seus próprios recursos, criando uma abstraçãohigher-level para

Introdução: O que são Operators em Kubernetes

Um Kubernetes Operator é um padrão de design que encapsula conhecimento operacional complexo em código executável. Ele estende as capacidades nativas do Kubernetes através de Custom Resources (CRs) e controladores customizados, permitindo automatizar tarefas repetitivas e complexas de gerenciamento de aplicações stateful.

Enquanto recursos nativos como Deployments e StatefulSets lidam bem com aplicações stateless ou com estado simples, aplicações complexas como bancos de dados, sistemas de mensageria ou plataformas de análise exigem lógica sofisticada: provisioning, backup, failover, scaling inteligente e updates coordenados. Um Operator codifica essa expertise, tornando-a reutilizável e escalável.

Fundamentos: Custom Resources e CRDs

O que é uma Custom Resource Definition

Uma Custom Resource Definition (CRD) é um mecanismo que permite estender a API do Kubernetes com novos tipos de recursos. Sem uma CRD, você só pode trabalhar com resources nativos (Pods, Services, etc.). Com uma CRD, você define a estrutura e validação de seus próprios recursos, criando uma abstraçãohigher-level para suas aplicações.

Vamos criar uma CRD simples para gerenciar uma aplicação de banco de dados PostgreSQL customizado:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresinstances.database.example.com
spec:
  group: database.example.com
  names:
    kind: PostgresInstance
    plural: postgresinstances
    shortNames:
      - pg
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                version:
                  type: string
                  description: "Versão do PostgreSQL"
                  example: "14.5"
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 10
                  description: "Número de replicas"
                storage:
                  type: string
                  description: "Tamanho do volume de armazenamento"
                  example: "10Gi"
              required:
                - version
                - replicas
                - storage
            status:
              type: object
              properties:
                ready:
                  type: boolean
                primaryPod:
                  type: string
                replicaPods:
                  type: array
                  items:
                    type: string

Essa CRD define um novo tipo de recurso chamado PostgresInstance. Agora você pode criar instâncias desse tipo:

apiVersion: database.example.com/v1
kind: PostgresInstance
metadata:
  name: production-db
  namespace: default
spec:
  version: "14.5"
  replicas: 3
  storage: "100Gi"

A estrutura Spec e Status

A separação entre spec (desejado) e status (observado) é fundamental em Kubernetes. O spec descreve o estado que você quer, enquanto o status reflete o estado atual do recurso. Seu Operator constantemente observa a diferença e age para reconciliar.

Implementação: Construindo um Controlador com Go

Configuração do Projeto

Para construir Operators em produção, usaremos o Operator SDK, que fornece scaffolding automático e bibliotecas essenciais. Aqui assumo que você já tem Go 1.20+ e kubebuilder/operator-sdk instalados.

operator-sdk init --domain example.com --repo github.com/example/postgres-operator
operator-sdk create api --group database --version v1 --kind PostgresInstance --resource --controller

Esses comandos criam a estrutura básica do projeto. Você encontrará:
- api/v1/postgresinstance_types.go — definição da CRD em Go
- controllers/postgresinstance_controller.go — lógica do controlador
- config/crd/ — manifestos YAML das CRDs

Definindo a CRD em Go

O arquivo api/v1/postgresinstance_types.go define a estrutura Go que representa seu recurso:

package v1

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// PostgresInstanceSpec define as propriedades desejadas
type PostgresInstanceSpec struct {
    Version string `json:"version,omitempty"`
    Replicas int32 `json:"replicas,omitempty"`
    Storage string `json:"storage,omitempty"`
}

// PostgresInstanceStatus define o estado observado
type PostgresInstanceStatus struct {
    Ready bool `json:"ready,omitempty"`
    PrimaryPod string `json:"primaryPod,omitempty"`
    ReplicaPods []string `json:"replicaPods,omitempty"`
    ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type PostgresInstance struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   PostgresInstanceSpec   `json:"spec,omitempty"`
    Status PostgresInstanceStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type PostgresInstanceList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []PostgresInstance `json:"items"`
}

func init() {
    SchemeBuilder.Register(&PostgresInstance{}, &PostgresInstanceList{})
}

Os comentários com +kubebuilder são marcadores que geram código e manifests automaticamente durante a build.

Implementando a Lógica do Controlador

O controlador é o coração do Operator. Ele observa recursos PostgresInstance e realiza ações (criar Pods, StatefulSets, Services, etc.). Aqui está uma implementação funcional simplificada:

package controllers

import (
    "context"
    "fmt"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/types"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/predicate"

    databasev1 "github.com/example/postgres-operator/api/v1"
)

const finalizerName = "database.example.com/postgres-finalizer"

// PostgresInstanceReconciler reconcilia objetos PostgresInstance
type PostgresInstanceReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=database.example.com,resources=postgresinstances,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=database.example.com,resources=postgresinstances/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete

// Reconcile é chamado sempre que há mudanças no recurso observado
func (r *PostgresInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := ctrl.LoggerFrom(ctx)

    // Busca a instância PostgreSQL
    pg := &databasev1.PostgresInstance{}
    if err := r.Get(ctx, req.NamespacedName, pg); err != nil {
        if apierrors.IsNotFound(err) {
            log.Info("PostgresInstance não encontrada, ignorando")
            return ctrl.Result{}, nil
        }
        log.Error(err, "Erro ao buscar PostgresInstance")
        return ctrl.Result{}, err
    }

    // Handle deletion with finalizer
    if pg.ObjectMeta.DeletionTimestamp != nil {
        if controllerutil.ContainsFinalizer(pg, finalizerName) {
            // Aqui você faria lógica de limpeza (backup, etc)
            log.Info("Limpando recursos do PostgresInstance", "name", pg.Name)

            controllerutil.RemoveFinalizer(pg, finalizerName)
            if err := r.Update(ctx, pg); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // Add finalizer se não existe
    if !controllerutil.ContainsFinalizer(pg, finalizerName) {
        controllerutil.AddFinalizer(pg, finalizerName)
        if err := r.Update(ctx, pg); err != nil {
            return ctrl.Result{}, err
        }
    }

    // Cria ou atualiza o StatefulSet para o PostgreSQL
    sts := &appsv1.StatefulSet{}
    stsName := types.NamespacedName{Name: pg.Name, Namespace: pg.Namespace}

    if err := r.Get(ctx, stsName, sts); err != nil {
        if apierrors.IsNotFound(err) {
            log.Info("Criando novo StatefulSet")
            sts = r.constructStatefulSet(pg)
            if err := controllerutil.SetControllerReference(pg, sts, r.Scheme); err != nil {
                return ctrl.Result{}, err
            }
            if err := r.Create(ctx, sts); err != nil {
                log.Error(err, "Erro ao criar StatefulSet")
                return ctrl.Result{}, err
            }
        } else {
            log.Error(err, "Erro ao buscar StatefulSet")
            return ctrl.Result{}, err
        }
    } else {
        // StatefulSet existe, atualiza se necessário
        sts.Spec.Replicas = &pg.Spec.Replicas
        if err := r.Update(ctx, sts); err != nil {
            log.Error(err, "Erro ao atualizar StatefulSet")
            return ctrl.Result{}, err
        }
    }

    // Cria ou atualiza o Service
    svc := &corev1.Service{}
    svcName := types.NamespacedName{Name: pg.Name, Namespace: pg.Namespace}

    if err := r.Get(ctx, svcName, svc); err != nil {
        if apierrors.IsNotFound(err) {
            log.Info("Criando novo Service")
            svc = r.constructService(pg)
            if err := controllerutil.SetControllerReference(pg, svc, r.Scheme); err != nil {
                return ctrl.Result{}, err
            }
            if err := r.Create(ctx, svc); err != nil {
                log.Error(err, "Erro ao criar Service")
                return ctrl.Result{}, err
            }
        } else {
            log.Error(err, "Erro ao buscar Service")
            return ctrl.Result{}, err
        }
    }

    // Atualiza o status
    pg.Status.Ready = true
    pg.Status.PrimaryPod = fmt.Sprintf("%s-0", pg.Name)
    pg.Status.ReplicaPods = make([]string, 0)
    for i := int32(1); i < pg.Spec.Replicas; i++ {
        pg.Status.ReplicaPods = append(pg.Status.ReplicaPods, fmt.Sprintf("%s-%d", pg.Name, i))
    }
    pg.Status.ObservedGeneration = pg.Generation

    if err := r.Status().Update(ctx, pg); err != nil {
        log.Error(err, "Erro ao atualizar status")
        return ctrl.Result{}, err
    }

    log.Info("PostgresInstance reconciliado com sucesso", "name", pg.Name)
    return ctrl.Result{}, nil
}

// constructStatefulSet cria o StatefulSet para o PostgreSQL
func (r *PostgresInstanceReconciler) constructStatefulSet(pg *databasev1.PostgresInstance) *appsv1.StatefulSet {
    labels := map[string]string{
        "app":      "postgres",
        "instance": pg.Name,
    }

    sts := &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      pg.Name,
            Namespace: pg.Namespace,
            Labels:    labels,
        },
        Spec: appsv1.StatefulSetSpec{
            ServiceName: pg.Name,
            Replicas:    &pg.Spec.Replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: labels,
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: labels,
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "postgres",
                            Image: fmt.Sprintf("postgres:%s", pg.Spec.Version),
                            Ports: []corev1.ContainerPort{
                                {
                                    Name:          "postgres",
                                    ContainerPort: 5432,
                                    Protocol:      corev1.ProtocolTCP,
                                },
                            },
                            Env: []corev1.EnvVar{
                                {
                                    Name:  "POSTGRES_PASSWORD",
                                    Value: "changeme",
                                },
                            },
                            VolumeMounts: []corev1.VolumeMount{
                                {
                                    Name:      "data",
                                    MountPath: "/var/lib/postgresql/data",
                                },
                            },
                        },
                    },
                },
            },
            VolumeClaimTemplates: []corev1.PersistentVolumeClaim{
                {
                    ObjectMeta: metav1.ObjectMeta{
                        Name: "data",
                    },
                    Spec: corev1.PersistentVolumeClaimSpec{
                        AccessModes: []corev1.PersistentVolumeAccessMode{
                            corev1.ReadWriteOnce,
                        },
                        Resources: corev1.ResourceRequirements{
                            Requests: corev1.ResourceList{
                                corev1.ResourceStorage: *parseQuantity(pg.Spec.Storage),
                            },
                        },
                    },
                },
            },
        },
    }

    return sts
}

// constructService cria o Service para o PostgreSQL
func (r *PostgresInstanceReconciler) constructService(pg *databasev1.PostgresInstance) *corev1.Service {
    labels := map[string]string{
        "app":      "postgres",
        "instance": pg.Name,
    }

    svc := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      pg.Name,
            Namespace: pg.Namespace,
            Labels:    labels,
        },
        Spec: corev1.ServiceSpec{
            Selector: labels,
            Ports: []corev1.ServicePort{
                {
                    Name:       "postgres",
                    Port:       5432,
                    TargetPort: intstr.FromString("postgres"),
                    Protocol:   corev1.ProtocolTCP,
                },
            },
            ClusterIP: corev1.ClusterIPNone, // Headless service para StatefulSets
        },
    }

    return svc
}

// SetupWithManager registra o controlador com o manager
func (r *PostgresInstanceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&databasev1.PostgresInstance{}).
        Owns(&appsv1.StatefulSet{}).
        Owns(&corev1.Service{}).
        WithEventFilter(predicate.GenerationChangedPredicate{}). // Ignora updates de status
        Complete(r)
}

// Helper para parsear quantity
func parseQuantity(s string) *resource.Quantity {
    q, _ := resource.ParseQuantity(s)
    return &q
}

Ciclo de Vida e Padrões Avançados

Reconciliação: O Loop Fundamental

A reconciliação é o mecanismo central dos Operators. O controlador observa recursos e, quando há mudanças (criação, update, delete), o método Reconcile é acionado. Importante: o reconciliador deve ser idempotente — pode ser chamado múltiplas vezes e o resultado final deve ser sempre o mesmo.

No exemplo anterior, nossa lógica:
1. Busca o recurso PostgresInstance
2. Verifica se está sendo deletado (finalizers)
3. Cria ou atualiza StatefulSet e Service
4. Atualiza o status refletindo o estado real

Essa lógica roda continuously. Se alguém deletar manualmente o StatefulSet, na próxima reconciliação ele será recriado automaticamente.

Finalizers: Limpeza Segura

Quando você deleta um recurso, Kubernetes normalmente remove-o imediatamente. Com finalizers, você pode executar lógica de limpeza antes da deletion definitiva. No exemplo acima, adicionamos um finalizer que permite fazer backup ou limpeza de dados antes de remover o PostgresInstance.

Owner References: Rastreamento de Dependências

Usamos SetControllerReference para estabelecer uma relação pai-filho entre o PostgresInstance (pai) e seus StatefulSet e Service (filhos). Quando o pai é deletado, os filhos são automaticamente removidos (garbage collection).

Observabilidade: Logs e Métricas

import (
    "sigs.k8s.io/controller-runtime/pkg/log"
)

// No seu Reconcile:
log := log.FromContext(ctx)
log.Info("Iniciando reconciliação", "instance", pg.Name, "replicas", pg.Spec.Replicas)
log.Error(err, "Falha ao criar StatefulSet", "name", pg.Name)

O controller-runtime integra-se com loggers estruturados (Zap, por padrão) e você consegue rastrear exatamente o que seu Operator está fazendo.

Deployment e Testes

Gerando e Instalando a CRD

Após implementar seu controlador, execute:

make manifests

Isso gera os YAMLs em config/crd/bases/. Para instalar localmente em um cluster:

kubectl apply -f config/crd/bases/

Rodando o Operator Localmente

Durante desenvolvimento, você pode rodar o controlador na sua máquina:

make install run

Isso instala a CRD e executa o Operator localmente, conectando-se ao seu cluster via kubeconfig.

Buildando a Imagem Docker

Para deployar em produção, você precisa criar uma imagem Docker:

# Dockerfile (já fornecido pelo Operator SDK)
FROM golang:1.20 as builder
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-w -s" -o manager main.go

FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532
ENTRYPOINT ["/manager"]
docker build -t myregistry/postgres-operator:v0.1.0 .
docker push myregistry/postgres-operator:v0.1.0

Testando com Envtest

Para testes unitários, use o framework envtest que spinna um APIServer e etcd reais:

package controllers

import (
    "testing"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "k8s.io/client-go/kubernetes/scheme"
    "sigs.k8s.io/controller-runtime/pkg/envtest"
    logf "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"

    databasev1 "github.com/example/postgres-operator/api/v1"
)

var testEnv *envtest.Environment

func TestAPIs(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
    logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))

    testEnv = &envtest.Environment{}
    cfg, err := testEnv.Start()
    Expect(err).NotTo(HaveOccurred())

    err = databasev1.AddToScheme(scheme.Scheme)
    Expect(err).NotTo(HaveOccurred())
})

var _ = AfterSuite(func() {
    Expect(testEnv.Stop()).To(Succeed())
})

Conclusão

Ao longo deste artigo, você aprendeu que Operators não são aplicações genéricas, mas codificações de conhecimento operacional específico. Você agora compreende como CRDs funcionam como contratos que definem a API do seu Operator, permitindo que usuários declarem intenção ao invés de descrever procedimentos.

O padrão de reconciliação contínua, embora possa parecer simples, é extraordinariamente poderoso: o controlador sempre busca o estado desejado (declarado na CRD) e o estado real (Pods, Services, etc) e age para reconciliá-los. Isso significa que seu Operator recupera-se automaticamente de falhas, não é necessário polling manual e a lógica é centralizada e versionável.

Por fim, lembre-se que a qualidade de um Operator está na robustez, observabilidade e documentação. Testes automatizados, logs estruturados, finalizers bem implementados e CRDs com validação clara transformam um código inicial em uma ferramenta confiável que outros engenheiros podem usar com segurança em seus clusters.

Referências


Artigos relacionados