Controller Kubernetes: Come Funziona il Cuore di K8s
Il Meccanismo Dietro kubectl apply
Quando si esegue kubectl apply -f deployment.yaml, i Pod appaiono nel cluster. Si aumentano le repliche da 3 a 5 e, pochi secondi dopo, due nuovi Pod sono in esecuzione. Si elimina un Pod per errore e Kubernetes lo ricrea autonomamente. Il meccanismo responsabile di questo comportamento è il controller pattern.
Il controller pattern è il meccanismo fondamentale su cui poggia l’intera piattaforma. Ogni risorsa applicata - Deployment, Service, Ingress - è gestita da un controller dedicato che osserva, confronta e agisce in un ciclo continuo. Nonostante questo, viene spesso trattato come una black box.
Questo articolo esplora la teoria dietro i controller Kubernetes, dalla loro architettura interna fino alla costruzione di un controller custom con controller-runtime. La serie su Cluster API introduce concetti come reconciliation loop e controller pattern - qui vengono analizzati nel dettaglio.
Stato Desiderato vs Stato Attuale
Il modello dichiarativo è il cuore di Kubernetes. Invece di dire al sistema come fare qualcosa (imperativo), dichiariamo cosa vogliamo ottenere e lasciamo che sia il sistema a convergere verso lo stato desiderato.
Spec e Status: Il Linguaggio del Controller
Ogni risorsa Kubernetes è strutturata intorno a due campi fondamentali:
.spec- lo stato desiderato: rappresenta l’intenzione dell’operatore. “Voglio 3 repliche di questo container.”.status- lo stato osservato: rappresenta la realtà corrente del cluster. “Attualmente ci sono 2 repliche pronte.”
Il lavoro del controller è esattamente questo: confrontare .spec con .status e intraprendere azioni per ridurre la distanza tra i due. Questo confronto avviene in un ciclo continuo, non in una singola esecuzione.
Vediamo un esempio concreto con un Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3 # Stato desiderato: vogliamo 3 repliche
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:stable
---
# Dopo la riconciliazione, lo status riporta:
# status:
# replicas: 3
# readyReplicas: 3 # Stato osservato: 3 repliche pronte
# availableReplicas: 3
Il Deployment Controller osserva che spec.replicas è 3. Se status.readyReplicas è 2, crea un nuovo Pod. Se è 4 (magari per un errore), ne elimina uno. Il controller non sa perché c’è divergenza - sa solo che deve convergere.
Reconciliation Loop
.spec (Desired) .status (Observed)
replicas: 3 replicas: 2
│ │
└──────────▶ Controller ◀────────────────┘
Confronta e
Agisce
│
▼
Crea 1 Pod mancante
Anatomia di un Controller
Un controller Kubernetes non è un semplice script che gira in loop. È un componente architetturale composto da più parti, progettato per scalare e per essere resiliente. Di seguito i tre componenti principali.
Informer e Cache Locale
Il primo problema da risolvere è: come fa il controller a sapere cosa succede nel cluster? La risposta immediata sarebbe “interroga l’API Server continuamente”, ma questo approccio non scala. Con centinaia di controller e migliaia di risorse, l’API Server verrebbe sommerso di richieste.
La soluzione è il meccanismo LIST+WATCH:
- LIST: All’avvio, l’Informer esegue una singola chiamata LIST per ottenere lo stato completo di tutte le risorse che il controller gestisce.
- WATCH: Dopo la LIST iniziale, apre una connessione HTTP persistente e riceve solo gli eventi incrementali (creazione, modifica, eliminazione).
Tutti i dati ricevuti vengono archiviati in una cache locale in-memory. Quando il controller ha bisogno di leggere una risorsa, la legge dalla cache - non dall’API Server. Questo riduce drasticamente il carico sulla componente centrale del cluster.
Work Queue
Gli eventi ricevuti dall’Informer non vengono processati immediatamente. Vengono inseriti in una work queue (coda di lavoro) sotto forma di chiavi namespace/name. La work queue offre tre garanzie fondamentali:
- Deduplicazione: Se lo stesso oggetto viene modificato 10 volte in rapida successione, nella coda finisce una sola chiave. Il controller processerà lo stato corrente, non ogni singola modifica intermedia.
- Rate limiting: La coda limita il numero di elementi processati per unità di tempo, proteggendo il sistema da burst di eventi.
- Retry con backoff esponenziale: Se una riconciliazione fallisce, la chiave viene reinserita nella coda con un delay crescente (il default parte da 5ms e raddoppia ad ogni fallimento, fino a un massimo di ~16 minuti), evitando loop di errori frenetici.
Reconciliation Loop
Il cuore del controller è la funzione Reconcile(). Un worker estrae una chiave dalla coda, legge lo stato corrente dalla cache, confronta con lo stato desiderato e intraprende le azioni necessarie. Ecco la struttura logica in pseudocodice:
// Pseudocodice del Reconciliation Loop
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 1. Leggi lo stato desiderato dalla cache
desired := cache.Get(req.NamespacedName)
if desired == nil {
return ctrl.Result{}, nil // La risorsa è stata eliminata
}
// 2. Osserva lo stato attuale del mondo
actual := observeCurrentState(desired)
// 3. Confronta e agisci
if actual != desired.Spec {
err := reconcileDifference(desired, actual)
if err != nil {
return ctrl.Result{}, err // Reinserisci nella coda
}
}
// 4. Aggiorna lo status
desired.Status = computeNewStatus(actual)
return ctrl.Result{}, client.Update(ctx, desired)
}
Un principio fondamentale: la funzione Reconcile deve essere idempotente. Chiamarla due volte di seguito con lo stesso input deve produrre lo stesso risultato. Questo perché il controller non ha garanzie su quante volte verrà invocato per la stessa risorsa.
Il diagramma seguente mostra come i tre componenti interagiscono:
API Server
│
│ LIST+WATCH
▼
Informer
├── Cache (locale)
│ │
│ │ Leggi stato
│ ▼
└── Work Queue (chiavi namespace/name)
│
│ Dequeue
▼
Reconcile()
1. Leggi risorsa dalla cache
2. Confronta spec vs status
3. Crea/Aggiorna/Elimina ──▶ API Server
4. Aggiorna status
Controller nella Pratica
La teoria diventa concreta quando osserviamo i controller built-in di Kubernetes. Ogni risorsa che usiamo quotidianamente è gestita da un controller dedicato che implementa esattamente il pattern descritto sopra.
Deployment Controller
Quando modifichiamo spec.replicas in un Deployment, il Deployment Controller non crea direttamente i Pod. Crea (o aggiorna) un ReplicaSet, che a sua volta è responsabile dei Pod. Questo approccio a due livelli è ciò che permette i rolling update: il Deployment Controller gestisce la transizione tra un vecchio ReplicaSet (con la versione precedente) e un nuovo ReplicaSet (con la nuova versione), bilanciando gradualmente le repliche.
ReplicaSet Controller
Il ReplicaSet Controller ha un compito più semplice ma altrettanto critico: garantire che il numero di Pod con determinate label corrisponda a spec.replicas. Se un Pod muore o viene eliminato, il controller ne crea uno nuovo. Se ce ne sono troppi, ne elimina l’eccesso. Il matching avviene tramite label selector - il controller non “possiede” i Pod in senso stretto, li identifica tramite le label.
Il Pattern si Ripete
Questo schema - osserva, confronta, agisci - si ripete in ogni angolo di Kubernetes. L’Ingress Controller osserva le risorse Ingress e riconfigura il reverse proxy. I controller di Cluster API osservano le Custom Resource che descrivono cluster e macchine, e riconciliano l’infrastruttura sottostante. Il pattern è lo stesso, cambiano solo le risorse osservate e le azioni intraprese.
Estendere Kubernetes: Custom Resource Definitions
Uno dei punti di forza di Kubernetes è la sua estensibilità. Non siamo limitati alle risorse built-in: possiamo definire le nostre risorse e i nostri controller.
Cos’è una CRD
Una Custom Resource Definition (CRD) è un modo per insegnare a Kubernetes un nuovo tipo di risorsa. Una volta applicata la CRD, l’API Server accetta e memorizza le nostre risorse custom esattamente come fa con Deployment o Service. Possiamo usare kubectl get, kubectl describe, kubectl apply - tutto funziona nativamente.
Il Pattern Operator
Un Operator è la combinazione di una CRD con un controller custom che la gestisce. Il termine, coniato da CoreOS, cattura l’idea di “codificare la conoscenza operativa” in un software. Invece di avere un operatore umano che esegue procedure manuali, l’Operator automatizza il ciclo di vita di un’applicazione complessa.
Esempi noti di Operator:
- Prometheus Operator - gestisce istanze Prometheus dichiarativamente
- cert-manager - gestisce il ciclo di vita dei certificati TLS
- Cluster API - gestisce il ciclo di vita di interi cluster Kubernetes
Nella prossima sezione costruiremo un Operator minimale per comprendere il meccanismo dall’interno.
Ecco la CRD che useremo - un EchoConfig che descrive un semplice servizio echo:
# crd-echoconfig.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: echoconfigs.demo.example.com
spec:
group: demo.example.com
names:
kind: EchoConfig
listKind: EchoConfigList
plural: echoconfigs
singular: echoconfig
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["message", "replicas"]
properties:
message:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
Costruiamo un Controller: Da Zero alla Reconciliation
Questa sezione mostra la costruzione di un controller che osserva risorse EchoConfig e, per ognuna, crea e gestisce un Deployment Kubernetes.
Il Progetto: EchoConfig Controller
Il nostro controller avrà un comportamento semplice ma significativo:
- Watch: osserva le risorse
EchoConfignel cluster - Reconcile: per ogni
EchoConfig, crea un Deployment con un container echo-server configurato con ilmessagee il numero direplicasspecificati - Self-healing: se qualcuno elimina o modifica il Deployment gestito, il controller lo ricrea o ripristina
Ecco un esempio della Custom Resource che il nostro controller gestirà:
# echo-sample.yaml
apiVersion: demo.example.com/v1alpha1
kind: EchoConfig
metadata:
name: hello-echo
namespace: default
spec:
message: "Ciao dal controller custom!"
replicas: 2
Setup con controller-runtime
La libreria controller-runtime è il framework standard per costruire controller Kubernetes in Go. È lo stesso framework usato da Kubebuilder e Operator SDK.
Il punto di ingresso è il main.go, dove creiamo un Manager e registriamo il nostro controller:
// main.go
package main
import (
"os"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
)
func main() {
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
log := ctrl.Log.WithName("setup")
// Il Manager gestisce cache condivisa, client e lifecycle dei controller
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
if err != nil {
log.Error(err, "impossibile creare il manager")
os.Exit(1)
}
// Registra il controller EchoConfig
if err := (&EchoConfigReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
log.Error(err, "impossibile creare il controller")
os.Exit(1)
}
// Avvia il Manager (blocca fino a shutdown)
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
log.Error(err, "errore durante l'esecuzione del manager")
os.Exit(1)
}
}
La Funzione Reconcile
Ecco il cuore del nostro controller - la funzione Reconcile che implementa la logica di riconciliazione:
// reconciler.go
// Import: context, appsv1, corev1, metav1, ctrl, client, ptr, demov1alpha1
func (r *EchoConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := ctrl.LoggerFrom(ctx)
// 1. Leggi la risorsa EchoConfig dalla cache
var echoConfig demov1alpha1.EchoConfig
if err := r.Get(ctx, req.NamespacedName, &echoConfig); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 2. Prepara l'oggetto Deployment con solo Name e Namespace
desired := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: echoConfig.Name + "-deployment",
Namespace: echoConfig.Namespace,
},
}
// 3. Crea o aggiorna il Deployment - tutti i campi vanno nella mutate function
result, err := ctrl.CreateOrUpdate(ctx, r.Client, desired, func() error {
// Imposta l'owner reference (serve sia in create che in update)
if err := ctrl.SetControllerReference(&echoConfig, desired, r.Scheme); err != nil {
return err
}
// Selector è immutabile dopo la creazione, ma va impostato al primo create
desired.Spec.Selector = &metav1.LabelSelector{
MatchLabels: map[string]string{"app": echoConfig.Name},
}
desired.Spec.Replicas = ptr.To(int32(echoConfig.Spec.Replicas))
desired.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": echoConfig.Name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "echo",
Image: "hashicorp/http-echo",
Args: []string{"-text=" + echoConfig.Spec.Message},
}},
},
}
return nil
})
if err != nil {
return ctrl.Result{}, err
}
log.Info("Deployment riconciliato", "operazione", result)
return ctrl.Result{}, nil
}
Ogni passaggio è intenzionale: leggiamo lo stato desiderato (la CR), prepariamo un oggetto Deployment con solo Name e Namespace, e deleghiamo alla mutate function di CreateOrUpdate l’impostazione di tutti i campi desiderati. Questo pattern è fondamentale: sul path di update, CreateOrUpdate sovrascrive l’oggetto con quello esistente nel cluster prima di invocare la mutate function. Se i campi fossero impostati fuori dalla mutate function, verrebbero persi. L’owner reference, impostata dentro la stessa funzione, collega il Deployment alla CR in modo che Kubernetes sappia chi “possiede” cosa.
Registrare il Controller
L’ultimo pezzo è dire al framework cosa osservare. Il metodo SetupWithManager definisce le sorgenti di eventi:
// setup.go
// Import: ctrl, appsv1, demov1alpha1
func (r *EchoConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&demov1alpha1.EchoConfig{}). // Watch primario: le nostre CR
Owns(&appsv1.Deployment{}). // Watch secondario: i Deployment che creiamo
Complete(r)
}
For()- registra il watch primario. Ogni modifica a unEchoConfigscatena una riconciliazione.Owns()- registra un watch secondario. Se un Deployment posseduto dal nostro controller viene modificato o eliminato, il framework risale all’owner (EchoConfig) e scatena una riconciliazione. Questo è il meccanismo che abilita il self-healing.
Test su un Cluster kind
Vediamo il controller in azione su un cluster locale kind:
# Crea un cluster kind
kind create cluster --name controller-demo
# Applica la CRD
kubectl apply -f crd-echoconfig.yaml
# Avvia il controller (in un terminale separato)
go run .
# Crea una risorsa EchoConfig
kubectl apply -f echo-sample.yaml
Verifichiamo che il Deployment sia stato creato:
# Controlla il Deployment generato dal controller
kubectl get deployments
# Output atteso:
# NAME READY UP-TO-DATE AVAILABLE
# hello-echo-deployment 2/2 2 2
Testiamo il self-healing eliminando il Deployment:
# Elimina il Deployment
kubectl delete deployment hello-echo-deployment
# Attendi qualche secondo e verifica
kubectl get deployments
# Output: il Deployment è stato ricreato dal controller!
# NAME READY UP-TO-DATE AVAILABLE
# hello-echo-deployment 2/2 2 2
Testiamo l’aggiornamento modificando la CR:
# Aggiorna il messaggio e le repliche
kubectl patch echoconfig hello-echo --type merge \
-p '{"spec":{"message":"Messaggio aggiornato!","replicas":3}}'
# Verifica
kubectl get deployments
# Output: il Deployment ora ha 3 repliche
# NAME READY UP-TO-DATE AVAILABLE
# hello-echo-deployment 3/3 3 3
Approfondimenti
Tre concetti architetturali meritano un approfondimento aggiuntivo.
Level-Triggered vs Edge-Triggered
Una distinzione fondamentale nei sistemi di controllo: i controller Kubernetes sono level-triggered, non edge-triggered. Questo significa che la funzione Reconcile non riceve l’evento che ha causato l’invocazione (“il campo replicas è stato cambiato da 2 a 3”). Riceve solo una chiave (namespace/name) e deve determinare da sola qual è lo stato corrente e cosa fare.
Perché questa scelta? Perché è più resiliente. Se il controller si riavvia e perde tutti gli eventi in coda, non importa: alla prossima riconciliazione leggerà lo stato corrente e convergerà. Non può “perdere” un evento critico, perché non dipende dagli eventi - dipende dallo stato.
Owner References e Garbage Collection
Quando il nostro controller crea un Deployment, imposta un’owner reference che punta alla risorsa EchoConfig genitore. Questo ha due effetti:
- Garbage collection: quando l’
EchoConfigviene eliminata, Kubernetes elimina automaticamente tutti gli oggetti “posseduti” (il Deployment, e di conseguenza i ReplicaSet e i Pod). Non serve scrivere logica di cleanup. - Watch secondario: grazie all’owner reference, il framework
controller-runtimeè in grado di risalire dal Deployment modificato al suo owner e triggerare la riconciliazione corretta.
Questa catena di ownership è la stessa che lega Deployment → ReplicaSet → Pod nel sistema built-in di Kubernetes.
Idempotenza
Se c’è un solo principio da ricordare nella scrittura di controller, è questo: la funzione Reconcile deve essere idempotente. Deve poter essere chiamata 100 volte di seguito senza effetti collaterali indesiderati. Se il Deployment esiste già con la configurazione corretta, Reconcile non deve fare nulla. Se esiste ma con configurazione errata, deve aggiornarlo. Se non esiste, deve crearlo.
Questo principio deriva dalla natura stessa della work queue: non c’è garanzia su quante volte Reconcile verrà invocata per una data risorsa. La deduplicazione riduce le invocazioni ridondanti, ma non le elimina del tutto. Un controller idempotente è un controller affidabile.
Conclusioni
L’articolo ha coperto il funzionamento interno dei controller Kubernetes:
- Il modello dichiarativo di Kubernetes si basa sul confronto continuo tra stato desiderato (
.spec) e stato osservato (.status). - L’architettura Informer + Work Queue + Reconcile garantisce efficienza, resilienza e scalabilità.
- Le CRD e il pattern Operator permettono di estendere Kubernetes con la stessa logica dei controller built-in.
- La costruzione di un controller custom con controller-runtime è accessibile e segue pattern ben definiti.
Ogni kubectl apply attiva esattamente questo meccanismo: un controller confronta l’intent dichiarato con la realtà del cluster e lavora per colmare la distanza.
Risorse Utili
Controller Kubernetes - Documentazione Ufficiale: La pagina ufficiale che descrive il ruolo e il funzionamento dei controller nell’architettura Kubernetes.
The Kubebuilder Book: La guida completa per costruire Operator Kubernetes usando Kubebuilder, lo scaffolding ufficiale basato su controller-runtime.
controller-runtime - Repository GitHub: Il framework Go utilizzato in questo articolo. Include esempi, godoc e guide alla migrazione.
Custom Resource Definitions - Documentazione Ufficiale: Guida completa alle CRD, dalla definizione dello schema alla validazione.
CAPI Parte 1: Dal Chaos all’Automazione: Il primo articolo della serie su Cluster API, che utilizza estensivamente il controller pattern.
CAPI Parte 2: Anatomia di Cluster API: Approfondimento sull’architettura interna di CAPI e i suoi controller.
Da port-forward a Ingress: Come funziona l’Ingress Controller, un altro esempio pratico del pattern descritto in questo articolo.