DevOps

Kubernetes Operators en Go: automatización de recursos personalizados en 2026

Ruslan Ismailov Publicado 18 min de lectura
K

Introducción: qué es un Kubernetes Operator y para qué sirve

Kubernetes ofrece una potente API declarativa para gestionar infraestructura, pero las abstracciones estándar —Deployment, StatefulSet, Service— a menudo no son suficientes para administrar aplicaciones stateful complejas. Los charts de Helm resuelven el problema de la plantilla de manifiestos, pero no pueden reaccionar a eventos del clúster ni tomar decisiones en tiempo real. El Kubernetes Operator cubre este vacío: codifica el conocimiento operativo (runbook) del ingeniero directamente en un controlador que se ejecuta dentro del clúster.

Un Operator es un patrón de extensión de Kubernetes compuesto por dos partes: una Custom Resource Definition (CRD) que describe el estado deseado, y un controlador que lleva el estado real del clúster al estado deseado. A diferencia de Helm, un Operator puede realizar backups programados, recuperar automáticamente un clúster PostgreSQL tras un fallo, rotar secretos y ejecutar rolling upgrades con lógica de negocio personalizada.

En 2026, el patrón Operator se ha convertido en el estándar de facto para los equipos de plataforma: OperatorHub.io cuenta con más de 400 soluciones listas, y el ecosistema de herramientas en Go —kubebuilder y controller-runtime— ha alcanzado un alto nivel de madurez. En este artículo recorreremos el camino desde los conceptos hasta un Operator funcional para gestionar instancias de PostgreSQL.

Conceptos clave: CRD, controlador y reconciliation loop

Custom Resource Definition

Una CRD es una extensión del esquema de la API de Kubernetes. Una vez aplicado el manifiesto CRD, el clúster comienza a aceptar objetos de un nuevo tipo, por ejemplo PostgreSQLCluster. Los usuarios interactúan con él mediante kubectl de la misma forma que con un Deployment.

apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  name: postgresqlclusters.db.example.com\nspec:\n  group: db.example.com\n  versions:\n    - name: v1alpha1\n      served: true\n      storage: true\n      schema:\n        openAPIV3Schema:\n          type: object\n          properties:\n            spec:\n              type: object\n              properties:\n                replicas:\n                  type: integer\n                  minimum: 1\n                version:\n                  type: string\n            status:\n              type: object\n              properties:\n                phase:\n                  type: string\n  scope: Namespaced\n  names:\n    plural: postgresqlclusters\n    singular: postgresqlcluster\n    kind: PostgreSQLCluster\n

Reconciliation Loop

El corazón de cualquier Operator es el reconciliation loop. El controlador se suscribe a eventos (creación, modificación, eliminación de objetos) y en cada evento invoca la función Reconcile. Esta función obtiene el estado actual del clúster y decide qué acciones ejecutar para que el estado real coincida con el estado deseado descrito en el spec. La propiedad más importante del reconciler es la idempotencia: una invocación repetida con el mismo estado no debe producir efectos secundarios.

«No pienses en eventos, piensa en estado. El reconciler siempre responde a la pregunta: ¿qué hay que hacer ahora mismo para alcanzar el estado deseado?»

Herramientas: kubebuilder vs operator-sdk en 2026

Las dos principales herramientas de scaffolding para operadores en Go son kubebuilder (mantenido por sig-controller-tools) y operator-sdk (Red Hat). En 2026 la diferencia entre ambos es mínima: operator-sdk usa kubebuilder como base y añade integración con OLM (Operator Lifecycle Manager) y plugins para operadores Ansible/Helm.

  • kubebuilder — la elección para equipos que quieren mínimo overhead y control total sobre el código.
  • operator-sdk — la elección para equipos que planean publicar en OperatorHub o trabajan en el ecosistema OpenShift.
  • Ambos usan controller-runtime — una biblioteca Go que abstrae el trabajo de bajo nivel con la API de Kubernetes.

