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.