Qué hace un controller de Kubernetes al fallar algo

En pocas palabras: Cuando reventás un controller de Kubernetes, el Reconcile() no recibe un diff sino solo namespace y nombre, y reconstruye todo desde cero: en las pruebas de kirponik (7 de septiembre de 2026) corrió a 2,71ms de media en 77 llamadas, y sumar GenerationChangedPredicate a 2 de 4 watches bajó los reconciles en reposo un 48,5%.

kirponik construyó un operador de Kubernetes, lo rompió de cinco formas distintas y midió cada mecanismo del controller: el Reconcile() corrió a 2,71ms de media sobre 77 llamadas, sin requests extra a la API, y aplicar GenerationChangedPredicate a dos de cuatro watches bajó los reconciles en reposo un 48,5%, según publicó el 7 de septiembre de 2026 en dev.to.

Un controller de Kubernetes es un proceso que corre en loop y compara el estado real de un recurso contra el estado deseado en su spec, para después ejecutar las acciones que los acerquen. Kubernetes trae controllers built-in para Deployment, ReplicaSet y StatefulSet, y cualquiera puede escribir uno propio (un operator) con la librería controller-runtime.

En 30 segundos

  • El Reconcile() de un controller solo recibe el namespace y el nombre del objeto, nunca un diff: reconstruye todo el estado desde cero en cada corrida.
  • Los reads contra el informer cache no generan tráfico a la API; solo los writes salen hacia el API server.
  • El histograma interno de Prometheus midió 2,71ms de media en 77 reconciles, todos por debajo de 25ms.
  • El resync period no vuelve a consultar el cluster: reemite el mismo objeto cacheado cada 10 segundos, sin costo de red.
  • Aplicar GenerationChangedPredicate a 2 de 4 watches bajó los reconciles en reposo un 48,5%, sin frenar la reparación de drift real.

¿Por qué un controller de Kubernetes no sabe qué cambió?

Un controller de Kubernetes no recibe un diff cuando algo cambia: el método Reconcile() solo toma un namespace y un nombre, y desde ahí recalcula el spec completo de los objetos que administra, sin importar si el disparador fue un kubectl apply, un timer sin nada roto o un proceso que acaba de reiniciar después de ocho horas caído.

Ese diseño se llama level-based: actuar sobre el estado actual del mundo, no sobre la transición que lo produjo. El modelo contrario, edge-triggered, parece más barato a primera vista. ¿Por qué reconstruir todo un Deployment si ya sabés exactamente qué campo cambió? Porque ese modelo exige un stream de eventos perfecto, y nunca lo es.

Según los principios de arquitectura de Kubernetes que kirponik cita en su experimento, el comportamiento tiene que ser level-based (correcto dado el estado deseado y el observado, sin importar cuántas actualizaciones intermedias se perdieron), mientras que lo edge-triggered “debe ser solamente una optimización”.

La consecuencia es la razón por la que todo el modelo aguanta: un evento de watch perdido no es una reparación perdida, es una reparación demorada. No hay code path de catch-up (spoiler: nunca hizo falta escribir uno), porque no existe un camino separado. Hay uno solo, y corre todo el tiempo.

¿De dónde viene el trabajo del controller si no hace polling?

Un controller de Kubernetes no hace polling. El trabajo sale de cuatro piezas separadas (el watch, el informer cache, la work queue y los predicates), y entender dónde termina una y empieza la otra explica casi todos los fallos raros que aparecen en producción. Te puede servir nuestra cobertura del soporte Linux para el chip T1 de Apple.

  • El watch es una conexión HTTP persistente. Se abre una sola vez al arrancar y se mantiene viva, transmitiendo cambios de un tipo de recurso. No es una request por chequeo.
  • El informer cache es una copia local en memoria. Cada lectura contra ese cache no genera tráfico a la API; solo las escrituras van al API server.
  • La work queue guarda keys, no eventos. Si la misma key se encola cinco veces antes de que un worker la tome, corre un solo Reconcile(), no cinco.
  • Los predicates filtran antes de encolar. Deciden qué eventos entran a la cola, y ese filtro tiene un costo silencioso que se explica más abajo.

Subís el manifiesto, kubectl apply lo manda al API server, el watch te avisa casi al instante, la key entra a la work queue, un worker la saca, corre Reconcile() completo otra vez desde cero, y recién ahí el Deployment vuelve a tener las réplicas que pediste. En el operador que armó kirponik, un watch sigue al recurso custom y otros tres siguen al Deployment, el Service y el ConfigMap que administra, cada uno mapeando el evento de vuelta a la key del owner. Cuatro fuentes de eventos, una sola key, un solo Reconcile().