En este artículo usamos kubebuilder v4, la versión vigente en 2026.

Creación paso a paso de un Operator en Go

Paso 1: Scaffolding del proyecto

Instala kubebuilder e inicializa el proyecto:

# Instalación de kubebuilder\ncurl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)\nchmod +x kubebuilder && mv kubebuilder /usr/local/bin/\n\n# Inicialización del proyecto\nmkdir postgres-operator && cd postgres-operator\nkubebuilder init --domain example.com --repo github.com/example/postgres-operator\n\n# Creación de la API y el controlador\nkubebuilder create api --group db --version v1alpha1 --kind PostgreSQLCluster\n

Tras ejecutar los comandos, kubebuilder genera la estructura del proyecto: api/v1alpha1/ contiene los tipos Go para la CRD, internal/controller/ contiene el esqueleto del reconciler, y config/ los manifiestos de kustomize.

Paso 2: Definición de los tipos CRD en Go

// api/v1alpha1/postgresqlcluster_types.go
package v1alpha1

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

// PostgreSQLClusterSpec describe el estado deseado del clúster
type PostgreSQLClusterSpec struct {
    // Replicas — número de réplicas (1 primary + N standbys)
    Replicas int32 `json:"replicas"`
    // Version — versión de PostgreSQL, por ejemplo "16.2"
    Version string `json:"version"`
    // StorageSize — tamaño del PVC para cada instancia
    StorageSize string `json:"storageSize"`
    // BackupSchedule — programación cron para los backups
    BackupSchedule string `json:"backupSchedule,omitempty"`
}

// PostgreSQLClusterStatus refleja el estado real
type PostgreSQLClusterStatus struct {
    Phase      string `json:"phase,omitempty"`
    ReadyNodes int32  `json:"readyNodes,omitempty"`
    Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyNodes"
type PostgreSQLCluster struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   PostgreSQLClusterSpec   `json:"spec,omitempty"`
    Status PostgreSQLClusterStatus `json:"status,omitempty"`
}

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

func init() {
    SchemeBuilder.Register(&PostgreSQLCluster{}, &PostgreSQLClusterList{})
}

Tras modificar los tipos, ejecuta make generate manifests — controller-gen actualizará automáticamente el CRD YAML y generará los métodos DeepCopy.

Paso 3: Implementación del Reconciler

// internal/controller/postgresqlcluster_controller.go
package controller

import (
    "context"
    "fmt"

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

    dbv1alpha1 "github.com/example/postgres-operator/api/v1alpha1"
)

type PostgreSQLClusterReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=db.example.com,resources=postgresqlclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=db.example.com,resources=postgresqlclusters/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

func (r *PostgreSQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. Obtenemos el objeto del clúster
    cluster := &dbv1alpha1.PostgreSQLCluster{}
    if err := r.Get(ctx, req.NamespacedName, cluster); err != nil {
        if errors.IsNotFound(err) {
            // El objeto fue eliminado — no hay nada que hacer
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, fmt.Errorf("failed to get PostgreSQLCluster: %w", err)
    }

    logger.Info("Reconciling", "name", cluster.Name, "phase", cluster.Status.Phase)

    // 2. Verificamos que el StatefulSet existe y coincide con el spec
    if err := r.reconcileStatefulSet(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    // 3. Verificamos que el Service existe
    if err := r.reconcileService(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    // 4. Actualizamos el estado
    if err := r.updateStatus(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

func (r *PostgreSQLClusterReconciler) reconcileStatefulSet(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    desired := r.buildStatefulSet(cluster)

    // Establecemos la owner reference para garbage collection
    if err := ctrl.SetControllerReference(cluster, desired, r.Scheme); err != nil {
        return err
    }

    existing := &appsv1.StatefulSet{}
    err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
    if errors.IsNotFound(err) {
        return r.Create(ctx, desired)
    }
    if err != nil {
        return err
    }

    // Actualizamos solo si cambiaron replicas o image
    existing.Spec.Replicas = desired.Spec.Replicas
    existing.Spec.Template = desired.Spec.Template
    return r.Update(ctx, existing)
}

func (r *PostgreSQLClusterReconciler) buildStatefulSet(
    cluster *dbv1alpha1.PostgreSQLCluster,
) *appsv1.StatefulSet {
    image := fmt.Sprintf("postgres:%s", cluster.Spec.Version)
    replicas := cluster.Spec.Replicas

    return &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cluster.Name,
            Namespace: cluster.Namespace,
        },
        Spec: appsv1.StatefulSetSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{"app": cluster.Name},
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{"app": cluster.Name},
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "postgres",
                            Image: image,
                            Env: []corev1.EnvVar{
                                {
                                    Name:  "POSTGRES_PASSWORD",
                                    Value: "changeme", // en producción, usar un Secret
                                },
                            },
                        },
                    },
                },
            },
        },
    }
}

