Idempotence, la règle d'or du reconciler & Requeue et backoff sur erreur
Pourquoi un Reconcile doit produire le même état réel qu'on l'appelle une fois ou cent fois, et comment requeue et backoff exponentiel rendent la boucle robuste — avec YAML, kubectl et C#/KubeOps.
Idempotence, la règle d’or du reconciler
L’idée en une phrase
Une opération est idempotente lorsqu’elle appliquée une fois ou N fois avec la même entrée laisse le système dans un état identique. Pour une boucle de réconciliation — qui rejoue la même clé un nombre imprévisible de fois — l’idempotence est la condition de la convergence : chaque passage compare l’état désiré à l’état réel observé et n’agit que sur l’écart, sans jamais accumuler d’effets.
Analogie : Considérons le bouton d’appel d’un ascenseur. L’état désiré qu’il porte est « une cabine à cet étage ». Appuyer une fois ou cinq fois de suite produit le même résultat : une seule cabine arrive. Le bouton est idempotent parce qu’il exprime un état souhaité, non un décompte d’actions à cumuler. Un reconciler bien conçu fonctionne ainsi : il déclare l’écart à combler, pas une commande à empiler.
Points clés
- Une opération idempotente appliquée N fois (N ≥ 1) produit le même état final qu’une seule application.
kubectl applyest idempotent ;kubectl createne l’est pas — il échoue avecAlreadyExistssi l’objet existe déjà. - La méthode
ReconcileAsyncest invoquée un nombre imprévisible de fois pour une même ressource : resync périodique (chapitre 15-16), redémarrage du contrôleur qui rejoue tout le cache, requeue après erreur. Un Reconcile qui suppose « c’est la première fois » et crée aveuglément duplique ou échoue. - Le pattern de l’idempotence est level-based (chapitre 13-14) : toujours observer l’état réel avant d’agir, puis n’écrire que la différence (create-or-update, chapitre 27-28). On raisonne sur un état, jamais sur un événement.
- Le résultat ne doit dépendre que du spec (l’état désiré) et de l’état réel observé — jamais d’un compteur interne, de l’horloge, ou du nombre d’appels précédents. Incrémenter une valeur à chaque passage n’est, par définition, pas idempotent.
- L’idempotence se vérifie : appeler
ReconcileAsyncdeux fois de suite doit laisser le cluster identique, et le second appel ne doit émettre aucune écriture (chapitre 33-34 sur les tests).
Exemple concret
Un contrôleur gère une CR WebApp déclarant spec.replicas: 3. Au premier Reconcile, aucun Deployment n’existe : le contrôleur en crée un. Dix minutes plus tard, le resync rejoue la même CR, spec inchangé. Un contrôleur non idempotent rappelle Create et reçoit une erreur AlreadyExists (409) — ou, si le nom de l’enfant est généré, fabrique un second Deployment. Un contrôleur idempotent observe d’abord : le Deployment existe, l’image et le nombre de replicas correspondent, l’écart est nul, donc il n’écrit rien. Le cas critique est le redémarrage : au démarrage, l’informer resynchronise les 100 CR existantes et déclenche 100 Reconcile. Le contrôleur idempotent émet 0 écriture (tout converge déjà) ; le contrôleur naïf émet 100 Create voués à l’échec.
Opérations idempotentes ou non
| Opération | Idempotente ? | Comportement au 2ᵉ appel identique |
|---|---|---|
kubectl apply -f | Oui | unchanged — aucune modification |
kubectl create -f | Non | Erreur AlreadyExists (409) |
Lecture (get) | Oui | Même résultat |
Create aveugle dans Reconcile | Non | Duplique l’enfant ou échoue |
Observe → diff → Update ciblé | Oui | Aucune écriture si l’écart est nul |
| Incrémenter un compteur de status | Non | Valeur différente à chaque passage |
Code YAML — l’état désiré appliqué de façon idempotente
# webapp-deploy.yaml — l'ÉTAT DÉSIRÉ : 3 replicas
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
spec:
replicas: 3 # le contrat ; apply le fera converger sans le cumuler
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
containers:
- name: web
image: nginx:1.27
Code kubectl — apply converge, create échoue
# apply est idempotent : N applications convergent vers le MÊME état
kubectl apply -f webapp-deploy.yaml
# deployment.apps/webapp created ← 1er appel : écart comblé
kubectl apply -f webapp-deploy.yaml
# deployment.apps/webapp unchanged ← 2e appel : écart nul, aucune action
# create n'est PAS idempotent
kubectl create -f webapp-deploy.yaml
# Error from server (AlreadyExists): deployments.apps "webapp" already exists
Code C# — le piège : un Reconcile non idempotent
// NON IDEMPOTENT — suppose toujours "première fois", crée aveuglément.
// Au 2e passage (resync, redémarrage), Create échoue : AlreadyExists (409).
public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
{
await _client.Create(BuildDeployment(entity));
}
Code C# — observe → diff → agit : la version idempotente
// IDEMPOTENT — le résultat ne dépend que de l'état désiré et de l'état réel.
public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
{
var ns = entity.Namespace() ?? "default";
// 1. OBSERVER l'état réel (Get renvoie null si l'enfant n'existe pas)
var existing = await _client.Get<V1Deployment>(entity.Name(), ns);
if (existing is null)
{
// Écart : l'enfant manque → le créer
await _client.Create(BuildDeployment(entity));
return;
}
// 2. DIFF : le nombre de replicas désiré diverge-t-il de l'état réel ?
if (existing.Spec.Replicas != entity.Spec.Replicas)
{
existing.Spec.Replicas = entity.Spec.Replicas;
await _client.Update(existing); // 3. AGIR uniquement sur l'écart
}
// Écart nul → aucune écriture. Appelé 1 fois ou 100 fois : même résultat.
}
Piège courant : « Puisque l’API server gère les doublons, appeler
Createà chaque Reconcile est sans risque » est inexact.Createsur un objet existant renvoie une erreurAlreadyExists(409) que le contrôleur doit traiter ; et si le nom de l’enfant est généré, chaque passage fabrique un nouvel objet — la boucle accumule des doublons à chaque resync. L’idempotence ne s’obtient pas par chance : elle se construit en observant l’état réel avant toute écriture.
Requeue et backoff sur erreur
L’idée en une phrase
Lorsqu’un Reconcile échoue ou laisse l’état réel non encore convergé, le contrôleur réenfile la clé (requeue) pour réessayer plus tard ; un backoff exponentiel espace les tentatives successives afin de ne pas marteler une dépendance en panne. Ce mécanisme rend la boucle de réconciliation robuste : elle ne renonce jamais, mais elle ne s’acharne pas non plus.
Analogie : Considérons une tentative d’appel vers une ligne occupée. Recomposer sans relâche sature le réseau sans rapprocher du but. Espacer les tentatives — une minute, puis deux, puis quatre — laisse à la ligne le temps de se libérer tout en garantissant qu’on finira par passer. Le backoff exponentiel applique exactement cette discipline aux réessais d’une boucle.
Points clés
- Deux familles de re-déclenchement coexistent : le requeue explicite avec délai, demandé par le Reconcile (« revérifier cet état dans 15 s », le temps qu’un pod devienne prêt), et le requeue automatique sur exception, géré par la work queue (chapitre 15-16) avec backoff.
- Le backoff exponentiel fait croître géométriquement le délai entre deux tentatives d’une même clé — typiquement 5 ms, 10 ms, 20 ms… jusqu’à un plafond de l’ordre de 16 min. L’objectif est d’éviter l’afflux simultané de réessais (effet « troupeau ») et de laisser une panne transitoire se résorber.
- La clé est réenfilée, pas un instantané de l’objet. À la tentative suivante, le contrôleur relit l’état frais (level-based, chapitre 13-14) et réconcilie l’état courant. C’est précisément pourquoi l’idempotence (sous-thème précédent) est le prérequis du requeue : réessayer doit rester sûr.
- Distinguer l’erreur transitoire (timeout d’API, conflit 409, dépendance momentanément indisponible) — qu’il faut propager pour déclencher le requeue — de l’erreur permanente (spec invalide, champ obligatoire manquant) — pour laquelle réessayer à l’identique est inutile : il faut la signaler via un Event ou une condition de status et attendre une correction.
- Après un succès, le compteur de tentatives se réinitialise : la clé est « oubliée » du rate limiter, et son backoff repart de zéro au prochain incident.
Exemple concret
Un Reconcile crée un Deployment puis lit un secret externe pour configurer l’application. À 14h02, le service de secrets est indisponible : la lecture lève une exception. Le contrôleur ne l’avale pas, il la propage. La work queue réenfile la clé avec backoff : nouvelle tentative à +5 ms (échec), +10 ms (échec), +20 ms… À 14h03 le service est rétabli ; la tentative suivante réussit, et le rate limiter oublie la clé — le backoff de cette ressource repart de zéro. Grâce à l’idempotence, ces réessais n’ont pas recréé le Deployment déjà présent : seule la lecture du secret manquait, et c’est le seul écart que la dernière tentative a comblé.
Stratégies de re-déclenchement
| Situation | Action recommandée | Mécanisme |
|---|---|---|
| Travail inachevé, attendre une condition | Requeue après délai fixe | délai explicite (ex. 15 s) |
| Erreur transitoire (timeout, 409) | Requeue avec backoff | propager l’erreur → rate limiter |
| Erreur permanente (spec invalide) | Pas de requeue ; signaler | Event + condition de status |
| Succès | Pas de requeue ; oublier la clé | réinitialisation du backoff |
Code C# — requeue explicite : revérifier l’état plus tard
// KubeOps injecte un délégué EntityRequeue<T> pour reprogrammer la clé.
public class WebAppController : IEntityController<V1WebApp>
{
private readonly IKubernetesClient _client;
private readonly EntityRequeue<V1WebApp> _requeue;
public WebAppController(
IKubernetesClient client, EntityRequeue<V1WebApp> requeue)
{
_client = client;
_requeue = requeue;
}
public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
{
var ns = entity.Namespace() ?? "default";
var deploy = await _client.Get<V1Deployment>(entity.Name(), ns);
if (deploy is null)
{
await _client.Create(BuildDeployment(entity));
// L'état réel ne peut pas être déjà convergé : revérifier bientôt.
_requeue(entity, TimeSpan.FromSeconds(15));
return;
}
var ready = deploy.Status?.ReadyReplicas ?? 0;
if (ready < entity.Spec.Replicas)
{
// Les pods montent encore : demander une nouvelle observation
// dans 15 s plutôt que de bloquer ou de boucler activement.
_requeue(entity, TimeSpan.FromSeconds(15));
return;
}
// État convergé → rien à réenfiler.
}
}
Code C# — requeue sur erreur : transitoire vs permanente
public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
{
try
{
var secret = await _vault.ReadAsync(entity.Spec.SecretRef, token);
await ApplyConfig(entity, secret);
}
catch (TransientVaultException)
{
// TRANSITOIRE : propager. KubeOps réenfile la clé avec un backoff
// exponentiel (5 ms, 10 ms, 20 ms … plafonné ~16 min). La prochaine
// tentative relira l'état frais (level-based) — réessai sûr car idempotent.
throw;
}
catch (InvalidSpecException ex)
{
// PERMANENTE : réessayer à l'identique ne corrigera rien.
// Signaler à l'utilisateur et NE PAS propager → pas de requeue en boucle.
await PublishWarningEvent(entity, ex.Message);
}
}
Code kubectl — lire le backoff dans les events
# Les events d'un objet révèlent les tentatives répétées du contrôleur
kubectl describe webapp frontend
# ...
# Events:
# Type Reason Age From Message
# ---- ------ ---- ---- -------
# Warning ReconcileError 2m (x6 over 3m) webapp-operator vault indisponible, nouvelle tentative
# Normal Reconciled 30s webapp-operator etat desire atteint : 3/3 prets
# Le « x6 over 3m » trahit le backoff : 6 tentatives espacées, pas un matraquage.
Piège courant : « Sur erreur, il suffit d’attraper l’exception et de la logguer pour que la boucle continue » est inexact. Avaler l’exception (un
catchsansthrow) signale à la work queue que la réconciliation a réussi : la clé n’est pas réenfilée, le backoff ne s’arme pas, et l’état réel reste durablement divergent jusqu’au prochain resync — potentiellement plusieurs minutes plus tard. Sur une erreur transitoire, il faut propager pour déclencher le requeue ; n’avaler que les erreurs permanentes, et seulement après les avoir signalées.