El operador
El bucle, en Go
🕐 Este laboratorio tarda en arrancar más que el resto del curso, y es normal. Antes de que
escribas la primera línea, el playground se descarga el toolchain de Go y compila el operador
para que puedas ejecutarlo al instante en vez de esperar al primer go build. Cuenta con dos a
cuatro minutos desde que abres la lección hasta que la primera tarea se pone en verde.
Si la terminal ya responde pero ls /home/laborant/operador todavía no muestra el binario
operador-promociones, no has hecho nada mal: sigue compilando. Espera a que la primera tarea
se marque sola antes de empezar.
⚠️ El operador de esta lección es un ejemplo didáctico, no una plantilla de producción. Está escrito en un único fichero y recortado a propósito para que quepa en una pantalla y se pueda leer entero: le faltan las probes de salud del manager, la elección de líder, las métricas, los tests con envtest y la separación en paquetes que trae cualquier proyecto serio.
Para escribir uno de verdad, el punto de partida no es este fichero: es Kubebuilder u Operator SDK, que generan el esqueleto completo con todo eso resuelto, y la documentación de controller-runtime y el Operator Whitepaper de la CNCF. Lo que sí te llevas de aquí, y es lo que importa, es el mecanismo: qué hace un bucle de reconciliación y por qué.
En la lección anterior le enseñaste a Kubernetes un sustantivo (Promocion) y comprobaste que no significaba nada. Ahora le vas a dar un verbo, y lo vas a escribir en el mismo lenguaje y con la misma librería que usa el propio Kubernetes.
Un controlador es un programa que hace tres cosas, en bucle, para siempre:
- Observar el estado deseado.
- Comparar con el estado real.
- Actuar para acercar el segundo al primero.
El kube-controller-manager que estudiaste en el capítulo de arquitectura no es más que un montón de estos bucles corriendo juntos: uno para los Deployments, otro para los ReplicaSets, otro para los nodos. El tuyo va a ser uno más, y va a usar exactamente la misma librería: controller-runtime.
El código ya está escrito, en /home/laborant/operador. El objetivo de esta unidad no es teclearlo: es leerlo, así que ábrelo en la pestaña IDE.
types.go: el CRD, en Go
El CRD que aplicaste ayer es un contrato en YAML. Este fichero es el mismo contrato en Go:
var GroupVersion = schema.GroupVersion{Group: "tienda.example.com", Version: "v1"}
type PromocionSpec struct {
Banner string `json:"banner"`
Replicas *int32 `json:"replicas,omitempty"`
}
type PromocionStatus struct {
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
Fase string `json:"fase,omitempty"`
}
type Promocion struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec PromocionSpec `json:"spec,omitempty"`
Status PromocionStatus `json:"status,omitempty"`
}
Preguntas y respuestas
¿Por qué Replicas es un puntero (*int32) y Banner no?
Porque un puntero puede ser nil, y una cadena vacía no se distingue de "no lo puso". Con *int32, el operador puede diferenciar entre "el usuario pidió 0 réplicas" y "el usuario no dijo nada". Con un int32 a secas, ambos casos llegan como 0 y son indistinguibles.
Es la convención en toda la API de Kubernetes: si un campo es opcional y su valor cero significa algo, va como puntero. Míralo en el spec.replicas de un Deployment: también es *int32.
¿Qué son TypeMeta y ObjectMeta embebidos?
Son el apiVersion/kind y el metadata que llevan todos los objetos de Kubernetes. Al embeberlos, tu tipo hereda Name, Namespace, Labels, Annotations, OwnerReferences... y pasa a ser un objeto de Kubernetes de pleno derecho, no una estructura de datos cualquiera.
¿Y esas funciones DeepCopy al final del fichero?
Ahí está la única concesión de esta lección. Kubernetes exige que todo objeto sepa copiarse a sí mismo en profundidad, porque los clientes cachean objetos en memoria y una copia superficial permitiría que un controlador modificara sin querer la caché compartida. Es un requisito de seguridad de memoria, no un capricho.
En un proyecto real esas funciones las genera controller-gen a partir de unos comentarios mágicos (// +kubebuilder:object:root=true). Aquí las hemos escrito a mano, y son veinte líneas, porque el objetivo es que en este operador no haya ni una sola línea de magia.
¿Qué hace AddToScheme?
Registra tu tipo en el scheme: la tabla que le dice al cliente "cuando veas un objeto con apiVersion: tienda.example.com/v1 y kind: Promocion, deserialízalo en esta estructura de Go, y para escribirlo, manda la petición a /apis/tienda.example.com/v1/namespaces/{ns}/promociones".
Sin ese registro, el cliente no sabría ni qué URL llamar.
main.go: el reconciler
Aquí está el corazón. La función Reconcile se llama una vez por cada Promocion que necesita atención, y recibe un solo argumento útil: su nombre.
func (r *PromocionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var promo Promocion
if err := r.Get(ctx, req.NamespacedName, &promo); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// ... crear ConfigMap, Deployment y Service ...
// ... actualizar el status ...
return ctrl.Result{}, nil
}
Preguntas y respuestas
Reconcile solo recibe un nombre. ¿No debería recibir qué ha cambiado?
Y aquí está la idea más importante de todo el módulo: no, y es deliberado.
Un controlador nunca recibe un "evento" del tipo "se ha borrado el Deployment X". Recibe un nombre y tiene que averiguar por sí mismo cómo está el mundo. Esto se llama reconciliación a nivel de estado (level-based), frente a reaccionar a eventos (edge-based), y tiene una consecuencia enorme:
Si tu controlador se pierde un evento, no pasa nada. Si se reinicia, no pasa nada. Si se queda dormido una hora, no pasa nada. Si arranca en un clúster que ya tiene la mitad del trabajo hecho, no pasa nada.
Un controlador basado en eventos, en cambio, se desincroniza para siempre en cuanto pierde un mensaje. Y los mensajes se pierden: se reinicia el proceso, se corta la conexión, expira un watch.
Es la razón de fondo por la que Kubernetes es fiable. Y es un principio que puedes robar para tus propios sistemas.
¿Por qué client.IgnoreNotFound(err) cuando el Promocion no existe?
Porque el Promocion ya se ha borrado, y no hay nada que limpiar. Los objetos que creó desaparecerán solos, por sus ownerReferences. Devolver un error aquí solo conseguiría que controller-runtime reintentara eternamente reconciliar un objeto que ya no existe.
¿Qué hace controllerutil.CreateOrUpdate?
Es el kubectl apply del código, y contiene el "comparar" del bucle:
controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
dep.Spec.Replicas = &replicas
// ...el estado deseado...
return controllerutil.SetControllerReference(&promo, dep, r.Scheme)
})
Hace un Get; si el objeto no existe, lo crea; si existe, aplica tu función mutadora y, solo si algo ha cambiado de verdad, hace un Update.
Fíjate en lo que esto significa: tú describes el estado deseado igual siempre, exista ya o no. No escribes un if existe { actualizar } else { crear }. Eso es lo que hace tu Reconcile idempotente: puedes llamarlo mil veces seguidas y el resultado es el mismo que llamarlo una.
¿Y SetControllerReference?
Es la línea que estampa el ownerReferences en el objeto hijo:
ownerReferences:
- apiVersion: tienda.example.com/v1
kind: Promocion
name: rebajas-verano
uid: 8f3e... # <- el UID, no solo el nombre
controller: true
blockOwnerDeletion: true
Le dice a Kubernetes: "este Deployment le pertenece a aquel Promocion". A partir de ahí, el garbage collector del clúster se encarga de la limpieza, y tu operador no escribe una sola línea de código para borrar nada.
El uid es un detalle sutil y necesario: no basta con el nombre. Si alguien borra rebajas-verano y crea otro Promocion con el mismo nombre, es un objeto distinto, con otro UID, y los huérfanos del primero no deben adoptarse por error.
¿Por qué el operador escribe el status con r.Status().Update() y no con un Update() normal?
Porque status es un subrecurso: vive en su propio endpoint (/promociones/rebajas-verano/status) y tiene su propio permiso RBAC. Y esa separación es intencionada:
El
speces del usuario. Elstatuses del operador.
El usuario declara lo que quiere; el operador informa de cómo va. Si tu operador reescribe el spec de su propio recurso, has construido una máquina de pelearse con el GitOps de tu equipo: Argo aplica el spec del repositorio, el operador lo cambia, Argo lo vuelve a aplicar, y así hasta el fin de los tiempos.
Por eso el ClusterRole que aplicarás luego da get/list/watch sobre promociones, pero solo update sobre promociones/status. La API te obliga a diseñar bien.
SetupWithManager: dónde está la diferencia real
Estas cinco líneas son lo que separa a este operador de un script en bucle:
func (r *PromocionReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&Promocion{}). // el objeto que gobernamos
Owns(&appsv1.Deployment{}). // ...y los que le pertenecen
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Named("promocion").
Complete(r)
}
For(&Promocion{}) es lo obvio: reconcilia cuando cambie un Promocion.
Owns(&appsv1.Deployment{}) es lo que no es obvio, y es lo mejor de controller-runtime.
Le dice al manager: "vigila también los Deployments. Cuando uno cambie, mira su ownerReferences, y si pertenece a un Promocion, reconcilia ese Promocion".
Traducido: si alguien borra el Deployment rebajas-verano, controller-runtime lo detecta al instante (a través de un watch del API server, no de un sleep), resuelve que su dueño es el Promocion rebajas-verano, y encola una reconciliación de ese Promocion. Tu Reconcile se ejecuta en milisegundos, y encuentra que falta el Deployment, y lo recrea.
Un script en bucle con sleep 5 haría lo mismo... cinco segundos después, y consumiendo CPU del API server con un list completo cada cinco segundos aunque no haya cambiado nada. Con Owns() no hay polling: hay un watch, y el API server te empuja el cambio.
Esto es lo que hacen, exactamente, todos los controladores nativos de Kubernetes.
¿Y dónde está el cliente HTTP, la caché, la cola de trabajo, los reintentos?
Los pone el manager, y no los ves. mgr.GetClient() te devuelve un cliente que lee de una caché en memoria alimentada por watches (por eso un Get en un Reconcile no golpea al API server) y escribe directamente contra la API.
Y si tu Reconcile devuelve un error, controller-runtime reencola el objeto con backoff exponencial, solo. No hay que escribir un retry.
return ctrl.Result{}, err // reintenta con backoff
return ctrl.Result{}, nil // hecho
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // vuelve dentro de 30s
Esos tres valores de retorno son toda la API de control de flujo de un controlador. Todo lo demás es tu lógica de negocio.
💡 ¿Y Kubebuilder? Es un generador de proyectos que anda por encima de controller-runtime: te crea el esqueleto, el Makefile, el Dockerfile, los manifiestos de RBAC (a partir de comentarios en el código) y el CRD (a partir de tus structs). Es lo que usarías en un proyecto real, y te ahorra un día de trabajo.
Continúa con la siguiente unidad para ejecutarlo.
Fuera del clúster, y luego dentro
Un operador no es un componente de Kubernetes. Es un programa normal que habla con el API server. Y la mejor forma de interiorizarlo es ejecutarlo desde tu propia máquina, antes de meterlo en ningún contenedor.
Paso 1: go run .
cd /home/laborant/operador
go run .
Déjalo corriendo. Lo primero que aparece es esto:
INFO operador-promociones arrancando
INFO controller-runtime.metrics Starting metrics server
INFO Starting EventSource {"controller": "promocion", "source": "kind source: *main.Promocion"}
INFO Starting EventSource {"controller": "promocion", "source": "kind source: *v1.Deployment"}
INFO Starting Controller {"controller": "promocion"}
INFO Starting workers {"controller": "promocion", "worker count": 1}
Esos cuatro EventSource son las cuatro llamadas de SetupWithManager: el For(&Promocion{}) y los tres Owns(...). Y en cuanto los workers arrancan, llega la primera reconciliación de rebajas-verano.
Si la terminal se queda muda unos segundos, no está colgada: está compilando. El primer go run . enlaza controller-runtime entero y no imprime nada mientras lo hace. Si pasado medio minuto sigue sin salir una sola línea, entonces sí hay algo raro y el sitio donde mirar es go build ., que te dirá qué falla sin arrancar nada.
Abre una segunda terminal (la pestaña dev-machine permite varias) y mira el clúster:
kubectl get all
kubectl get configmap rebajas-verano-html
Ahí están: un Deployment con tres réplicas, un Service y un ConfigMap. Nadie los ha aplicado. Existen porque un objeto Promocion dice que deben existir, y hay un programa (en tu terminal, no en el clúster) que se lo toma en serio.
¿Con qué credenciales está hablando?
Con las tuyas. Mira esta línea de main.go:
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{Scheme: scheme})
GetConfigOrDie() busca credenciales en este orden:
- La ServiceAccount montada en el Pod, si el proceso corre dentro del clúster (
/var/run/secrets/kubernetes.io/serviceaccount/). $KUBECONFIGo~/.kube/config, si corre fuera.
El mismo binario sirve para las dos cosas, sin cambiar una línea. Ahora mismo está usando tu kubeconfig, que es de administrador. Por eso funciona sin que le hayas dado ni un permiso.
Recuérdalo, porque dentro de diez minutos va a dejar de funcionar.
💡 Esta forma de trabajar (go run . contra un clúster real, con tu kubeconfig) es cómo se desarrollan los operadores de verdad. Nadie construye una imagen y hace un rollout para probar un cambio de tres líneas. Se ejecuta local, se itera en segundos, y solo al final se empaqueta.
Paso 2: El status
Mira el Promocion:
kubectl get promociones
NAME BANNER DESEADAS LISTAS FASE EDAD
rebajas-verano https://tienda.example.com 3 3 Listo 2m
Las columnas LISTAS y FASE no salen del spec: las ha escrito el operador, en el status. Es el canal por el que un operador le cuenta al usuario cómo va la cosa.
kubectl get promocion rebajas-verano -o jsonpath='{.status}' | jq .
Esto es exactamente lo que hace un Deployment cuando kubectl get deployments te enseña 3/3. Tu recurso ya se comporta como uno nativo.
Paso 3: Rómpelo, con los logs delante
Este es el momento de la lección. No cierres la terminal donde corre go run .. Ponla donde puedas verla, y desde la otra:
kubectl delete deployment rebajas-verano
Mira los logs del operador. La reconciliación se dispara inmediatamente. No en cinco segundos: en el mismo instante.
Y ahora la pregunta: tu controlador solo declara For(&Promocion{}). El Promocion no ha cambiado. ¿Por qué se ha reconciliado?
Por esto:
Owns(&appsv1.Deployment{}).
Controller-runtime está vigilando los Deployments. Cuando el tuyo desapareció, miró sus ownerReferences, vio que pertenecía al Promocion rebajas-verano, y encoló una reconciliación de ese Promocion. Tu Reconcile se ejecutó, encontró que faltaba el Deployment, y lo recreó.
Este es el mecanismo real de Kubernetes, y no hay polling en ninguna parte: hay watches, y el API server empuja los cambios.
Prueba también a escalarlo a mano:
kubectl scale deployment rebajas-verano --replicas=1
Vuelve a 3. El Promocion dice 3. Tu kubectl no es una orden final: es una discrepancia temporal con el estado deseado, y la reconciliación la corrige.
Paso 4: Ahora, dentro del clúster
Para el go run . con Ctrl+C.
Un operador tiene que vivir dentro del clúster: con alta disponibilidad, con su identidad, y sin depender del portátil de nadie. Toca empaquetarlo.
Mira el Dockerfile:
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 go build -o /operador .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /operador /operador
USER 65532:65532
ENTRYPOINT ["/operador"]
¿Por qué dos stages? Porque compilar necesita el toolchain de Go entero (unos 800 MB) y ejecutar no necesita nada. Go compila un binario estático: no hay intérprete, no hay librerías compartidas, no hay dependencias.
La imagen final es distroless/static: ni shell, ni gestor de paquetes, ni siquiera ls. Solo tu binario. Pesa unos 50 MB y su superficie de ataque es prácticamente nula. Un atacante que consiga ejecución dentro de ese contenedor no tiene ni con qué mirar alrededor.
⚠️ Esto tiene una contrapartida que vas a encontrarte: kubectl exec no funciona en una imagen distroless. No hay shell al que entrar. Cuando necesites depurar dentro, la herramienta es kubectl debug -it <pod> --image=busybox --target=operador, que te inyecta un contenedor efímero con las herramientas, compartiendo los namespaces del contenedor original. Es exactamente el caso de uso para el que se inventó.
Construye la imagen. Fíjate en el nombre: lleva por delante el host del registro, y eso no es decoración: es lo que le dice a Docker adónde publicarla.
cd /home/laborant/operador
docker build -t registry.iximiuz.com/tienda/operador-promociones:v1 .
docker images registry.iximiuz.com/tienda/operador-promociones
Ahí está, en el almacén de Docker de dev-machine. Y ahí no le sirve a nadie, porque quien tiene que descargarla no es Docker: es el kubelet de cada nodo. dev-machine no es un nodo del clúster — es tu máquina de trabajo, y el clúster son cplane-01, node-01 y node-02. Publícala en el registro del playground, que sí ven todos:
docker push registry.iximiuz.com/tienda/operador-promociones:v1
¿Por qué no basta con construirla? Porque es el tropiezo clásico de todo el que empieza: la imagen "existe" —docker images la lista— y el Pod se queda en ImagePullBackOff. El kubelet no mira tu Docker local. Nunca. Ni en este playground ni en producción.
Un registro es la respuesta normal a ese problema, y es la que usarás siempre: el registro es el punto de encuentro entre quien construye y quien ejecuta. Por eso deploy.yaml referencia la imagen por su nombre completo con host, y por eso lleva un imagePullSecrets: el registro de este playground pide credenciales, y el kubelet necesita las suyas.
💡 En un clúster de un solo nodo con k3s existe un atajo que te encontrarás en tutoriales: docker save imagen | sudo k3s ctr images import -, que mete la imagen directamente en containerd saltándose el registro. Aquí no sirve —el binario k3s está en los nodos, no en dev-machine— y en un clúster de varios nodos habría que repetirlo en cada uno. Es un apaño para desarrollo local, no un flujo de trabajo.
Despliega:
Los manifiestos del operador están en manifiestos/, y se aplican en el orden en que se explican. Primero la identidad:
kubectl apply -f manifiestos/sa.yaml
Una ServiceAccount y nada más: seis líneas, sin un solo permiso. Acaba de nacer y no puede hacer absolutamente nada, y eso es exactamente lo que quieres comprobar dentro de un momento.
Después la carga, que la usa:
kubectl apply -f manifiestos/deploy.yaml
kubectl get pods -l app=operador-promociones
Paso 5: El operador ya no es administrador
kubectl logs -l app=operador-promociones --tail=30
El manager arranca, intenta sincronizar su caché... y el API server le cierra la puerta en la cara. Ni siquiera consigue hacer un list de Promociones.
Y tiene toda la razón. Antes, el operador usaba tu kubeconfig de administrador. Ahora usa la ServiceAccount operador-promociones, que tiene exactamente cero permisos.
Un operador es un cliente más del API server, y el API server no hace excepciones con nadie.
Paso 6: Los permisos, y solo los necesarios
Abre el rbac.yaml antes de aplicarlo:
cat /home/laborant/operador/manifiestos/rbac.yaml
rules:
# Observar los Promocion. SOLO LECTURA: el spec es del usuario.
- apiGroups: ["tienda.example.com"]
resources: ["promociones"]
verbs: ["get", "list", "watch"]
# Escribir el status. Es un subrecurso APARTE, con su propio permiso.
- apiGroups: ["tienda.example.com"]
resources: ["promociones/status"]
verbs: ["get", "update", "patch"]
# Actuar: los objetos derivados.
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
Fíjate en las dos primeras reglas. Sobre promociones, el operador tiene solo lectura. Sobre promociones/status, puede escribir. Ese es el contrato del que hablábamos: el spec es del usuario, el status es del operador, y RBAC lo hace cumplir de verdad. No es una convención: es un permiso.
¿Por qué también list y watch sobre Deployments, Services y ConfigMaps, si solo los creo? Porque tu controlador declara Owns() sobre los tres, y Owns() abre un watch. Sin permiso de watch, el manager ni siquiera arranca.
Aplícalo:
kubectl apply -f /home/laborant/operador/manifiestos/rbac.yaml
kubectl logs -l app=operador-promociones -f
Los logs cambian al instante. El operador reconcilia.
Y comprueba los permisos haciéndote pasar por él:
kubectl auth can-i list promociones.tienda.example.com \
--as=system:serviceaccount:default:operador-promociones -A
kubectl auth can-i update promociones.tienda.example.com --subresource=status \
--as=system:serviceaccount:default:operador-promociones -A
kubectl auth can-i delete secrets \
--as=system:serviceaccount:default:operador-promociones -A
Los dos primeros dicen yes. El tercero dice no, y eso también es una victoria: si alguien compromete tu operador, no se lleva los Secrets del clúster.
⚠️ El subrecurso se pide con --subresource, no con una barra. Es una trampa fácil de pisar,
porque en RBAC el subrecurso sí se escribe con barra (resources: ["promociones/status"]) y
uno tiende a repetir esa forma en la línea de comandos. Pero para kubectl auth can-i la barra
significa otra cosa:
kubectl auth can-i VERB [TYPE | TYPE/NAME | NONRESOURCEURL]
Ese NAME es el nombre de un objeto concreto. Así que
can-i update promociones.tienda.example.com/status no pregunta por el subrecurso: pregunta si
puedes actualizar una Promocion llamada status. Y como sobre promociones el operador solo
tiene lectura, responde no — una respuesta correcta a una pregunta que no querías hacer.
Exígele: reconciliación, escalado y borrado en cascada
El operador vive dentro del clúster, con su identidad y sus permisos. Ahora vamos a exigirle.
Paso 7: Intenta romperlo (otra vez, pero en serio)
Ten los logs delante:
kubectl logs -l app=operador-promociones -f
Y en otra terminal, sabotea:
kubectl delete deployment rebajas-verano
kubectl delete service rebajas-verano
kubectl delete configmap rebajas-verano-html
Los tres vuelven. Al instante.
Borra el Deployment veinte veces si quieres. Vuelve veinte veces.
Esto es lo que significa que Kubernetes sea declarativo, y merece la pena decirlo con todas las letras porque cambia la forma de operar sistemas:
Tú no le dices al clúster lo que tiene que hacer. Le dices cómo tiene que ser el mundo. Y hay alguien, ahí dentro, comprobándolo sin descanso.
Tu kubectl delete no fue una orden final. Fue una discrepancia temporal con el estado deseado, y la reconciliación la corrigió. Exactamente lo mismo que le pasa a un Pod que borras de un ReplicaSet.
⚠️ Y esto tiene un corolario incómodo que te ahorrará una tarde algún día: si un objeto gestionado por un operador vuelve solo cada vez que lo borras, no está "poseído". Está funcionando. Para eliminarlo de verdad hay que borrar el recurso de nivel superior, o parar el operador. Es la causa de muchas horas perdidas peleándose con un kubectl delete que "no funciona".
Paso 8: El Custom Resource es la fuente de la verdad
El Deployment no manda. El Promocion manda. Demuéstralo:
kubectl patch promocion rebajas-verano --type=merge -p '{"spec":{"replicas":5}}'
kubectl get promociones -w
Mira las columnas: DESEADAS pasa a 5 al instante, FASE cambia a Desplegando, y LISTAS va subiendo hasta 5, momento en el que FASE vuelve a Listo.
Fíjate en lo que acaba de ocurrir por dentro, porque son dos reconciliaciones distintas:
- Cambiaste el Promocion →
For(&Promocion{})lo detectó →Reconcilepuso el Deployment a 5 réplicas. - El Deployment cambió (sus réplicas ready subieron de 3 a 5) →
Owns(&appsv1.Deployment{})lo detectó →Reconcilese ejecutó otra vez → actualizó elstatusdel Promocion.
Sin Owns(), el status se habría quedado congelado en 3 hasta la siguiente vez que alguien tocara el Promocion. Con Owns(), el status sigue al mundo real.
Has escalado una aplicación modificando un objeto que no existía hace media hora y que definiste tú. Eso es exactamente lo que hacen los operadores de PostgreSQL, de Kafka o de Prometheus: te dan un objeto de alto nivel (kind: PostgresCluster) y traducen tu intención a docenas de recursos de bajo nivel.
Paso 9: Bórralo todo con una sola línea
Borra el Promocion. Solo el Promocion:
kubectl delete promocion rebajas-verano
kubectl get all
kubectl get configmap
El Deployment, el Service, el ConfigMap y los cinco Pods: todo ha desaparecido.
Y lo más interesante: tu operador no tiene una sola línea de código de limpieza. Búscala en main.go. No está. Ni un Delete. Ni un defer.
Lo ha hecho el garbage collector de Kubernetes, siguiendo los ownerReferences que SetControllerReference estampó en cada objeto al crearlo. Los mismos ownerReferences por los que borrar un Deployment borra sus ReplicaSets, y borrar un ReplicaSet borra sus Pods.
Mira los logs del operador: verás una última reconciliación en la que r.Get() devuelve NotFound, y esa línea que ahora entiendes del todo:
return ctrl.Result{}, client.IgnoreNotFound(err)
"El objeto ya no existe. No hay nada que hacer." Y no lo hay.
Por qué este operador no vale para producción
Tu operador funciona, y el 90 % de los operadores del mundo no hacen nada conceptualmente distinto. Pero para ponerlo a gobernar algo importante le faltan cuatro cosas, y conviene saber sus nombres:
1. Leader election. Ahora corre con una réplica. Si pones dos, se pelearían: cada una sobrescribiría a la otra en cada vuelta. Los operadores serios corren varias réplicas por disponibilidad, pero solo una reconcilia; las demás esperan su turno mediante un objeto Lease, el mismo mecanismo que usan el kube-scheduler y el kube-controller-manager. En controller-runtime es una opción del manager:
ctrl.Options{LeaderElection: true, LeaderElectionID: "operador-promociones"}
2. Finalizers. El garbage collector limpia lo que está dentro del clúster. No sabe nada de un bucket de S3, de una base de datos gestionada o de una entrada de DNS. Para eso están los finalizers: una marca en metadata.finalizers que impide que el objeto se borre del todo hasta que el operador haya hecho la limpieza externa y retire su marca.
Son también la causa número uno de objetos atascados en Terminating para siempre: un finalizer cuyo operador ya no existe, y por tanto nadie va a retirar nunca. Si algún día te encuentras uno, ahora sabes qué mirar (y por qué kubectl patch ... -p '{"metadata":{"finalizers":null}}' es una operación que hay que entender antes de ejecutar).
3. Eventos. Un operador serio emite Events sobre los objetos que gestiona, para que kubectl describe promocion rebajas-verano cuente su historia. Es una línea con el EventRecorder del manager, y es la diferencia entre un operador que se puede depurar y uno que no.
4. Validación fuerte. Tu openAPIV3Schema ya valida tipos y rangos. Para reglas más ricas ("replicas no puede bajar si spec.protegido es true") existen las CEL validation rules dentro del propio CRD, y los webhooks de admisión. Siempre en este orden: el esquema es gratis, CEL es barato, un webhook es un servicio más que mantener y que puede tumbarte el clúster si se cae.
Resumen
- Un operador es un controlador para un recurso personalizado. Y un controlador es un bucle: observar, comparar, actuar.
- La reconciliación es level-based, no basada en eventos:
Reconcilerecibe un nombre, no un "qué ha cambiado". Por eso puede reiniciarse, perder eventos o arrancar a medias, y aun así converger. CreateOrUpdatehace idempotente el "actuar": describes el estado deseado igual siempre, exista ya o no.Owns()es lo que separa un operador de un script consleep: abre un watch sobre los objetos hijos y reconcilia al padre cuando cambian. Sin polling, y en milisegundos.- El
speces del usuario, elstatuses del operador. Son subrecursos distintos, con permisos RBAC distintos, y la API te obliga a respetarlo. SetControllerReferenceconecta cada hijo con su padre. El garbage collector hace la limpieza, y tu código no escribe un soloDelete.- Un operador es un cliente más del API server: mismo binario dentro y fuera del clúster, y sin RBAC no hace nada.
- Lo que le falta para producción tiene nombres concretos: leader election, finalizers, eventos y validación. Ya sabes cuáles son y por qué.
- El Prometheus Operator, cert-manager y el operador de PostgreSQL hacen exactamente esto. Ya no son cajas negras.
- Previous lesson
- Un tipo de recurso propio (CRD)
- Next lesson
- Events, la primera línea de diagnóstico