func (r *PostgreSQLClusterReconciler) reconcileService(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    svc := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cluster.Name + "-svc",
            Namespace: cluster.Namespace,
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{"app": cluster.Name},
            Ports: []corev1.ServicePort{
                {Port: 5432},
            },
        },
    }
    if err := ctrl.SetControllerReference(cluster, svc, r.Scheme); err != nil {
        return err
    }
    existing := &corev1.Service{}
    if err := r.Get(ctx, client.ObjectKeyFromObject(svc), existing); errors.IsNotFound(err) {
        return r.Create(ctx, svc)
    } else {
        return err
    }
}

func (r *PostgreSQLClusterReconciler) updateStatus(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    cluster.Status.Phase = "Running"
    cluster.Status.ReadyNodes = cluster.Spec.Replicas
    return r.Status().Update(ctx, cluster)
}

func (r *PostgreSQLClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&dbv1alpha1.PostgreSQLCluster{}).
        Owns(&appsv1.StatefulSet{}).
        Owns(&corev1.Service{}).
        Complete(r)
}

Ejemplo práctico: backup y restore de PostgreSQL

Ampliaremos el Operator con funcionalidad de copia de seguridad. Añadiremos la CRD PostgreSQLBackup y un reconciler que crea un Kubernetes Job para ejecutar pg_dump.

// Fragmento del reconciler para backup
func (r *PostgreSQLBackupReconciler) Reconcile(
    ctx context.Context,
    req ctrl.Request,
) (ctrl.Result, error) {
    backup := &dbv1alpha1.PostgreSQLBackup{}
    if err := r.Get(ctx, req.NamespacedName, backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Idempotencia: si el Job ya existe, lo saltamos
    job := &batchv1.Job{}
    jobName := fmt.Sprintf("%s-backup-job", backup.Name)
    err := r.Get(ctx, types.NamespacedName{
        Name:      jobName,
        Namespace: backup.Namespace,
    }, job)

    if errors.IsNotFound(err) {
        newJob := r.buildBackupJob(backup, jobName)
        if err := ctrl.SetControllerReference(backup, newJob, r.Scheme); err != nil {
            return ctrl.Result{}, err
        }
        if err := r.Create(ctx, newJob); err != nil {
            return ctrl.Result{}, fmt.Errorf("failed to create backup job: %w", err)
        }
        backup.Status.Phase = "Running"
        return ctrl.Result{RequeueAfter: 30 * time.Second},
            r.Status().Update(ctx, backup)
    }

    // Verificamos el resultado del Job
    if job.Status.Succeeded > 0 {
        backup.Status.Phase = "Completed"
        backup.Status.CompletedAt = &metav1.Time{Time: time.Now()}
    } else if job.Status.Failed > 3 {
        backup.Status.Phase = "Failed"
    } else {
        // El Job sigue en ejecución
        return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
    }

    return ctrl.Result{}, r.Status().Update(ctx, backup)
}

func (r *PostgreSQLBackupReconciler) buildBackupJob(
    backup *dbv1alpha1.PostgreSQLBackup,
    name string,
) *batchv1.Job {
    clusterSvc := backup.Spec.ClusterName + "-svc"
    return &batchv1.Job{
        ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: backup.Namespace},
        Spec: batchv1.JobSpec{
            Template: corev1.PodTemplateSpec{
                Spec: corev1.PodSpec{
                    RestartPolicy: corev1.RestartPolicyOnFailure,
                    Containers: []corev1.Container{
                        {
                            Name:  "pg-dump",
                            Image: "postgres:16",
                            Command: []string{
                                "pg_dump",
                                "-h", clusterSvc,
                                "-U", "postgres",
                                "-F", "c",
                                "-f", "/backup/dump.pgc",
                                backup.Spec.Database,
                            },
                        },
                    },
                },
            },
        },
    }
}