¿Cuánto tarda Kubernetes en reparar un recurso roto?

El Reconcile() real corrió con una media de 2,71ms sobre 77 llamadas medidas por el histograma interno de controller-runtime, todas por debajo de 25ms. Medir desde afuera con un poller de kubectl impone un piso artificial de decenas de milisegundos que no tiene nada que ver con la velocidad real del loop.

kirponik cronometró primero con un poller externo armado con kubectl get deployment -o json, que arranca el timer, corre el fork del proceso, abre TLS, espera el round trip a la API y recién después parsea el JSON: entre 50 y 60 milisegundos solo en ese envoltorio, antes de medir nada del controller en sí.

¿Y si el poller hubiera necesitado un segundo intento? Habría reportado al menos 0,21 segundos (sí, en serio: fork, sleep y otro fork). Nada en los 60 runs medidos se acerca a ese número. Los cinco tipos de drift se resolvieron en la primera observación.

Por eso kirponik dejó de medir desde afuera y leyó directo el histograma que expone controller-runtime bajo la métrica controller_runtime_reconcile_time_seconds. Ahí, sobre diez inyecciones del drift D1 (borrar el Deployment a mano), el bucket acumulado dio una media de 2,71ms en 77 reconciles, mezcla de reparaciones reales y pasadas de settling, todas por debajo de los 25ms. Cubrimos ese tema en detalle en Omarchy Linux y su freno en Apple Silicon.

¿Qué es el resync period y por qué no cuesta nada?

El resync period es un timer local del informer: reemite el mismo objeto que ya tiene en el cache, con el old y el new idénticos, sin ninguna sincronización de red con el servidor.

Por eso un período corto, como los 10 segundos que usó kirponik en su prueba, sale casi gratis en tráfico de API: no dispara ninguna consulta nueva, solo reinyecta en la work queue un evento sintético de update donde en realidad no cambió nada. El doc comment de la versión de controller-runtime usada en el experimento (v0.24.1) lo deja por escrito: un resync dispara localmente un evento de update artificial con el mismo objeto como old y new, y los predicates que esperan que esos dos difieran, como GenerationChangedPredicate, filtran ese evento.

¿Qué filtra realmente un predicate como GenerationChangedPredicate?

GenerationChangedPredicate filtra los eventos sintéticos que genera el resync period, no la reparación de drift real: en la prueba de kirponik, aplicarlo al watch del Deployment y después escalar las réplicas a 0 igual convergió 10 de 10 veces, a la misma velocidad que sin el predicate.

La confusión típica viene de mezclar dos campos que suenan parecido. generation solo sube cuando cambia el spec del objeto. resourceVersion sube con cualquier escritura, incluidas las de status. Un drift real (borrar, escalar, editar a mano) siempre implica una escritura de spec, así que la generation sube, el predicate deja pasar el evento y el controller repara igual que si el filtro no existiera.

El evento que sí queda afuera es el que genera el resync: ahí el objeto viejo y el nuevo son literalmente el mismo objeto cacheado, la generation no cambió porque no pasó nada, y GenerationChangedPredicate lo descarta sin loguear nada ni devolver ningún error visible. Complementá con cómo evitar caídas en tus DNS autoritativos.

¿Cuánto se reduce la carga del controller al aplicar predicates?

Aplicar GenerationChangedPredicate a 2 de los 4 watches (el del recurso custom y el del Deployment) bajó la tasa de reconciles en reposo, sin ningún drift activo, un 48,5%, con un resync period de 10 segundos.

Configuración del watchWatches con predicateReconciles en reposo (resync 10s)
Sin GenerationChangedPredicate0 de 4Tasa base (100%)
Con GenerationChangedPredicate2 de 4 (recurso custom + Deployment)-48,5% frente a la base

Ese ahorro no toca la reparación real de nada: Service y ConfigMap quedaron sin predicate en la prueba, y ninguno de los cinco tipos de drift dejó de repararse a la misma velocidad. Lo único que bajó fue el ruido de fondo que generaba el resync sobre watches que no lo necesitaban.

¿Qué pasa si el controller está caído cuando algo se rompe?

Si el controller está caído cuando algo rompe un recurso, Kubernetes no pierde la reparación: al reiniciar, el manager lista todo el estado existente y lo reconcilia por el mismo código que cualquier otro trigger, sin un mecanismo separado de catch-up. kirponik midió ese caso (drift D4) en 0,3673 segundos de media, con un desvío de solo 0,0038 segundos.

Es entre 6 y 7 veces más lento que una reparación en vivo, pero mucho más consistente, porque es un costo fijo: arrancar el proceso, levantar el informer cache y listar todo de nuevo, en ese orden. Estar caído cinco minutos o cinco horas produce la misma reparación, porque no existe una lógica separada de recuperación que se degrade con el tiempo.

Los cinco tipos de drift probados por kirponik
Tipo de driftQué se rompióResultado medido
D1 — Deployment borradoSe eliminó el Deployment hijoRepuesto en la primera observación, bajo el piso de ~55ms del poller
D2 — réplicas escaladas a 0Se forzó el replica count a 0Repuesto en la primera observación
D3 — ConfigMap editado a manoSe cambió el greeting directamenteRepuesto en la primera observación
D4 — drift con el controller caídoDrift aplicado y luego reinicio del manager0,3673s de media, desvío de 0,0038s
D5 — predicate + réplicas a 0Igual que D2, con GenerationChangedPredicate activo10 de 10 reparado, misma velocidad que sin predicate

Errores comunes al pensar cómo funciona un controller de Kubernetes

  • Pensar que el controller sabe qué campo cambió. Reconcile() solo recibe la key (namespace y nombre) y recalcula todo desde cero; no hay ningún dato sobre qué cambió específicamente.
  • Sacar un predicate por miedo a romper el self-healing. GenerationChangedPredicate filtra eventos sintéticos de resync, no el drift real; sacarlo de más watches de los necesarios solo sube el ruido de la API sin agregar ninguna seguridad extra.
  • Medir la velocidad de reparación con un poller externo. Un script en loop con kubectl get -o json impone un piso de decenas de milisegundos ajeno al loop real; para medir el Reconcile() en sí hay que leer el histograma controller_runtime_reconcile_time_seconds.
  • Confundir generation con resourceVersion al debuggear un evento que no disparó nada. Solo generation sube con cambios de spec; resourceVersion sube con cualquier escritura, incluidas las de status.

Preguntas Frecuentes

¿Qué es un controller de Kubernetes?

Es un proceso en loop que compara el estado deseado de un recurso contra el estado real del cluster y ejecuta las acciones necesarias para que converjan. Kubernetes trae controllers built-in para Deployment, ReplicaSet y StatefulSet, y cualquiera puede escribir uno propio con controller-runtime. Relacionado: la comparativa de pipelines CI/CD de este año.

¿Cuál es la diferencia entre un controller y un operator?

Un operator es un controller que administra un recurso custom (un CRD) en vez de uno nativo de Kubernetes. La mecánica de fondo (watch, informer cache, work queue, Reconcile) es exactamente la misma en los dos casos.

¿Cómo depuro un controller de Kubernetes que no responde?

Primero revisá con kubectl get events si el watch está entregando eventos sobre el recurso, y después mirá los logs del pod del controller para ver si Reconcile() está corriendo y devolviendo error. Si un evento puntual no disparó nada, sospechá primero de un predicate mal configurado antes que de un bug en el loop.

¿Cuánto tarda en reparar Kubernetes un recurso roto?

En el experimento de kirponik, el Reconcile() real corrió con una media de 2,71ms sobre 77 llamadas medidas con el histograma de Prometheus, todas por debajo de 25ms. Medir desde afuera con kubectl agrega alrededor de 50 a 60 milisegundos que no son parte del loop.

¿Qué pasa si aplico GenerationChangedPredicate a un watch?

El predicate filtra los eventos sintéticos que genera el resync period (mismo objeto como old y new), pero deja pasar cualquier escritura real de spec, así que la reparación de drift sigue funcionando igual. En la prueba de kirponik bajó los reconciles en reposo un 48,5% sin afectar la velocidad de reparación real.

Conclusión

Este experimento no reescribe el comportamiento de Kubernetes (nunca cambió). Aporta evidencia medida sobre cuatro supuestos que la mayoría maneja de memoria sin haberlos comprobado nunca: el Reconcile() no recibe un diff, el controller no hace polling, el resync period no toca la red y un predicate bien elegido no arriesga el self-healing.

Para un equipo que corre operators propios, la aplicación práctica es concreta: medí la velocidad real del loop con el histograma de controller-runtime, no con un script externo, y usá GenerationChangedPredicate en los watches de recursos que generan mucho ruido de resync sin miedo a perder reparación de drift real. El 48,5% menos de reconciles en reposo que reportó kirponik es infraestructura que hoy muchos operators gastan de más por no conocer esta distinción entre generation y resourceVersion.

Fuentes

Te puede interesar...