La lección anterior terminó con un CRD RutaProgramada perfectamente definido, validado y consultable, sobre el que no ocurría absolutamente nada. Y en la lección 06-01 dejamos otra deuda: postgres-reservas es un StatefulSet que da identidad y disco a sus réplicas, pero que no sabe elegir un primario, ni replicar datos, ni conmutar por error cuando ese primario muere.

Las dos carencias tienen la misma solución, y se resume en una frase que conviene memorizar:

Operador = recurso personalizado + controlador que lo reconcilia.

Un operador es el conocimiento operativo de un experto —cómo se promociona una réplica de PostgreSQL, cómo se hace una copia coherente, cómo se actualiza una versión mayor sin perder datos— codificado en software que corre dentro del clúster y no duerme nunca.

Esta es la última lección del módulo. Cerraremos las dos deudas abiertas, veremos el bucle de reconciliación por dentro, sustituiremos nuestro StatefulSet artesanal por un operador de PostgreSQL de verdad, esbozaremos el controlador de RutaProgramada, y terminaremos explicando por qué la mayoría de los equipos no deberían escribir operadores.

Contenido

  1. Qué es un operador, con precisión
  2. Anatomía real del bucle de reconciliación
  3. Idempotencia y ausencia de estado en memoria
  4. El modelo de capacidades en cinco niveles
  5. Operadores que ya has usado en este curso
  6. Caso práctico: un operador de PostgreSQL para postgres-reservas
  7. Dónde encontrar operadores y qué mirar antes de adoptar uno
  8. Cómo se escribe uno: Kubebuilder y controller-runtime
  9. ownerReferences y el borrado en cascada
  10. Cuándo NO escribir un operador

  1. Qué es un operador, con precisión

Un operador tiene exactamente dos piezas:

  1. Uno o varios CRD que definen el vocabulario: Cluster, Certificate, RutaProgramada. Es la interfaz declarativa con la que el usuario expresa qué quiere.
  2. Un controlador —normalmente un Deployment corriendo en el propio clúster— que observa esos objetos y hace el trabajo.
graph LR
  U[Usuario] -->|kubectl apply<br/>Cluster con 3 réplicas| API[kube-apiserver]
  API -->|watch| C[Controlador del operador<br/>Deployment en el clúster]
  C -->|compara deseado vs observado| R{¿Coinciden?}
  R -->|no| A[Actuar: crear pods,<br/>promover primario,<br/>ajustar Services]
  R -->|sí| N[No hacer nada]
  A -->|escribe| API
  C -->|actualiza status| API
  API -->|cambio| C

El término lo acuñó CoreOS en 2016 con esta idea: cuando un equipo de operaciones lleva años administrando PostgreSQL, ha acumulado un conjunto de procedimientos —qué hacer si el primario deja de responder, cómo añadir una réplica de lectura, en qué orden actualizar— que viven en runbooks, en scripts sueltos y en la cabeza de dos personas. Un operador convierte todo eso en un programa que se ejecuta continuamente.

La diferencia con las herramientas que ya conoces:

Herramienta Cuándo actúa Qué mantiene
Script de despliegue Cuando alguien lo lanza Nada: es un disparo único
Helm (10-03) En install y upgrade Renderiza plantillas; no vigila después
Kustomize (10-04) Al generar los manifiestos Nada en tiempo de ejecución
Operador Continuamente El estado deseado, pase lo que pase

Un operador no instala: mantiene. Si alguien borra un pod, lo recrea. Si el primario cae a las tres de la madrugada, promociona una réplica sin despertar a nadie. Si el disco se llena, lo amplía. Esa vigilancia permanente es su valor.

  1. Anatomía real del bucle de reconciliación

En la lección 01-02 describimos el bucle de reconciliación como la idea central de Kubernetes: observar el estado deseado, observar el real, actuar para acercarlos. Ahora lo vemos por dentro, tal como lo implementa cualquier controlador serio.

graph TB
  W[Informer: WATCH sobre la API<br/>+ caché local del estado] -->|evento add/update/delete| Q[Cola de trabajo<br/>con deduplicación y retraso]
  Q -->|extrae una clave<br/>namespace/nombre| REC[Reconcile ns/nombre]
  REC --> LEER[Leer el objeto actual de la caché]
  LEER --> OBS[Observar el mundo real:<br/>pods, PVC, servicios]
  OBS --> COMP{¿deseado == observado?}
  COMP -->|sí| ST[Actualizar status<br/>y terminar]
  COMP -->|no| ACT[Ejecutar el siguiente paso]
  ACT --> ST
  ST --> RES{¿Resultado?}
  RES -->|error| REQ[Reencolar con<br/>retroceso exponencial]
  RES -->|requeue tras N s| REQ2[Reencolar con retraso fijo]
  RES -->|ok| FIN[Esperar al siguiente evento]
  REQ --> Q
  REQ2 --> Q

El watch y el informer

El controlador no consulta la API en bucle: abre una conexión watch y recibe notificaciones de cada cambio. La biblioteca estándar (client-go) envuelve eso en un informer, que mantiene además una caché local de todos los objetos observados.

La caché importa por dos motivos:

  • Las lecturas del controlador no golpean al apiserver: en un clúster con cientos de objetos, la diferencia es sustancial.
  • La caché puede estar ligeramente desactualizada. Un controlador debe tolerar leer un objeto con un par de segundos de retraso, lo que refuerza la necesidad de idempotencia.

La cola de trabajo

Los eventos no se procesan directamente: se traduce cada uno a una clave (namespace/nombre) y se mete en una cola con tres propiedades:

  • Deduplicación: si un objeto cambia cinco veces mientras se procesa, la clave aparece una sola vez. Se reconciliará una vez con el estado final, no cinco veces con estados intermedios.
  • Retraso: se puede pedir "vuelve a mirar esto dentro de 30 segundos", útil para esperar a que algo externo progrese.
  • Límite de tasa con retroceso exponencial: un objeto que falla se reintenta a los 5 ms, 10 ms, 20 ms… hasta un máximo. Un objeto permanentemente roto no consume el controlador entero.

La función Reconcile

Es el corazón, y su firma dice mucho:

func (r *RutaProgramadaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)

Recibe solo una clave, no el objeto ni el evento. Esa decisión de diseño es deliberada: Reconcile no sabe qué cambió ni por qué fue llamada. Su contrato es siempre el mismo: dado este nombre, lee el estado deseado, mira el real, y haz que se parezcan.

Devuelve dos cosas:

Devuelve Efecto
error != nil Reencolar con retroceso exponencial
Result{Requeue: true} Reencolar inmediatamente
Result{RequeueAfter: 30*time.Second} Reencolar dentro de 30 segundos
Result{}, nil Terminado; esperar al siguiente evento

Actualizar el status

El último paso de cada reconciliación es escribir lo observado en .status, a través del subrecurso que estudiamos en 06-06. Aquí aparece un campo convencional que merece explicación:

status:
  observedGeneration: 4

metadata.generation lo incrementa el apiserver cada vez que cambia el spec (no cuando cambian etiquetas o anotaciones). El controlador guarda en status.observedGeneration la generación que ya procesó. Comparando ambos, cualquiera puede saber si el controlador está al día:

kubectl get rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  -o jsonpath='generación={.metadata.generation} observada={.status.observedGeneration}{"\n"}'
generación=4 observada=4

Si difieren, hay un cambio del spec que el controlador aún no ha atendido.

  1. Idempotencia y ausencia de estado en memoria

Dos propiedades no negociables de cualquier Reconcile.

Idempotencia

Reconcile puede ejecutarse muchas más veces de las que esperas: por un cambio real, por un reintento tras error, por una resincronización periódica del informer (cada 10 horas por defecto), o simplemente porque el controlador se reinició y reconcilia todo lo que existe.

Por eso Reconcile nunca debe pensar en términos de "crear" sino de "asegurar que existe":

// MAL: falla en la segunda ejecución con AlreadyExists
if err := r.Create(ctx, deployment); err != nil {
    return ctrl.Result{}, err
}

// BIEN: idempotente
existente := &appsv1.Deployment{}
err := r.Get(ctx, client.ObjectKeyFromObject(deseado), existente)
switch {
case apierrors.IsNotFound(err):
    return ctrl.Result{}, r.Create(ctx, deseado)
case err != nil:
    return ctrl.Result{}, err
default:
    if !reflect.DeepEqual(existente.Spec, deseado.Spec) {
        existente.Spec = deseado.Spec
        return ctrl.Result{}, r.Update(ctx, existente)
    }
    return ctrl.Result{}, nil   // ya estaba bien: no hacer nada
}

Corolario práctico: una reconciliación que no cambia nada es el caso normal y debe ser barata. Si tu Reconcile escribe en la API en cada pasada aunque nada haya cambiado, provocarás un bucle infinito: la escritura genera un evento, el evento provoca otra reconciliación, y así indefinidamente. Es el error clásico del primer operador que escribe cualquiera, y se detecta porque el apiserver registra miles de update por minuto sobre el mismo objeto.

Ausencia de estado en memoria

El controlador no puede recordar nada entre reconciliaciones. Ni en variables globales, ni en mapas, ni en ficheros locales.

Motivos:

  • Puede reiniciarse en cualquier momento (actualización, desalojo, fallo del nodo) y perdería todo.
  • Puede haber varias réplicas para alta disponibilidad; solo una está activa gracias a la elección de líder, pero el relevo puede ocurrir en cualquier momento.
  • El estado en memoria se desincroniza del mundo real sin que nadie lo note.

Todo el estado que el controlador necesite recordar debe vivir en la API de Kubernetes: en .status, en anotaciones, en etiquetas o en los propios objetos que gestiona. Si tu operador necesita saber "ya lancé la copia de seguridad de hoy", eso va en status.ultimaCopia, no en una variable.

La consecuencia positiva es que un operador bien escrito se puede matar y arrancar en cualquier momento sin efectos secundarios. Es una propiedad que conviene probar deliberadamente: borra el pod del controlador en mitad de una operación y comprueba que retoma correctamente.

  1. El modelo de capacidades en cinco niveles

El proyecto Operator Framework definió una escala para medir cuánto sabe hacer un operador. Es la mejor herramienta para evaluar uno antes de adoptarlo. Aplicada a una base de datos como postgres-reservas:

Nivel Nombre Qué significa para una base de datos
1 Instalación básica Crea el StatefulSet, el Service, el PVC y el Secret. Equivale a lo que hicimos a mano en 06-01
2 Actualizaciones sencillas Cambia la versión menor (16.4 → 16.6) en el orden correcto, réplicas antes que primario
3 Ciclo de vida completo Réplicas de lectura, copias programadas, restauración, escalado, cambio de configuración sin parada
4 Observabilidad profunda Expone métricas, alertas, y el status refleja el estado real de replicación con su retraso
5 Piloto automático Detecta el primario caído y conmuta solo, ajusta parámetros según la carga, repara réplicas corruptas, escala por sí mismo

Detalle de lo que aporta cada salto:

Nivel 1 → 2: la actualización deja de ser un procedimiento manual. El operador sabe que hay que actualizar primero las réplicas y promover después, y que hay que esperar a que cada una se sincronice.

Nivel 2 → 3: aquí está el grueso del valor. Copias programadas con verificación, restauración a un instante concreto, añadir una réplica de lectura con un solo cambio en el spec, cambiar shared_buffers sin perder conexiones.

Nivel 3 → 4: el operador deja de ser una caja negra. Publica métricas del retraso de replicación, del tamaño de la base y del estado de las copias, y su status dice la verdad sobre lo que está pasando.

Nivel 4 → 5: la conmutación por error automática. Es el nivel que separa "me ahorra trabajo" de "puedo dormir tranquilo". También el más difícil y el que más cuidado requiere: un operador que conmuta mal puede provocar un split brain con dos primarios aceptando escrituras.

Al evaluar un operador, pregunta por el nivel. Muchos proyectos vistosos se quedan en el 2, y para el nivel 2 no compensa la complejidad añadida: eso ya lo hace un chart de Helm.

  1. Operadores que ya has usado en este curso

Sin llamarlos así, llevamos varios módulos usando operadores.

cert-manager (lección 04-05)

Cuando aplicamos un Certificate para www.rutasnorte.example, ocurrió lo siguiente sin que hiciéramos nada más:

  1. El controlador de cert-manager observó el nuevo objeto Certificate.
  2. Creó un CertificateRequest, generó una clave privada y la guardó en un Secret.
  3. Creó un Order y un Challenge para el protocolo ACME.
  4. Publicó un Ingress temporal con el token del desafío HTTP-01.
  5. Esperó a que Let's Encrypt validara, obtuvo el certificado y lo escribió en el Secret.
  6. Y desde entonces vigila la fecha de caducidad y repite el proceso 30 días antes de que expire.

El paso 6 es la definición de operador. Un script habría hecho los pasos 1 a 5; solo un controlador que corre siempre hace el 6. En capacidades, cert-manager está en el nivel 5 para su dominio: renueva sin intervención humana.

kubectl get pods -n cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-6d8f7c9b54-p2m4x              1/1     Running   0          24d
cert-manager-cainjector-7b9d4f8c6-k7t2v    1/1     Running   0          24d
cert-manager-webhook-59c8d7b64-w3n8q       1/1     Running   0          24d

Tres Deployments: el controlador, un inyector de certificados de CA y el webhook de validación de sus CRD. Es la anatomía típica de un operador maduro.

El snapshot-controller (lección 05-05)

Cuando creamos un VolumeSnapshot de los datos de reservas, un controlador lo observó, habló con el driver CSI, creó el VolumeSnapshotContent correspondiente y actualizó status.readyToUse. Mismo patrón: CRD más controlador.

Velero (lección 05-06)

Sus Backup, Restore y Schedule son CRD, y su controlador es quien ejecuta las copias, aplica los hooks antes y después, y respeta la retención. Un Schedule de Velero es un operador creando Jobs, conceptualmente igual a lo que hace el controlador de CronJob con nuestro informes-ocupacion.

Prometheus Operator (lección 07-03)

Lo que viene. Sus CRD Prometheus, ServiceMonitor, PodMonitor y PrometheusRule permiten declarar "recoge métricas de todos los Services con esta etiqueta" y el operador genera y recarga la configuración de Prometheus. Sin él, añadir un objetivo nuevo significa editar a mano un fichero de configuración de cientos de líneas.

Y los controladores nativos

Conviene cerrar el círculo: el controlador de Deployments, el de ReplicaSets y el de Jobs funcionan exactamente igual. Observan objetos, comparan deseado con observado y actúan. La única diferencia es que sus tipos vienen de fábrica y su código va dentro del kube-controller-manager en lugar de en un Deployment aparte. El patrón es idéntico; los operadores solo lo extienden a dominios que Kubernetes no conoce.

  1. Caso práctico: un operador de PostgreSQL para postgres-reservas

Llegamos a la deuda de 06-01. Nuestro StatefulSet artesanal tiene una réplica y no sabe hacer nada más. Vamos a sustituirlo por CloudNativePG, un operador de PostgreSQL maduro y de código abierto.

Instalar el operador

kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.1.yaml

kubectl get deployment -n cnpg-system
kubectl get crds | grep postgresql
NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
cnpg-controller-manager    1/1     1            1           47s

backups.postgresql.cnpg.io                    2026-08-05T21:40:11Z
clusters.postgresql.cnpg.io                   2026-08-05T21:40:11Z
poolers.postgresql.cnpg.io                    2026-08-05T21:40:11Z
scheduledbackups.postgresql.cnpg.io           2026-08-05T21:40:12Z

Un Deployment (el controlador) y cuatro CRD (el vocabulario). Exactamente las dos piezas de la definición.

Declarar el clúster

Ahora postgres-reservas se describe así:

# k8s/base/postgres-reservas-cluster.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  instances: 3                      # un primario y dos réplicas
  imageName: ghcr.io/cloudnative-pg/postgresql:16.4

  primaryUpdateStrategy: unsupervised   # el operador conmuta solo al actualizar

  bootstrap:
    initdb:
      database: reservas
      owner: rutasnorte
      secret:
        name: postgres-reservas-credenciales
      localeCollate: es_ES.UTF-8
      localeCType: es_ES.UTF-8

  storage:
    size: 20Gi
    storageClass: rutasnorte-rapida

  walStorage:                        # WAL en volumen separado: mejor rendimiento
    size: 5Gi
    storageClass: rutasnorte-rapida

  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: "512MB"
      work_mem: "8MB"
      log_min_duration_statement: "500"   # registrar consultas de más de 500 ms

  resources:
    requests:
      cpu: "1"
      memory: 2Gi
    limits:
      cpu: "1"
      memory: 2Gi                    # requests == limits: QoS Guaranteed (03-05)

  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required    # nunca dos instancias en el mismo nodo (06-05)
    nodeSelector:
      disco: ssd

  monitoring:
    enablePodMonitor: true           # métricas para Prometheus (07-03)

  backup:
    retentionPolicy: "30d"
    barmanObjectStore:
      destinationPath: "s3://rutasnorte-copias/postgres-reservas"
      s3Credentials:
        accessKeyId:
          name: copias-credenciales
          key: ACCESS_KEY_ID
        secretAccessKey:
          name: copias-credenciales
          key: SECRET_ACCESS_KEY
      wal:
        compression: gzip
        maxParallel: 4
      data:
        compression: gzip
        immediateCheckpoint: false
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: postgres-reservas-copia-nocturna
  namespace: rutas-norte-pro
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  schedule: "0 30 2 * * *"          # 02:30 (formato de 6 campos, con segundos)
  backupOwnerReference: self
  cluster:
    name: postgres-reservas
kubectl apply -f k8s/base/postgres-reservas-cluster.yaml
kubectl get cluster -n rutas-norte-pro
NAME                AGE     INSTANCES   READY   STATUS                     PRIMARY
postgres-reservas   3m42s   3           3       Cluster in healthy state   postgres-reservas-1
kubectl get pods,svc -n rutas-norte-pro -l cnpg.io/cluster=postgres-reservas
NAME                      READY   STATUS    RESTARTS   AGE
pod/postgres-reservas-1   1/1     Running   0          3m
pod/postgres-reservas-2   1/1     Running   0          2m
pod/postgres-reservas-3   1/1     Running   0          2m

NAME                          TYPE        CLUSTER-IP       PORT(S)
service/postgres-reservas-rw  ClusterIP   10.96.201.14     5432/TCP
service/postgres-reservas-ro  ClusterIP   10.96.188.77     5432/TCP
service/postgres-reservas-r   ClusterIP   10.96.140.22     5432/TCP

Los tres Services son la pieza que un StatefulSet no puede dar:

Service Apunta a Uso en Rutas Norte
-rw Solo el primario actual Escrituras de api-reservas
-ro Solo las réplicas Consultas de informes-ocupacion
-r Cualquier instancia Lecturas que toleran retraso

Y lo esencial: cuando el primario cambia, el operador reescribe el Service -rw para que apunte al nuevo. La aplicación no se entera. Eso es exactamente lo que un StatefulSet no sabe hacer, porque su Service headless da nombres estables pero no sabe cuál de esos nombres es el primario.

La prueba de fuego: matar al primario

kubectl delete pod postgres-reservas-1 -n rutas-norte-pro
kubectl get cluster postgres-reservas -n rutas-norte-pro -w
NAME                INSTANCES   READY   STATUS                            PRIMARY
postgres-reservas   3           2       Failing over to postgres-reservas-2   postgres-reservas-1
postgres-reservas   3           2       Cluster in healthy state              postgres-reservas-2
postgres-reservas   3           3       Cluster in healthy state              postgres-reservas-2

En cuestión de segundos: se detecta la caída, se promociona postgres-reservas-2, se reescribe el Service -rw, y la instancia caída vuelve reincorporada como réplica. Sin intervención humana, sin runbook, sin llamada a las tres de la madrugada.

Qué resuelve el operador que tendrías que hacer a mano

Capacidad Con StatefulSet artesanal (06-01) Con operador
Elección de primario Decidir por convención que es el ordinal 0 El operador lo decide, lo registra en status y lo publica
Conmutación por error Detectar la caída, promocionar, reconfigurar réplicas, cambiar el Service: todo manual Automática en segundos
Réplicas de lectura pg_basebackup a mano, primary_conninfo, ranuras de replicación instances: 3
Enrutado lectura/escritura Nada: un solo Service para todo Services -rw, -ro y -r mantenidos al día
Copias programadas CronJob con pg_dump (05-06) ScheduledBackup con WAL continuo
Recuperación a un instante Imposible sin archivado de WAL montado a mano recoveryTarget.targetTime
Actualización de versión menor Editar la imagen y confiar Réplicas primero, conmutación, primario después
Actualización de versión mayor Volcado, restauración y horas de parada Procedimiento guiado por el operador
Ampliar el disco kubectl patch de cada PVC (05-05) Cambiar storage.size
Ranuras de replicación Configuración manual, y se rompen al recrear pods Gestionadas
Sondas de salud reales pg_isready, que no distingue primario de réplica El operador conoce el rol de cada instancia
Métricas Añadir un sidecar exportador (06-04) enablePodMonitor: true

Ese es el argumento entero a favor de los operadores para software con estado: la columna de la izquierda son semanas de trabajo, procedimientos frágiles y guardias nocturnas; la de la derecha son campos de un YAML.

Migrar sin perder los datos

Con lo aprendido en 05-06 y 06-01, la migración desde nuestro StatefulSet se hace por restauración lógica, no moviendo volúmenes:

spec:
  bootstrap:
    initdb:
      database: reservas
      owner: rutasnorte
      import:
        type: microservice
        databases: ["reservas"]
        source:
          externalCluster: statefulset-antiguo
  externalClusters:
    - name: statefulset-antiguo
      connectionParameters:
        host: postgres-reservas-nodos.rutas-norte-pro.svc.cluster.local
        user: rutasnorte
        dbname: reservas
      password:
        name: postgres-reservas-credenciales
        key: password

El operador arranca, se conecta al StatefulSet antiguo, importa la base y monta el clúster nuevo. Después se verifica el número de reservas, se apunta api-reservas al Service -rw y se retira el StatefulSet.

  1. Dónde encontrar operadores y qué mirar antes de adoptar uno

Dónde buscar

Fuente Qué contiene
OperatorHub.io Catálogo comunitario con nivel de capacidades declarado
Artifact Hub Charts de Helm y operadores, con recuento de descargas
Repositorio oficial del proyecto Casi siempre la fuente más fiable y actualizada
Marketplace de tu proveedor cloud Operadores validados para EKS, AKS o GKE (10-06)

La lista de comprobación

Adoptar un operador es adoptar una dependencia que tendrá permisos amplios sobre tu clúster y de la que dependerán tus datos. Antes de instalarlo:

1. Mantenimiento. ¿Cuándo fue el último commit? ¿Cuántas personas contribuyen? ¿Hay releases regulares? ¿Cuántas incidencias abiertas sin respuesta? Un operador abandonado que gestiona tu base de datos es un problema serio, porque desinstalarlo sin perder datos rara vez es trivial.

2. Permisos RBAC que pide. Este es el punto que más se pasa por alto. Míralo antes de aplicar el manifiesto:

curl -sL <url-del-manifiesto> | grep -A40 'kind: ClusterRole'

Preguntas: ¿pide cluster-admin? (señal de alarma inmediata) ¿Pide acceso a todos los Secrets del clúster? ¿Puede crear ClusterRoleBindings, es decir, ampliarse sus propios permisos? Un operador comprometido con permisos amplios equivale a un clúster comprometido. Volveremos a esto en 08-01.

3. Madurez. ¿Qué nivel de capacidades declara y cuál cumple de verdad? ¿Hay casos de uso en producción documentados? ¿Existe una guía de actualización entre versiones del propio operador?

4. Qué pasa si lo desinstalas. La pregunta decisiva:

  • ¿Los objetos que gestionaba siguen funcionando o se paran?
  • ¿Sus CRD tienen finalizadores que dejarían objetos colgados en Terminating?
  • ¿Puedes exportar los datos a un formato estándar?
  • ¿Hay un procedimiento documentado de salida?

Un operador del que no se puede salir es un secuestro tecnológico. Con CloudNativePG, por ejemplo, las copias en formato Barman son restaurables con herramientas estándar de PostgreSQL: hay puerta de salida.

5. Recursos y ámbito. ¿Cuánta CPU y memoria consume el controlador? ¿Vigila todo el clúster o se puede limitar a namespaces concretos? Un operador que observa todos los objetos de un clúster grande puede consumir bastante memoria.

6. Modelo de actualización. ¿Cómo se actualiza el operador sin afectar a lo que gestiona? ¿Es compatible hacia atrás con los CRD ya desplegados?

  1. Cómo se escribe uno: Kubebuilder y controller-runtime

Nadie escribe un operador desde cero. Las herramientas estándar:

Herramienta Qué aporta
controller-runtime Biblioteca de Go con informers, colas, cliente con caché y gestor de controladores
Kubebuilder Andamiaje: genera el proyecto, los CRD desde structs de Go, el RBAC y el despliegue
Operator SDK Envuelve Kubebuilder y añade Helm y Ansible como alternativas a Go

Con Operator SDK se puede construir un operador sin escribir Go, usando un chart de Helm o un playbook de Ansible como lógica de reconciliación. Es una vía razonable para operadores sencillos, aunque limita las capacidades a los niveles 1 y 2.

Arranque de un proyecto

mkdir -p ~/proyectos/operador-rutasnorte && cd ~/proyectos/operador-rutasnorte

kubebuilder init \
  --domain rutasnorte.example \
  --repo github.com/rutasnorte/operador-rutasnorte

kubebuilder create api \
  --group rutasnorte \
  --version v1 \
  --kind RutaProgramada \
  --resource --controller

Estructura generada:

api/v1/rutaprogramada_types.go        <- los tipos Go: de aquí sale el CRD
internal/controller/rutaprogramada_controller.go   <- aquí va Reconcile
config/crd/bases/                     <- CRD generados con "make manifests"
config/rbac/                          <- Roles generados desde los marcadores
config/samples/                       <- ejemplos de instancias
Makefile                              <- make manifests, make docker-build, make deploy

Los tipos, de los que sale el CRD

El CRD de la lección 06-06 no se escribe a mano en un proyecto real: se genera a partir de structs de Go anotados.

// api/v1/rutaprogramada_types.go

type RutaProgramadaSpec struct {
    // Código comercial de la ruta, formato XX-999 (ej. RN-041)
    // +kubebuilder:validation:Pattern=`^[A-Z]{2}-[0-9]{3}$`
    Codigo string `json:"codigo"`

    // Ciudad de origen del trayecto
    // +kubebuilder:validation:MinLength=2
    // +kubebuilder:validation:MaxLength=60
    Origen string `json:"origen"`

    // Ciudad de destino del trayecto
    // +kubebuilder:validation:MinLength=2
    // +kubebuilder:validation:MaxLength=60
    Destino string `json:"destino"`

    // Plazas totales ofertadas en cada salida
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=90
    Plazas int32 `json:"plazas"`

    // Horas de salida diarias en formato HH:MM
    // +kubebuilder:validation:MinItems=1
    // +kubebuilder:validation:items:Pattern=`^([01][0-9]|2[0-3]):[0-5][0-9]$`
    Horarios []string `json:"horarios"`

    // Si la ruta admite ventas actualmente
    // +kubebuilder:default=true
    // +optional
    Activa bool `json:"activa,omitempty"`
}

type RutaProgramadaStatus struct {
    // +optional
    Fase string `json:"fase,omitempty"`
    // +optional
    PlazasVendidas int32 `json:"plazasVendidas,omitempty"`
    // Generación del spec que el controlador procesó por última vez
    // +optional
    ObservedGeneration int64 `json:"observedGeneration,omitempty"`
    // +optional
    Condiciones []metav1.Condition `json:"condiciones,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=ruta;rutas,categories=rutasnorte
// +kubebuilder:printcolumn:name="Código",type=string,JSONPath=`.spec.codigo`
// +kubebuilder:printcolumn:name="Origen",type=string,JSONPath=`.spec.origen`
// +kubebuilder:printcolumn:name="Destino",type=string,JSONPath=`.spec.destino`
// +kubebuilder:printcolumn:name="Fase",type=string,JSONPath=`.status.fase`
type RutaProgramada struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   RutaProgramadaSpec   `json:"spec,omitempty"`
    Status RutaProgramadaStatus `json:"status,omitempty"`
}

Los comentarios // +kubebuilder:... son marcadores que el generador traduce al esquema OpenAPI del CRD. make manifests produce exactamente el YAML que escribimos a mano en 06-06, y los comentarios normales se convierten en las description que alimentan kubectl explain.

El esqueleto de Reconcile

Este es el corazón del operador de RutaProgramada: cada ruta activa debe tener su propio CronJob que genere el informe de ocupación de esa ruta.

// internal/controller/rutaprogramada_controller.go

// Permisos que necesita el controlador. Estos marcadores generan config/rbac/role.yaml
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutasprogramadas,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutasprogramadas/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutasprogramadas/finalizers,verbs=update
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch

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

    // ── 1. LEER EL ESTADO DESEADO ────────────────────────────────────────
    var ruta rutasnortev1.RutaProgramada
    if err := r.Get(ctx, req.NamespacedName, &ruta); err != nil {
        // NotFound significa que la ruta se borró. No hay nada que hacer:
        // los objetos derivados se borran solos por ownerReferences (apartado 9).
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // ── 2. RUTA INACTIVA: retirar lo que se hubiera creado ───────────────
    if !ruta.Spec.Activa {
        log.Info("Ruta inactiva; no se mantiene el CronJob de informes", "codigo", ruta.Spec.Codigo)
        return ctrl.Result{}, r.actualizarEstado(ctx, &ruta, "Cancelada")
    }

    // ── 3. CONSTRUIR EL ESTADO DESEADO ───────────────────────────────────
    // Función pura: mismos datos de entrada, mismo objeto de salida SIEMPRE.
    // Esta pureza es lo que hace posible la comparación del paso 5.
    deseado := r.construirCronJobInforme(&ruta)

    // ── 4. ownerReferences: el CronJob pertenece a la ruta ───────────────
    // Al borrar la ruta, el recolector de basura borra el CronJob (apartado 9).
    if err := ctrl.SetControllerReference(&ruta, deseado, r.Scheme); err != nil {
        return ctrl.Result{}, err
    }

    // ── 5. RECONCILIAR: crear si falta, actualizar si difiere, nada si coincide ──
    var actual batchv1.CronJob
    err := r.Get(ctx, client.ObjectKeyFromObject(deseado), &actual)
    switch {
    case apierrors.IsNotFound(err):
        log.Info("Creando CronJob de informes", "ruta", ruta.Spec.Codigo)
        if err := r.Create(ctx, deseado); err != nil {
            // Devolver el error hace que la cola reencole con retroceso exponencial
            return ctrl.Result{}, err
        }
        r.Recorder.Eventf(&ruta, corev1.EventTypeNormal, "CronJobCreado",
            "Creado el CronJob de informes de la ruta %s", ruta.Spec.Codigo)

    case err != nil:
        return ctrl.Result{}, err

    default:
        // IDEMPOTENCIA: si nada cambió, NO escribir. Escribir aquí provocaría
        // un evento, que provocaría otra reconciliación: bucle infinito.
        if !equality.Semantic.DeepDerivative(deseado.Spec, actual.Spec) {
            log.Info("Actualizando CronJob de informes", "ruta", ruta.Spec.Codigo)
            actual.Spec = deseado.Spec
            if err := r.Update(ctx, &actual); err != nil {
                return ctrl.Result{}, err
            }
        }
    }

    // ── 6. OBSERVAR EL MUNDO REAL Y ESCRIBIR EL STATUS ───────────────────
    vendidas, err := r.consultarPlazasVendidas(ctx, &ruta)
    if err != nil {
        // Fallo transitorio consultando la base de datos: reintentar en un minuto
        // sin marcar la reconciliación como fallida.
        log.Error(err, "no se pudieron consultar las plazas vendidas")
        return ctrl.Result{RequeueAfter: time.Minute}, nil
    }

    ruta.Status.Fase = "Activa"
    ruta.Status.PlazasVendidas = vendidas
    ruta.Status.ObservedGeneration = ruta.Generation   // marca de "ya procesado"
    meta.SetStatusCondition(&ruta.Status.Condiciones, metav1.Condition{
        Type:    "InformesProgramados",
        Status:  metav1.ConditionTrue,
        Reason:  "CronJobActivo",
        Message: fmt.Sprintf("Informes de la ruta %s programados", ruta.Spec.Codigo),
    })
    // Escritura por el SUBRECURSO status: no toca el spec del usuario (06-06)
    if err := r.Status().Update(ctx, &ruta); err != nil {
        return ctrl.Result{}, err
    }

    // ── 7. RECONCILIACIÓN PERIÓDICA ──────────────────────────────────────
    // Aunque no haya eventos, revisar cada 10 minutos: detecta cambios
    // externos y desviaciones que no generaron notificación.
    return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
}

func (r *RutaProgramadaReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&rutasnortev1.RutaProgramada{}).
        // Observar también los CronJobs propios: si alguien borra uno a mano,
        // se dispara una reconciliación de su ruta y se recrea.
        Owns(&batchv1.CronJob{}).
        Complete(r)
}

Los siete pasos son el esqueleto de cualquier operador, sea de rutas de autobús o de PostgreSQL: leer lo deseado, contemplar el caso de borrado, construir lo que debería existir, marcar la propiedad, crear o actualizar solo si hace falta, observar la realidad y escribir el status, y decidir cuándo volver a mirar.

El Owns(&batchv1.CronJob{}) de SetupWithManager merece un comentario: hace que el controlador reciba eventos de los CronJobs que él creó, y los traduzca a reconciliaciones de la RutaProgramada propietaria. Es lo que hace que borrar el CronJob a mano provoque su recreación en segundos. Sin esa línea, el operador solo reaccionaría a cambios en sus propios recursos personalizados.

Los permisos RBAC

Los marcadores +kubebuilder:rbac: de arriba generan este ClusterRole:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: operador-rutasnorte-manager-role
rules:
  - apiGroups: ["rutasnorte.rutasnorte.example"]
    resources: ["rutasprogramadas"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: ["rutasnorte.rutasnorte.example"]
    resources: ["rutasprogramadas/status"]
    verbs: ["get", "update", "patch"]
  - apiGroups: ["batch"]
    resources: ["cronjobs"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]

Obsérvese el principio de mínimo privilegio: el operador solo puede tocar CronJobs y sus propios recursos. No puede leer Secrets, ni crear Deployments, ni tocar nada más. Cuando evalúes un operador ajeno (apartado 7), esto es exactamente lo que debes mirar y comparar con lo que el operador dice que hace. RBAC en detalle es la lección 08-01.

Ejecutar y desplegar

# Generar CRD y RBAC a partir de los marcadores
make manifests generate

# Instalar los CRD en el clúster
make install

# Ejecutar el controlador LOCALMENTE contra el clúster: el ciclo de desarrollo
make run
INFO  setup   starting manager
INFO  Starting EventSource  {"controller": "rutaprogramada", "source": "kind source: *v1.RutaProgramada"}
INFO  Starting Controller   {"controller": "rutaprogramada"}
INFO  Creando CronJob de informes  {"ruta": "RN-041"}
# Y para producción: imagen y despliegue dentro del clúster
make docker-build docker-push IMG=registry.rutasnorte.example/operador-rutasnorte:0.1.0
make deploy IMG=registry.rutasnorte.example/operador-rutasnorte:0.1.0

make run es la ventaja práctica de este modelo: el controlador corre en tu portátil, con depurador si hace falta, hablando con el clúster real. No hay que construir imágenes para cada iteración.

  1. ownerReferences y el borrado en cascada

En la lección 02-02 vimos que un ReplicaSet pone ownerReferences en sus pods, y que por eso borrar un Deployment borra todo lo que hay debajo. Los operadores usan el mismo mecanismo, y es imprescindible entenderlo.

    if err := ctrl.SetControllerReference(&ruta, deseado, r.Scheme); err != nil {
        return ctrl.Result{}, err
    }

Esa línea escribe en el CronJob generado:

  ownerReferences:
    - apiVersion: rutasnorte.rutasnorte.example/v1
      kind: RutaProgramada
      name: rn-041-bilbao-santander
      uid: 3f1a9c04-8e2b-4c71-9a55-71b0e2d8c4f3
      controller: true
      blockOwnerDeletion: true

Consecuencias:

  1. Borrado en cascada automático. Al borrar la RutaProgramada, el recolector de basura de Kubernetes borra el CronJob. El operador no tiene que escribir código de limpieza: el propio clúster se ocupa.
  2. La propiedad es visible. kubectl describe cronjob muestra Controlled By: RutaProgramada/rn-041-bilbao-santander, lo que hace trazable de dónde salió cada objeto.
  3. Eventos hacia el propietario. Gracias a Owns(), cambios en el CronJob provocan reconciliaciones de la ruta.

Dos restricciones importantes:

  • El propietario y el objeto poseído deben estar en el mismo namespace. Un objeto de namespace no puede ser propiedad de otro de un namespace distinto.
  • Un objeto de clúster no puede ser propiedad de uno de namespace. Si tu operador crea ClusterRoles o PersistentVolumes, tendrás que limpiarlos tú.

Finalizadores: limpieza fuera del clúster

Cuando el operador crea recursos externos —un bucket, un registro DNS, una base de datos en un proveedor— las ownerReferences no sirven: el recolector de basura de Kubernetes no sabe nada de esos recursos.

Para eso están los finalizadores, el mismo mecanismo que vimos protegiendo los PVC en 05-03:

const finalizador = "rutasnorte.example/limpiar-recursos-externos"

if !ruta.DeletionTimestamp.IsZero() {
    // El objeto está marcado para borrado pero NO se ha borrado:
    // el finalizador lo retiene hasta que lo quitemos.
    if controllerutil.ContainsFinalizer(&ruta, finalizador) {
        if err := r.borrarRecursosExternos(ctx, &ruta); err != nil {
            return ctrl.Result{}, err   // reintentará; el objeto sigue retenido
        }
        controllerutil.RemoveFinalizer(&ruta, finalizador)
        if err := r.Update(ctx, &ruta); err != nil {
            return ctrl.Result{}, err
        }
    }
    return ctrl.Result{}, nil    // ahora sí, Kubernetes completa el borrado
}

// Objeto vivo: asegurar que tiene el finalizador
if !controllerutil.ContainsFinalizer(&ruta, finalizador) {
    controllerutil.AddFinalizer(&ruta, finalizador)
    if err := r.Update(ctx, &ruta); err != nil {
        return ctrl.Result{}, err
    }
}

Advertencia operativa de primer orden: si el operador deja de funcionar y hay objetos con su finalizador, esos objetos se quedan en Terminating para siempre. kubectl delete se queda colgado y no hay forma limpia de salir salvo editar el objeto y quitar el finalizador a mano:

kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  --type=merge -p '{"metadata":{"finalizers":null}}'

Eso deja los recursos externos huérfanos, que habrá que limpiar por otra vía. Es una de las razones de la pregunta "¿qué pasa si lo desinstalo?" del apartado 7: desinstala siempre el operador después de borrar sus objetos, nunca antes.

  1. Cuándo NO escribir un operador

Escribir un operador es divertido y casi siempre innecesario. Antes de empezar, pasa este filtro.

No lo escribas si tu aplicación no tiene estado

Un Deployment, un Service, un HorizontalPodAutoscaler y un ConfigMap cubren api-reservas, tienda-web y worker-notificaciones completamente. Un operador no añadiría nada: el controlador de Deployments ya hace la reconciliación.

Los operadores brillan con software con estado y con procedimientos operativos complejos: bases de datos, colas de mensajes, sistemas de consenso, almacenes distribuidos. Si tu aplicación se reinicia sin consecuencias, no necesitas uno.

No lo escribas si Helm o Kustomize bastan

Necesidad Herramienta
Desplegar con valores distintos por entorno Helm (10-03) o Kustomize (10-04)
Aplicar automáticamente lo que hay en Git GitOps con Argo CD o Flux (10-05)
Reaccionar a fallos y ejecutar procedimientos continuamente Operador
Ejecutar algo periódicamente CronJob (06-03)
Validar o modificar objetos en la admisión Webhook, no operador

La pregunta discriminante: "¿qué tiene que pasar cuando algo se rompe a las tres de la madrugada?" Si la respuesta es "nada, el Deployment lo recrea", no necesitas operador. Si es "hay que promover una réplica, reconfigurar el enrutado y avisar", ahí sí hay un operador.

No lo escribas si ya existe uno

Para PostgreSQL, MySQL, Redis, Kafka, MongoDB, Elasticsearch, RabbitMQ y prácticamente cualquier software conocido ya existen operadores maduros, mantenidos por equipos que llevan años en ello. Escribir el tuyo significa reimplementar peor lo que otros ya resolvieron, y mantenerlo tú solo.

El coste real de mantener un operador

Lo que la gente subestima:

  • Es un servicio de producción con permisos privilegiados. Necesita despliegue, actualizaciones, monitorización, alertas y guardias.
  • Un fallo en el operador afecta a todo lo que gestiona. Un Reconcile con un error puede borrar objetos en cascada en todo el clúster. Hay incidentes públicos famosos por esto.
  • Los CRD son una API pública con las obligaciones de compatibilidad que vimos en 06-06. Cambiar el esquema después es caro.
  • Probar un operador es difícil. Hacen falta pruebas con envtest o un clúster efímero, y la lógica de reconciliación tiene muchos caminos posibles.
  • Requiere Go y conocimiento profundo de Kubernetes en el equipo, a perpetuidad, no solo mientras se escribe.

Una regla de oro sensata:

Escribe un operador cuando tengas un procedimiento operativo documentado, repetitivo, que se ejecuta a menudo y que se ejecuta mal cuando lo hace una persona cansada. Si no puedes escribir ese runbook con precisión, tampoco podrás codificarlo.

Alternativas más baratas

En vez de un operador Prueba primero
Automatizar un despliegue Helm + GitOps (10-03, 10-05)
Ejecutar algo periódicamente CronJob (06-03)
Reaccionar a un evento puntual Un Job disparado desde el pipeline de CI
Añadir configuración a los pods Webhook mutante o initContainer (06-04)
Gestionar una base de datos Un operador existente o un servicio gestionado (10-06)
Validar manifiestos Políticas con Kyverno o esquemas en CI

Errores Comunes y Consejos

El bucle infinito de reconciliación. El error número uno: Reconcile escribe en la API en cada pasada aunque nada haya cambiado, la escritura genera un evento, el evento dispara otra reconciliación. Síntoma: miles de update por minuto sobre el mismo objeto. Solución: comparar antes de escribir, con DeepDerivative o similar.

Guardar estado en memoria. Variables globales, mapas de "ya lo hice". Se pierden al reiniciar y se desincronizan del mundo real. Todo el estado va en .status, en anotaciones o en los objetos gestionados.

Escribir .status sin el subrecurso. Un Update del objeto completo puede sobrescribir un cambio de spec que el usuario acaba de hacer. Usa siempre r.Status().Update() con el subrecurso activado.

Olvidar ownerReferences. Los objetos creados por el operador quedan huérfanos al borrar el recurso principal. Con el tiempo el clúster se llena de CronJobs, Services y Secrets que nadie sabe de dónde salieron.

Desinstalar el operador antes de borrar sus objetos. Si usa finalizadores, los objetos se quedan en Terminating para siempre. Orden correcto: borrar los objetos, comprobar que desaparecen, y solo entonces desinstalar el operador.

Adoptar un operador que pide cluster-admin. Es una puerta abierta a todo el clúster. Revisa siempre el ClusterRole antes de aplicar el manifiesto, y desconfía de cualquiera que pida acceso a todos los Secrets sin justificarlo.

Confundir "tiene CRD" con "es un operador". Un CRD sin controlador es una base de datos con formulario, como vimos en 06-06. Comprueba que hay un Deployment corriendo y que escribe en el status de los objetos.

No leer el nivel de capacidades. Un operador de nivel 2 no te va a salvar de una conmutación por error nocturna. Si lo adoptas creyendo que llega al 5, la sorpresa llegará en el peor momento.

Consejo: usa make run en desarrollo. El controlador corre en tu portátil contra el clúster real, con depurador. El ciclo de iteración pasa de minutos a segundos.

Consejo: emite eventos. r.Recorder.Eventf(...) hace que las acciones del operador aparezcan en kubectl describe del objeto. Es la diferencia entre un operador que se puede diagnosticar y uno que es una caja negra.

Consejo: registra observedGeneration. Permite a cualquiera saber si el controlador ha procesado el último cambio del spec, y es la base para que kubectl wait funcione de forma fiable sobre tus recursos.

Consejo: prueba matando el controlador. Bórralo en mitad de una operación y comprueba que al volver retoma correctamente. Si no lo hace, tienes estado en memoria o falta idempotencia.

Ejercicios

Ejercicio 1: identificar operadores en tu clúster

En tu minikube (perfil rutas-norte), identifica qué operadores hay instalados. Para cada uno, determina: qué CRD aporta, dónde corre su controlador, y qué permisos de ClusterRole tiene. Después razona por qué cert-manager es un operador y el controlador de Deployments, siendo el mismo patrón, no se llama así.

Ejercicio 2: la diferencia entre reconciliar y desplegar

Demuestra experimentalmente que un controlador reconcilia continuamente y un despliegue no:

  1. Crea un Deployment demo-reconciliacion con 3 réplicas en rutas-norte-dev.
  2. Borra un pod y observa qué ocurre y en cuánto tiempo.
  3. Cambia manualmente la imagen de un pod con kubectl edit pod y observa el resultado.
  4. Explica qué controlador actuó en cada caso y con qué información.

Ejercicio 3: diseñar un operador (sin escribirlo)

Rutas Norte quiere un recurso EntornoPruebas que, al crearse, provoque automáticamente: un namespace propio, una restauración de postgres-reservas desde el último snapshot, un despliegue de api-reservas apuntando a esa base de datos, y el borrado completo a los 7 días.

Sin escribir código, diseña:

  1. El spec y el status del CRD (campos y tipos).
  2. Los pasos de la función Reconcile, en orden.
  3. Los permisos RBAC mínimos que necesitaría.
  4. Qué usarías para el borrado a los 7 días y por qué.
  5. Si haría falta un finalizador, y para qué.

Soluciones

Solución 1

# Qué CRD hay y de quién son
kubectl get crds -o custom-columns=NOMBRE:.metadata.name,GRUPO:.spec.group | sort -k2
NOMBRE                                            GRUPO
certificaterequests.cert-manager.io                cert-manager.io
certificates.cert-manager.io                       cert-manager.io
clusterissuers.cert-manager.io                     cert-manager.io
issuers.cert-manager.io                            cert-manager.io
volumesnapshotclasses.snapshot.storage.k8s.io      snapshot.storage.k8s.io
volumesnapshotcontents.snapshot.storage.k8s.io     snapshot.storage.k8s.io
volumesnapshots.snapshot.storage.k8s.io            snapshot.storage.k8s.io
# Dónde corren los controladores
kubectl get deployments -A | grep -Ei 'cert-manager|snapshot|controller'
cert-manager    cert-manager              1/1   1   1   24d
cert-manager    cert-manager-cainjector   1/1   1   1   24d
cert-manager    cert-manager-webhook      1/1   1   1   24d
kube-system     snapshot-controller       1/1   1   1   17d
# Qué permisos tiene cert-manager
kubectl get clusterrole -l app.kubernetes.io/instance=cert-manager \
  -o custom-columns=NOMBRE:.metadata.name --no-headers | head -5

kubectl describe clusterrole cert-manager-controller-certificates | head -20
Name:  cert-manager-controller-certificates
PolicyRule:
  Resources                              Verbs
  ---------                              -----
  certificaterequests.cert-manager.io     [create delete get list patch update watch]
  certificates.cert-manager.io            [get list patch update watch]
  certificates.cert-manager.io/status     [patch update]
  secrets                                 [create delete get list patch update watch]
  events                                  [create patch]

Permisos acotados: sus propios CRD, más Secrets (necesarios: ahí guarda claves y certificados) y eventos. No pide cluster-admin.

Por qué cert-manager se llama operador y el controlador de Deployments no, siendo el mismo patrón:

La diferencia no es técnica, es de origen. Ambos son bucles de reconciliación sobre tipos de la API. El controlador de Deployments gestiona un tipo nativo y su código vive dentro del kube-controller-manager, un binario del plano de control. cert-manager gestiona tipos añadidos mediante CRD y corre como una carga de trabajo más del clúster.

El término "operador" designa esa segunda situación: el patrón controlador aplicado a un dominio que Kubernetes no conoce de fábrica, empaquetado como aplicación desplegable. Conceptualmente, el controlador de Deployments es un operador de Deployments; simplemente nadie lo llama así porque viene incluido.

Solución 2

# /tmp/demo-reconciliacion.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-reconciliacion
  namespace: rutas-norte-dev
  labels:
    app: demo-reconciliacion
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo-reconciliacion
      entorno: dev
  template:
    metadata:
      labels:
        app: demo-reconciliacion
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      containers:
        - name: nginx
          image: nginx:1.27.2-alpine
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
kubectl apply -f /tmp/demo-reconciliacion.yaml
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacion
NAME                                   READY   STATUS    RESTARTS   AGE
demo-reconciliacion-6b4d8f7c9-h2k4x    1/1     Running   0          22s
demo-reconciliacion-6b4d8f7c9-p8m2v    1/1     Running   0          22s
demo-reconciliacion-6b4d8f7c9-t5n7q    1/1     Running   0          22s

2. Borrar un pod:

kubectl delete pod demo-reconciliacion-6b4d8f7c9-h2k4x -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacion
NAME                                   READY   STATUS              RESTARTS   AGE
demo-reconciliacion-6b4d8f7c9-p8m2v    1/1     Running             0          2m
demo-reconciliacion-6b4d8f7c9-t5n7q    1/1     Running             0          2m
demo-reconciliacion-6b4d8f7c9-w3x9z    0/1     ContainerCreating   0          1s

En menos de un segundo hay un sustituto. Nadie ejecutó ningún comando: el ReplicaSet observó que había 2 pods donde debía haber 3 y creó uno.

3. Cambiar la imagen de un pod a mano:

kubectl set image pod/demo-reconciliacion-6b4d8f7c9-p8m2v \
  -n rutas-norte-dev nginx=nginx:1.26.2-alpine

kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacion \
  -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image
POD                                   IMAGEN
demo-reconciliacion-6b4d8f7c9-p8m2v   nginx:1.26.2-alpine
demo-reconciliacion-6b4d8f7c9-t5n7q   nginx:1.27.2-alpine
demo-reconciliacion-6b4d8f7c9-w3x9z   nginx:1.27.2-alpine

El cambio persiste. Esto sorprende, pero es correcto y muy instructivo.

4. Qué controlador actuó y con qué información:

Caso Controlador Qué comparó Resultado
Pod borrado ReplicaSet Pods que casan con su selector (2) vs replicas (3) Creó un pod
Imagen cambiada a mano Ninguno El cambio persiste

La clave está en qué reconcilia cada controlador:

  • El controlador de ReplicaSets reconcilia el número de pods que casan con su selector. Cuenta 2, quiere 3, crea uno. Le da igual qué imagen tengan.
  • El controlador de Deployments reconcilia qué ReplicaSets deben existir según la plantilla del Deployment. Como la plantilla no cambió, no hace nada.
  • Nadie reconcilia el contenido de un pod individual contra la plantilla del Deployment. La plantilla se usa al crear el pod, no para vigilarlo después.

Lección práctica: la reconciliación es de objetos gestionados, no de campos arbitrarios. Si borras el pod modificado, su sustituto nacerá de la plantilla y volverá a nginx:1.27.2-alpine. Y un kubectl rollout restart deployment/demo-reconciliacion recrearía todos los pods desde la plantilla, corrigiendo la desviación.

kubectl rollout restart deployment/demo-reconciliacion -n rutas-norte-dev
kubectl rollout status deployment/demo-reconciliacion -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacion \
  -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image
POD                                    IMAGEN
demo-reconciliacion-7c9e5a2b4-b1k8m    nginx:1.27.2-alpine
demo-reconciliacion-7c9e5a2b4-j4t2p    nginx:1.27.2-alpine
demo-reconciliacion-7c9e5a2b4-r6n9w    nginx:1.27.2-alpine
kubectl delete -f /tmp/demo-reconciliacion.yaml

Solución 3

1. Diseño del CRD:

spec:
  solicitante: string                 # obligatorio, correo del desarrollador
  ramaGit: string                     # obligatorio, patrón de rama válida
  duracionDias: integer               # 1-14, por defecto 7
  origenDatos:                        # de dónde restaurar
    tipo: string                      # enum: snapshot, copiaVelero, vacio
    nombre: string                    # nombre del snapshot o de la copia
  componentes:                        # qué desplegar
    apiReservas: boolean              # por defecto true
    tiendaWeb: boolean                # por defecto false
  tamanoBaseDatos: string             # por defecto 5Gi

status:
  fase: string                        # enum: Pendiente, Creando, Restaurando, Lista, Caducando, Error
  namespaceCreado: string
  urlAcceso: string
  fechaCaducidad: string (date-time)  # calculada: creationTimestamp + duracionDias
  observedGeneration: integer
  condiciones: []Condition            # NamespaceCreado, DatosRestaurados, ComponentesListos

Subrecurso status activado. additionalPrinterColumns: Solicitante, Fase, Caducidad, Antigüedad.

2. Pasos de Reconcile:

  1. Leer el EntornoPruebas. Si no existe, terminar (IgnoreNotFound).
  2. Si tiene DeletionTimestamp, ejecutar la lógica del finalizador (paso 10) y terminar.
  3. Asegurar el finalizador si no lo tiene.
  4. Comprobar la caducidad: si now() > status.fechaCaducidad, borrar el propio objeto y terminar. La cascada hará el resto.
  5. Asegurar el namespace pruebas-<nombre> con etiquetas de propiedad. (Es un objeto de clúster: no admite ownerReferences de un objeto de namespace; se limpia en el finalizador.)
  6. Asegurar el PVC restaurado desde el snapshot, con dataSource (05-05). Si aún no está Bound, actualizar status.fase = "Restaurando" y devolver RequeueAfter: 30s.
  7. Asegurar el StatefulSet/Cluster de PostgreSQL y su Secret de credenciales generado.
  8. Asegurar los Deployments y Services de los componentes marcados en spec.componentes, y el Ingress si procede.
  9. Observar la realidad: ¿están todos los pods Ready? Escribir status.fase, urlAcceso, condiciones y observedGeneration mediante el subrecurso.
  10. Devolver RequeueAfter calculado: hasta la caducidad si falta mucho, o 1 minuto si está cerca, para que el paso 4 se dispare a tiempo.

3. RBAC mínimo:

rules:
  - apiGroups: ["rutasnorte.example"]
    resources: ["entornospruebas"]
    verbs: ["get", "list", "watch", "update", "patch", "delete"]
  - apiGroups: ["rutasnorte.example"]
    resources: ["entornospruebas/status", "entornospruebas/finalizers"]
    verbs: ["get", "update", "patch"]
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["services", "secrets", "persistentvolumeclaims"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: ["apps"]
    resources: ["deployments", "statefulsets"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: ["snapshot.storage.k8s.io"]
    resources: ["volumesnapshots"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses", "networkpolicies"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]

Nótese lo que no está: nada de cluster-admin, ni permisos sobre nodos, ni sobre RBAC. Los Secrets son inevitables (genera credenciales), y eso ya es motivo suficiente para revisar el código antes de darle esos permisos.

4. El borrado a los 7 días:

La opción correcta es el propio bucle de reconciliación con RequeueAfter, no un CronJob de limpieza:

  • El operador ya está observando el objeto: comprobar una fecha en cada reconciliación es gratis.
  • RequeueAfter garantiza que se despierta a tiempo aunque no haya ningún otro evento.
  • Un CronJob externo sería un segundo componente con su propia lógica, sus propios permisos y su propia posibilidad de fallar, y podría estar desincronizado con lo que el operador cree.
  • Además, ttlSecondsAfterFinished (06-03) no aplica: eso es para Jobs, no para recursos personalizados.

Detalle de implementación: el operador borra su propio objeto (r.Delete(ctx, &entorno)), y el borrado en cascada más el finalizador se ocupan de todo lo demás. Es más limpio que ir borrando recursos uno a uno.

5. Finalizador: sí, y es imprescindible.

Motivos concretos:

  • El namespace es un objeto de clúster desde el punto de vista de las ownerReferences de un objeto que vive en otro namespace: no se puede establecer la propiedad, así que hay que borrarlo explícitamente.
  • Hay que verificar que el PVC se libera antes de dar el borrado por terminado, evitando volúmenes huérfanos con la clase Retain.
  • Puede haber recursos externos: un registro DNS pruebas-xyz.rutasnorte.example, una entrada en el sistema de facturación interna. Nada de eso lo conoce el recolector de basura de Kubernetes.
  • Conviene notificar al solicitante de que su entorno ha caducado antes de completar el borrado.

Con la advertencia del apartado 9 bien presente: si el operador se cae con objetos pendientes de finalizar, esos objetos quedan en Terminating para siempre. Por eso el finalizador debe ser rápido, tolerante a fallos y con un camino de escape documentado.

Conclusión

Un operador es un recurso personalizado más un controlador que lo reconcilia: el conocimiento operativo de un experto codificado en software que corre dentro del clúster y no duerme nunca. A diferencia de un script o de Helm, que actúan cuando alguien los invoca, un operador mantiene el estado deseado continuamente.

Su motor es el bucle de reconciliación de 01-02, ahora visto por dentro: un watch con caché local, una cola de trabajo con deduplicación y retroceso exponencial, y una función Reconcile que recibe solo un nombre y cuyo contrato es siempre el mismo —lee lo deseado, mira lo real, acércalos— y que termina escribiendo el status por su subrecurso. Dos propiedades no negociables: idempotencia, porque Reconcile se ejecutará muchas más veces de las que esperas, y ausencia de estado en memoria, porque el controlador puede reiniciarse en cualquier momento.

El modelo de cinco niveles —instalación, actualizaciones, ciclo de vida, observabilidad y piloto automático— es la herramienta para evaluar un operador antes de adoptarlo, junto con el mantenimiento del proyecto, los permisos RBAC que pide y la pregunta decisiva de qué pasa si lo desinstalas.

Hemos cerrado la deuda que abrimos en 06-01: un operador de PostgreSQL sustituye nuestro StatefulSet artesanal y aporta lo que un StatefulSet nunca podrá dar por sí solo —elección de primario, conmutación por error automática en segundos, réplicas de lectura, Services -rw y -ro que se reescriben solos, copias programadas y recuperación a un instante concreto— con un YAML en lugar de semanas de trabajo y guardias nocturnas. Y hemos esbozado el controlador de RutaProgramada con Kubebuilder: los marcadores que generan el CRD y el RBAC, los siete pasos de Reconcile, ownerReferences para el borrado en cascada y finalizadores para lo que vive fuera del clúster.

Y hemos terminado donde había que terminar: la mayoría de los equipos no deberían escribir operadores. Para aplicaciones sin estado no aportan nada, para software conocido ya existen y son mejores que el que escribirías, y mantener uno significa operar un servicio privilegiado a perpetuidad. Escríbelo solo cuando tengas un procedimiento repetitivo, documentado y que se ejecuta mal a las tres de la madrugada.

Cierre del módulo 6

Con esta lección se cierra el módulo de conceptos avanzados. La plataforma de Rutas Norte ha cambiado mucho:

Componente Cómo llegó al módulo 6 Cómo sale
postgres-reservas Deployment de 1 réplica con Recreate Clúster gestionado por operador, 3 instancias, conmutación automática
redis-cache Deployment StatefulSet con disco por réplica y arranque en caliente
informes-ocupacion No existía CronJob nocturno con su PVC, su SA y su NetworkPolicy
Logs de todos los nodos Sin recoger DaemonSet recolector en cada nodo
api-reservas Un contenedor Con initContainer de espera y embajador de pagos
worker-notificaciones Log propietario en un fichero Con adaptador que emite JSON estructurado
Colocación de pods Donde cayera Plan completo: afinidad, antiafinidad, nodo de análisis dedicado
Vocabulario propio Ninguno CRD RutaProgramada extendiendo la API

Es una plataforma potente. Y es, en este momento, completamente opaca.

No hay forma de saber si api-reservas está sana, más allá de que su pod diga Running —que solo significa que el proceso arrancó, no que funcione—. No sabemos cuánta memoria consume realmente postgres-reservas ni si los 2 GiB que le reservamos en 03-05 sobran o faltan. Si el CronJob de informes falló anoche a las tres de la madrugada, la única pista son unos logs que se borrarán con el historial. Si tienda-web empieza a responder en tres segundos en lugar de en cien milisegundos, nos enteraremos por una queja de un cliente que no pudo comprar su billete.

Todo lo que hemos construido está a ciegas. Eso se acaba en el módulo 7: Monitoreo y Registro. Empezaremos por lo más básico y lo más importante —enseñar a Kubernetes a distinguir un contenedor que arrancó de uno que funciona— con las verificaciones de salud y sondas de la lección 07-01.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados