frontendfacile.it

Quando più controller toccano la stessa Custom Resource, la concorrenza diventa un problema reale: ecco come gestirla in modo robusto e scalabile.

Il problema vero degli Operator: la concorrenza sugli aggiornamenti

Nel mondo reale raramente hai un solo controller che gestisce “la sua” risorsa in modo isolato. Appena introduci:

  • più controller nello stesso progetto (o nello stesso manager),
  • controller che scrivono su risorse altrui,
  • azioni lente (creare VM, registrare nodi, provisioning…),

inizi a scontrarti con un tema inevitabile: gli aggiornamenti concorrenti della stessa Custom Resource (CR).

Per capire bene cosa succede, servono due concetti chiave di Kubernetes:

  • metadata.generation: aumenta solo quando cambia la spec.
  • metadata.resourceVersion: cambia a ogni modifica dell’oggetto (spec, status, label, annotation… tutto).

Questa distinzione è cruciale perché i conflitti che ti esplodono addosso in fase di update dipendono quasi sempre da resourceVersion, non da generation.

Generation vs resourceVersion in pratica

  • Se modifichi spec.targetNodes, cambia generation e cambia resourceVersion.
  • Se aggiungi una label o un’annotation, non cambia generation, ma cambia resourceVersion.

In altre parole: qualsiasi write su un oggetto produce una nuova resourceVersion. E Kubernetes usa proprio quel valore per proteggerti dall’aggiornare “una copia vecchia” dell’oggetto.

Scenario tipico: NodePool + Autoscaler, due controller e una CR condivisa

Immagina un operator che gestisce un CRD NodePool:

  • spec.targetNodes: quanti nodi vuoi.
  • status.scaleOperation: inactive | active per indicare se è in corso una scalata.

Il controller NodePoolReconciler:

  1. legge spec.targetNodes,
  2. crea VM nel cloud,
  3. registra i nodi nel cluster,
  4. aggiorna lo status (da active a inactive).

Poi aggiungi un secondo CRD Autoscaler, con un suo controller, che periodicamente:

  • scansiona alcuni namespace,
  • trova Pod Pending per CPU/memoria,
  • incrementa spec.targetNodes del NodePool per innescare la creazione di nuove VM.

Quindi hai:

  • Controller A (NodePool): scrive soprattutto su status e talvolta su spec.
  • Controller B (Autoscaler): scrive su spec del NodePool.

Ed è qui che iniziano i guai.

Il conflitto classico: update su una copia “stale” (resourceVersion mismatch)

La sequenza tipica è:

  1. Il controller NodePool inizia a riconciliare leggendo il NodePool a resourceVersion = 1 e fa caching in memoria.
  2. Nel frattempo l’autoscaler aggiorna spec.targetNodes (es. da 1 a 2): l’oggetto in etcd diventa resourceVersion = 2.
  3. Il NodePool controller termina il provisioning e prova ad aggiornare lo status usando la copia vecchia (rv=1).
  4. Kubernetes rifiuta la write con un 409 Conflict (“the object has been modified; please apply your changes to the latest version”).

Questa non è un’eccezione “rara”: con operazioni lente + controller multipli è normale.

Best practice 1: separa spec e status (e usa la subresource status)

Prima regola: se la tua CRD lo consente, abilita e usa lo status subresource.

  • Spec: desiderio dell’utente (o di un altro controller che si comporta da “utente”).
  • Status: osservazioni del controller.

In Kubebuilder questo significa progettare bene l’API e poi, nel reconciler, aggiornare lo status con:

  • r.Status().Update(ctx, obj)

così da non “toccare” la spec accidentalmente.

Questo non elimina i conflitti, ma riduce drasticamente i casi in cui i controller si pestano i piedi.

Best practice 2: gestisci i Conflict in modo esplicito (retry solo quando serve)

Quando scrivi su Kubernetes devi assumere che il Conflict sia un caso normale.

Strategia robusta:

  1. tenti l’update,
  2. se ricevi apierrors.IsConflict(err):
    • fai Get() dell’oggetto aggiornato,
    • ri-applichi solo la tua modifica (tipicamente su status),
    • ritenti l’update.

Importante: non fare retry “ciechi” su qualsiasi errore. Un retry infinito su errori permanenti (es. validazione, permessi, quota) peggiora la situazione.

Best practice 3: evita loop infiniti di reconciliation (status che causa eventi)

Ogni update di un oggetto può generare eventi che ri-attivano la reconciliation.

Due trappole comuni:

  • aggiorni lo status a ogni reconcile anche se non cambia nulla;
  • scrivi campi “rumorosi” (timestamp, contatori non necessari) che cambiano sempre.

Rimedio:

  • aggiorna lo status solo se è cambiato davvero (confronto strutturale o campi specifici);
  • evita di usare lo status come “log”; usa event/metriche.

Questo riduce carico sul controller, su apiserver e soprattutto evita che la tua logica diventi una macchina da reconcile continua.

Best practice 4: più controller sì, ma con confini chiari (e parallelismo controllato)

Avere più controller nello stesso progetto è spesso corretto (e ti dà parallelismo), ma devi chiarire:

  • chi è owner di quali campi,
  • quali risorse un controller può aggiornare,
  • come gestire gli “handoff” (es. autoscaler aggiorna spec, nodepool controller esegue e aggiorna status).

Se due controller aggiornano frequentemente la stessa risorsa, l’effetto collaterale è inevitabile: più update → più resourceVersion → più conflict → più reconcile.

In questi casi è spesso meglio:

  • far scrivere l’autoscaler su una risorsa dedicata (es. ScaleRequest) e lasciare che il controller NodePool sia l’unico a mutare NodePool.spec, oppure
  • introdurre un campo “requested” separato e far sì che solo uno dei due controller lo trasformi in “desired state” effettivo.

Best practice 5: usa generation per capire cosa è cambiato (non per evitare conflict)

generation è utilissima per distinguere:

  • una reconcile dovuta a cambi spec (nuovo desiderio),
  • da una reconcile dovuta a cambi di metadata/status.

Pattern comune:

  • salvare in status un observedGeneration,
  • quando metadata.generation > status.observedGeneration, sai che c’è un nuovo desiderio da processare.

Questo ti aiuta a non ripetere lavoro costoso quando in realtà è cambiato solo altro (es. una label).

Sintesi: operator scalabili = update disciplinati

Quando inizi a far collaborare più controller, la qualità dell’operator dipende soprattutto da come gestisci gli aggiornamenti:

  • resourceVersion cambia sempre: i conflict sono normali, vanno gestiti.
  • separa spec e status e scrivi sullo status in modo minimale.
  • evita update “rumorosi” che innescano reconcile senza valore.
  • definisci confini chiari tra controller: chi scrive cosa, e perché.

Il salto di qualità non è “scrivere un reconciler che crea risorse”, ma costruire un sistema che resta stabile sotto carico e concorrenza. In Kubernetes, questo significa soprattutto: scrivere meno, scrivere meglio, e fare retry solo quando ha senso.


Articolo originale: https://frontendfacile.it/blog/kubernetes-operator-con-kubebuilder-best-practice-per-evitare-conflitti-retry-in