Manejo de errores e idempotencia

El reconciliation loop debe ser resistente a fallos de red, conflictos de versión (optimistic locking) e invocaciones repetidas. Reglas fundamentales:

  • Vuelve a leer el objeto siempre al inicio de Reconcile — no confíes en el estado cacheado del evento.
  • Usa ctrl.Result{RequeueAfter: ...} para verificaciones diferidas, en lugar de un sleep bloqueante.
  • Envuelve los errores con fmt.Errorf("...: %w", err) para facilitar la trazabilidad.
  • Comprueba conflictos: ante errors.IsConflict(err) basta con devolver el error — el controlador repetirá el reconcile automáticamente.
  • Usa Finalizers para la lógica de limpieza al eliminar un objeto: añade el finalizer al crearlo y elimínalo tras la limpieza.
// Ejemplo de uso de finalizer
const finalizer = "db.example.com/cleanup"

if cluster.DeletionTimestamp.IsZero() {
    // El objeto no está siendo eliminado — añadimos el finalizer
    if !controllerutil.ContainsFinalizer(cluster, finalizer) {
        controllerutil.AddFinalizer(cluster, finalizer)
        return ctrl.Result{}, r.Update(ctx, cluster)
    }
} else {
    // El objeto está siendo eliminado — ejecutamos la limpieza
    if controllerutil.ContainsFinalizer(cluster, finalizer) {
        if err := r.cleanupExternalResources(ctx, cluster); err != nil {
            return ctrl.Result{}, err
        }
        controllerutil.RemoveFinalizer(cluster, finalizer)
        return ctrl.Result{}, r.Update(ctx, cluster)
    }
}

Pruebas del Operator: envtest y pruebas de integración

controller-runtime incluye el paquete envtest, que levanta un servidor API de Kubernetes real junto con etcd de forma local, sin necesidad de un clúster completo. Esto permite escribir pruebas de integración que verifican el ciclo completo de reconciliation.

// internal/controller/suite_test.go
package controller_test

import (
    "testing"
    "path/filepath"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "sigs.k8s.io/controller-runtime/pkg/envtest"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var (
    testEnv *envtest.Environment
    k8sClient client.Client
)

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

var _ = BeforeSuite(func() {
    testEnv = &envtest.Environment{
        CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
    }
    cfg, err := testEnv.Start()
    Expect(err).NotTo(HaveOccurred())

    k8sClient, err = client.New(cfg, client.Options{})
    Expect(err).NotTo(HaveOccurred())
})

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

// internal/controller/postgresqlcluster_controller_test.go
var _ = Describe("PostgreSQLCluster Controller", func() {
    It("should create a StatefulSet for a new cluster", func() {
        ctx := context.Background()
        cluster := &dbv1alpha1.PostgreSQLCluster{
            ObjectMeta: metav1.ObjectMeta{
                Name:      "test-cluster",
                Namespace: "default",
            },
            Spec: dbv1alpha1.PostgreSQLClusterSpec{
                Replicas:    2,
                Version:     "16.2",
                StorageSize: "10Gi",
            },
        }
        Expect(k8sClient.Create(ctx, cluster)).To(Succeed())

        sts := &appsv1.StatefulSet{}
        Eventually(func() error {
            return k8sClient.Get(ctx, types.NamespacedName{
                Name:      "test-cluster",
                Namespace: "default",
            }, sts)
        }, "10s", "1s").Should(Succeed())

        Expect(*sts.Spec.Replicas).To(Equal(int32(2)))
    })
})

Despliegue del Operator en el clúster: kustomize, Helm y OLM

kubebuilder genera una estructura config/ lista con manifiestos de kustomize. Para el despliegue basta con:

# Construimos y publicamos la imagen
make docker-build docker-push IMG=ghcr.io/example/postgres-operator:v0.1.0

# Desplegamos con kustomize
make deploy IMG=ghcr.io/example/postgres-operator:v0.1.0

# O directamente
kubectl apply -k config/default

Para despliegues en producción se recomienda empaquetar el Operator en un chart de Helm con parametrización mediante values.yaml. Si se planea publicar en OperatorHub, usa operator-sdk para generar el bundle OLM:

operator-sdk generate bundle \
  --package postgres-operator \
  --version 0.1.0 \
  --channels stable

operator-sdk bundle validate ./bundle

Seguridad: RBAC y principio de mínimo privilegio

El Operator opera con los permisos del ServiceAccount en el clúster. Las anotaciones // +kubebuilder:rbac: en el código del controlador generan automáticamente el ClusterRole al ejecutar make manifests. Sigue el principio de mínimo privilegio:

  • Concede acceso únicamente a los recursos y namespaces estrictamente necesarios.
  • Usa un Role de namespace en lugar de ClusterRole siempre que sea posible.
  • Nunca otorgues al Operator permisos de cluster-admin.
  • Usa Admission Webhooks (ValidatingWebhookConfiguration) para validar las CRD a nivel del servidor API.
  • Almacena los secretos (contraseñas de BD, claves S3) en Kubernetes Secrets o External Secrets Operator, nunca en el spec de la CRD.
# Ejemplo de ClusterRole generado (fragmento)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: postgres-operator-manager-role
rules:
- apiGroups: ["db.example.com"]
  resources: ["postgresqlclusters"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["db.example.com"]
  resources: ["postgresqlclusters/status"]
  verbs: ["get", "update", "patch"]
- apiGroups: ["apps"]
  resources: ["statefulsets"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["services", "persistentvolumeclaims"]
  verbs: ["get", "list", "watch", "create", "update", "patch"]

Conclusión: cuándo escribir un Operator y cuándo usar herramientas estándar

El Kubernetes Operator es una herramienta poderosa, pero no es una bala de plata. Estos son los criterios para tomar la decisión:

  • Escribe un Operator si necesitas automatizar procedimientos operativos (backup/restore, failover, rolling upgrade con comprobaciones de salud) que requieren reaccionar a eventos del clúster en tiempo real.
  • Escribe un Operator si tienes un servicio stateful complejo (base de datos, cola de mensajes, broker) con una lógica de escalado no trivial.
  • Usa Helm o kustomize si la tarea se reduce a plantillas de manifiestos y gestión de configuración sin lógica de negocio compleja.
  • Usa un Operator existente (CloudNativePG para PostgreSQL, Strimzi para Kafka) si cubre tus requisitos — no reinventes la rueda.

En 2026, el ecosistema de Kubernetes Operators en Go ha alcanzado la madurez: kubebuilder v4 + controller-runtime proporcionan una base sólida, y los patrones de reconciliation idempotente y pruebas con envtest se han convertido en el estándar de la industria. Para ingenieros de plataforma y desarrolladores Go, dominar este stack abre la posibilidad de construir una infraestructura verdaderamente autogestionada.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →