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
- Qué es un operador, con precisión
- Anatomía real del bucle de reconciliación
- Idempotencia y ausencia de estado en memoria
- El modelo de capacidades en cinco niveles
- Operadores que ya has usado en este curso
- Caso práctico: un operador de PostgreSQL para
postgres-reservas - Dónde encontrar operadores y qué mirar antes de adoptar uno
- Cómo se escribe uno: Kubebuilder y
controller-runtime ownerReferencesy el borrado en cascada- Cuándo NO escribir un operador
- Qué es un operador, con precisión
Un operador tiene exactamente dos piezas:
- Uno o varios CRD que definen el vocabulario:
Cluster,Certificate,RutaProgramada. Es la interfaz declarativa con la que el usuario expresa qué quiere. - 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.
- 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:
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"}'Si difieren, hay un cambio del spec que el controlador aún no ha atendido.
- 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.
- 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.
- 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:
- El controlador de cert-manager observó el nuevo objeto
Certificate. - Creó un
CertificateRequest, generó una clave privada y la guardó en un Secret. - Creó un
Ordery unChallengepara el protocolo ACME. - Publicó un Ingress temporal con el token del desafío HTTP-01.
- Esperó a que Let's Encrypt validara, obtuvo el certificado y lo escribió en el Secret.
- 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.
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 24dTres 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.
- Caso práctico: un operador de PostgreSQL para
postgres-reservas
postgres-reservasLlegamos 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 postgresqlNAME 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:12ZUn 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-reservasNAME AGE INSTANCES READY STATUS PRIMARY
postgres-reservas 3m42s 3 3 Cluster in healthy state postgres-reservas-1NAME 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/TCPLos 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 -wNAME 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-2En 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: passwordEl 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.
- 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:
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?
- Cómo se escribe uno: Kubebuilder y
controller-runtime
controller-runtimeNadie 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 --controllerEstructura 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 deployLos 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 runINFO 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.0make 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.
ownerReferences y el borrado en cascada
ownerReferences y el borrado en cascadaEn 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: trueConsecuencias:
- 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. - La propiedad es visible.
kubectl describe cronjobmuestraControlled By: RutaProgramada/rn-041-bilbao-santander, lo que hace trazable de dónde salió cada objeto. - 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.
- 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
Reconcilecon 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
envtesto 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:
- Crea un Deployment
demo-reconciliacioncon 3 réplicas enrutas-norte-dev. - Borra un pod y observa qué ocurre y en cuánto tiempo.
- Cambia manualmente la imagen de un pod con
kubectl edit pody observa el resultado. - 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:
- El
specy elstatusdel CRD (campos y tipos). - Los pasos de la función
Reconcile, en orden. - Los permisos RBAC mínimos que necesitaría.
- Qué usarías para el borrado a los 7 días y por qué.
- 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 -k2NOMBRE 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 -20Name: 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: 64Mikubectl apply -f /tmp/demo-reconciliacion.yaml
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacionNAME 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 22s2. Borrar un pod:
kubectl delete pod demo-reconciliacion-6b4d8f7c9-h2k4x -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacionNAME 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 1sEn 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].imagePOD 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-alpineEl 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].imagePOD 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-alpineSolució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, ComponentesListosSubrecurso status activado. additionalPrinterColumns: Solicitante, Fase, Caducidad, Antigüedad.
2. Pasos de Reconcile:
- Leer el
EntornoPruebas. Si no existe, terminar (IgnoreNotFound). - Si tiene
DeletionTimestamp, ejecutar la lógica del finalizador (paso 10) y terminar. - Asegurar el finalizador si no lo tiene.
- Comprobar la caducidad: si
now() > status.fechaCaducidad, borrar el propio objeto y terminar. La cascada hará el resto. - Asegurar el namespace
pruebas-<nombre>con etiquetas de propiedad. (Es un objeto de clúster: no admiteownerReferencesde un objeto de namespace; se limpia en el finalizador.) - Asegurar el PVC restaurado desde el snapshot, con
dataSource(05-05). Si aún no estáBound, actualizarstatus.fase = "Restaurando"y devolverRequeueAfter: 30s. - Asegurar el StatefulSet/Cluster de PostgreSQL y su Secret de credenciales generado.
- Asegurar los Deployments y Services de los componentes marcados en
spec.componentes, y el Ingress si procede. - Observar la realidad: ¿están todos los pods
Ready? Escribirstatus.fase,urlAcceso, condiciones yobservedGenerationmediante el subrecurso. - Devolver
RequeueAftercalculado: 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.
RequeueAftergarantiza 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
ownerReferencesde 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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
