L’adoption de Kubernetes en production a atteint 82 % en 2025, contre 66 % deux ans plus tôt (CNCF Annual Survey 2025, 2026). De plus en plus d’équipes plateforme livrent donc des contrôleurs sur mesure. Et une catégorie de bug ne cesse de resurgir. Le cache informer ment à votre boucle de réconciliation. Un statut que vous venez d’écrire se retrouve silencieusement écrasé par une lecture périmée. Cet article détaille le pattern de réconciliateur par étapes que nous exécutons en production. Il revient ensuite sur trois incidents réels que ce pattern a révélés. Enfin, il montre comment le correctif de péremption du cache de Kubernetes v1.36 s’attaque à la cause racine.
À retenir
- Structurez les réconciliateurs en étapes ordonnées et testables indépendamment, chacune rapportant sa propre condition de statut, plutôt qu’en une fonction
Reconcilemonolithique.- Les caches informer accusent un retard de plusieurs dizaines de millisecondes sur l’API server à grande échelle ; une lecture périmée à l’intérieur d’un patch
MergeFrompeut écraser une condition que vous avez définie 49 ms plus tôt.- Corrigez le patch de statut avec un lecteur sans cache à l’intérieur de
RetryOnConflict; Kubernetes v1.36 (avril 2026) permet désormais aux contrôleurs de détecter et d’ignorer les réconciliations en retard sur le cache.- Même un changement de logging est un changement en production : une clé de log dupliquée a un jour fait disparaître chaque ligne de log du contrôleur le plus actif d’un environnement.
Tout ce qui suit provient de l’exploitation de la plateforme Kubernetes interne d’un grand constructeur automobile européen, qui fait tourner une flotte de contrôleurs sur mesure sur du GKE managé. Les noms et identifiants sont volontairement génériques. Les modes de défaillance, eux, sont exacts.
Qu’est-ce que le pattern de réconciliateur par étapes ?
Le pattern de réconciliateur par étapes découpe la boucle de réconciliation d’un contrôleur en une séquence ordonnée de petites étapes composables. Chaque étape rapporte sa propre condition de statut, au lieu d’une seule fonction monolithique qui fait tout. En pratique, cela garde la logique de provisionnement testable par tables et rend visible la progression partielle. Sur l’ensemble de notre suite de contrôleurs, cela a transformé un corps de réconciliation de 400 lignes en une chaîne lisible. C’est la même discipline de réconciliation qui sous-tend le control plane Kubernetes as a Service que nous exploitons, où des dizaines de types de ressources partagent un même contrat.
Une fonction Reconcile monolithique devient vite illisible. Imaginez une seule custom resource qui doit provisionner du réseau, de l’IAM, des namespaces, des quotas et du DNS. Avez-vous déjà essayé de tester unitairement une fonction pareille ? Nous organisons donc chaque contrôleur autour d’un controller.go, plus les packages steps/creation/ et steps/deletion/. Chaque étape embarque un BaseStep partagé. Celui-ci contient le client controller-runtime, un lecteur sans cache, le scheme, un logger et un event recorder. Chaque étape expose aussi des conditions Ready et Reconciling explicites.
Une ressource Environment, par exemple, exécute une chaîne comme celle-ci : EnsureFinalizer, ResolveClusterConfigRef, ComputeTargetNamespaceName, EnsureNamespace, ApplyResourceQuotas, ApplyLimitRanges, ApplyServiceAccounts, ApplyRoleBindings, ApplyNetworkPolicies, DeleteFinalizer. Chaque étape est petite, ordonnée et testable indépendamment. Résultat : les états d’échec partiel apparaissent sous forme de conditions par étape. Vous pouvez les lire directement depuis les print columns de kubectl get.
La primitive la plus précieuse que nous ayons construite n’était pas l’interface d’étape elle-même. C’était le helper de patch de statut partagé qui la sous-tend. Chaque étape appelle une seule fonction pour mettre à jour les conditions. Cette fonction centralise donc en un unique endroit la danse « lecture puis patch » sûre face aux conflits. Quand nous avons découvert plus tard un bug de cache, nous l’avons corrigé une seule fois, pas dans quinze sites d’appel.
Le patch de statut partagé, sûr face aux conflits
Un helper de statut re-récupère la dernière version de l’objet, la mute, puis la patche avec client.MergeFrom à l’intérieur d’une boucle retry.RetryOnConflict. Il évite ainsi que deux étapes en cours de réconciliation ne s’écrasent mutuellement leurs conditions. Et la comparaison sensible à la génération fait qu’un état inchangé ne redéclenche pas de patch. Cela garde l’API server et votre journal d’audit silencieux.
func (r *StepRuntime) UpdateStatusFields(ctx context.Context, obj client.Object,
mutate func(client.Object) error) error {
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
// Read through the UNCACHED reader, not the informer cache.
fresh := obj.DeepCopyObject().(client.Object)
if err := r.UncachedClient.Get(ctx, client.ObjectKeyFromObject(obj), fresh); err != nil {
return err
}
base := fresh.DeepCopyObject().(client.Object)
if err := mutate(fresh); err != nil {
return err // may be ErrNoOp; caller short-circuits
}
return r.Client.Status().Patch(ctx, fresh, client.MergeFrom(base))
})
}
Deux petits détails ont leur importance. Le sentinel ErrNoOp permet à la fonction de mutation d’une étape de dire « rien n’a changé », distinctement d’une véritable erreur. Les appelants court-circuitent donc proprement. Et la corrélation d’événements n’émet un événement Kubernetes que lorsque l’état visible de la condition a réellement changé, pas à chaque rafraîchissement portant uniquement sur la génération. Sans ce garde-fou, un contrôleur qui se réconcilie toutes les 20 secondes inonde le flux d’événements de doublons.
Piège : La lecture sans cache dans l’extrait ci-dessus ne faisait pas partie de la conception d’origine. C’était un correctif appliqué en plein incident. La première version lisait à travers le cache informer, et c’est précisément là que commence la section suivante.
Pourquoi une condition de statut fraîche se fait-elle écraser ?
Une condition de statut fraîche se fait écraser lorsqu’une étape lit l’objet à travers le cache informer plutôt que via l’API server. Le cache peut accuser un retard de plusieurs dizaines de millisecondes au sein d’une même réconciliation. Cette lecture périmée voyage ensuite dans un patch MergeFrom et écrase une condition qu’une autre étape venait de définir quelques instants plus tôt (controller-runtime #741, 2021).
Voici l’incident, exactement tel qu’il nous a mordus. Une release de routine a introduit de nouveaux types de conditions sur la ressource StandardCluster, remplaçant une ancienne condition de readiness par une plus spécifique. Une nouvelle réconciliation a fait trois choses dans l’ordre. D’abord, MarkReconciling a patché Ready=False à T=0. Ensuite, l’étape suivante, ne voyant aucune valeur antérieure pour son nouveau type de condition, a relu l’objet pour mettre à jour le statut. Enfin, le cache informer renvoyait encore l’objet d’avant le redémarrage, porteur d’un Ready=True périmé.
Ce Ready=True périmé a voyagé dans le même patch MergeFrom. Il a donc écrasé le Ready=False écrit 49 millisecondes plus tôt. Effet net : les clusters n’atteignaient jamais l’état Ready. Le plus exaspérant ? Exécuter le contrôleur en local corrigeait le problème à chaque fois. Un run local utilisait un client neuf qui contournait le cache partagé. Cela avait l’air non déterministe. Ça ne l’était pas. C’était une violation du principe « lis ta propre écriture », tapie dans le cache.
Piège : Si un bug disparaît quand vous exécutez le contrôleur en local mais persiste dans le cluster, soupçonnez la péremption du cache informer avant de soupçonner une erreur de logique. Les runs locaux contournent souvent le chemin de cache partagé que la production, elle, emprunte.
La plupart des articles présentent la boucle de réconciliation comme un cycle propre lecture-diff-écriture, et s’arrêtent là. Ils ne relient jamais le cache informer à un bug de correction. Le cache est en général présenté comme une pure optimisation de performance. Mais à grande échelle, il devient un danger pour la correction, tout comme un programme eBPF périmé sur GKE Dataplane V2 peut continuer à appliquer une NetworkPolicy que vous avez déjà supprimée. « Lire » et « écrire » ne visent plus la même vue du monde. La cause racine en amont est un backlog de DeltaFIFO combiné à une contention du mutex lecture-écriture dans le cache informer. Sous charge, cela produit des lectures périmées (kubernetes/kubernetes #130767, 2025).
Le correctif dans notre code a consisté à lire via un lecteur sans cache à l’intérieur de la boucle de retry, exactement comme le montre l’extrait précédent. En amont, le correctif est arrivé dans Kubernetes v1.36 en avril 2026. Les contrôleurs peuvent désormais vérifier le resourceVersion du cache. Ils sautent alors la réconciliation quand le cache est en retard sur leurs propres écritures. Cela referme l’écart « lis ta propre écriture » au niveau du framework (kubernetes.io blog, 2026).
Quand OperationResultUpdated doit-il déclencher un requeue ?
OperationResultUpdated ne devrait presque jamais vouloir dire « remettre en file et revenir plus tard ». Il signifie que quelque chose a changé, et vous devez tout de même poursuivre jusqu’à la vérification de readiness avant de décider de remettre en file. Traiter Updated comme terminal a produit une boucle de réconciliation infinie, d’environ 20 secondes, à travers sept fichiers d’étapes dans notre contrôleur d’exposition de services.
La forme de ce bug mérite d’être mémorisée, car elle est facile à réintroduire. La fonction de mutation d’une étape basculait une annotation à chaque réconciliation. Par exemple, un indice d’intervalle de réconciliation rapide dérivé de la readiness courante. L’annotation changeait donc à chaque passage. Résultat : controllerutil.CreateOrPatch renvoyait OperationResultUpdated presque à chaque fois. Plusieurs étapes traitaient alors Updated comme « terminé pour l’instant, requeue ». Elles n’atteignaient donc jamais la vérification qui demande si la ressource cloud sous-jacente est réellement prête.
switch result {
case controllerutil.OperationResultCreated,
controllerutil.OperationResultUpdated,
controllerutil.OperationResultNone:
// All three fall through to the SAME readiness check.
if !cloudResourceReady(obj) {
return ctrl.Result{RequeueAfter: 20 * time.Second}, nil
}
return ctrl.Result{}, nil // ready: stop requeuing
}
Nous avons corrigé cela dans sept fichiers frères. Nous avons fusionné les cas Created, Updated et inchangé en une seule vérification de readiness. Ensuite, nous ne remettons en file que lorsque la ressource n’est toujours pas prête. Si vous relisez une nouvelle étape qui enveloppe CreateOrPatch, cherchez l’anti-pattern case OperationResultUpdated: return ctrl.Result{RequeueAfter: ...}, nil sans vérification de readiness en amont. Cette seule ligne est une boucle auto-entretenue qui ne demande qu’à survenir.
Comment gérer les races NotFound en lecture-après-création ?
Gérez un NotFound en lecture-après-création en réessayant l’erreur transitoire quelques fois, et non en élargissant une fenêtre de cache. L’objet existe sur l’API server, mais n’est pas encore visible pour votre Get immédiatement suivant. Il s’agit d’un décalage de visibilité côté API server, l’image miroir de la péremption de cache côté client, si bien que les correctifs pointent dans des directions opposées.
Nous avons rencontré cela quand une étape de création d’espace de travail posait des owner references sur des namespaces juste après les avoir créés. Occasionnellement, le Get suivant renvoyait NotFound, alors que le namespace existait manifestement. L’instinct pousse à supposer qu’une attente de cohérence de cache plus longue aiderait. Mais le décalage ici est une visibilité côté serveur à terme, pas un cache client périmé. Une attente plus longue n’y change donc rien. Le bon pattern est plutôt un retry borné et court sur le NotFound transitoire. Traitez-le comme attendu, pas comme un échec d’étape.
Piège : Un bug de cache périmé et un bug de lecture-après-création semblent identiques dans une stack trace. L’un se corrige en lisant au-delà du cache ; l’autre en réessayant la lecture. Diagnostiquez de quel côté de la frontière client-serveur vous vous trouvez avant de vous précipiter sur un correctif.
Comment passer les contrôleurs à l’échelle avec un sharding par environnement ?
Le sharding par environnement partitionne la réconciliation selon une dimension métier : l’environnement de déploiement. Il s’appuie sur les predicates de controller-runtime, plutôt que de découper le CRD, l’API ou la base de code. Une seule instance de contrôleur qui réconcilie tous les environnements couple leur destin. Une tempête de réconciliation dans dev peut donc dégrader ope. Le sharding les découple, tout en conservant un schéma unique.
Le mécanisme est une topologie de release, pas un fork de code. Nous déployons donc un seul chart Helm sous forme de plusieurs releases de la même version. La même coordination à l’échelle d’une flotte réapparaît quand on promeut des add-ons sur de nombreux clusters, ce que nous gérons avec la promotion d’add-ons de flotte pilotée par Sveltos. Une release commune fait tourner les singletons agnostiques à l’environnement, comme l’API plateforme et le tenant controller. Une release par environnement ne fait tourner que les contrôleurs à périmètre d’environnement. Chacun reçoit un argument CLI déclarant l’unique environnement qu’il possède.
Chaque contrôleur à périmètre d’environnement compose ses predicates existants avec un predicate supplémentaire de correspondance d’environnement. Ce predicate n’admet un objet dans la work queue que lorsque son environnement correspond à celui du shard. Et il dérive cet environnement de la source de vérité existante, comme un label ou un namespace. Le filtrage a lieu avant que les objets n’entrent dans la queue. Les objets hors périmètre ne déclenchent donc jamais de réconciliation, et la logique existante reste intacte.
Le vrai coût de cette conception n’est pas du code. C’est un invariant opérationnel qui vit en dehors du système de types : l’union des releases d’environnement doit couvrir chaque environnement exactement une fois, sans chevauchement ni lacune. Le vérificateur de types ne peut pas l’imposer. Votre automatisation de release, si. Nous donnons aussi à chaque shard un identifiant de leader-election indépendant, pour que les releases ne se disputent jamais le même verrou. Un argument d’environnement manquant retombe en mode non shardé, ce qui rend le déploiement incrémental et réversible.
Pourquoi même un changement de logging est-il un changement en production ?
Un changement de logging est un changement en production. Pourquoi ? Les logs transitent par des parsers et des agents qui échouent d’une manière que vos tests unitaires ne voient jamais. En mars 2026, deux montées de version sur notre control plane se sont toutes deux ramenées à la même erreur : du code dupliquant un comportement que le framework fournissait déjà. Et l’une d’elles a silencieusement supprimé les logs du contrôleur le plus actif d’un environnement.
Le premier incident était une montée de version d’apimachinery. Une bump de dépendance a retiré une méthode ProtoMessage() générée des core meta types. Notre fichier proto généré embarquait ces types Kubernetes directement. Si bien que la première fois que le serveur gRPC a tenté de marshaler une réponse, le runtime protobuf a paniqué à l’initialisation paresseuse du descripteur. Cela a fait tomber le processus entier. Comme la panique se déclenche à l’init du file descriptor plutôt que champ par champ, une seule mauvaise requête a tout crashé. Le correctif a déplacé la gateway HTTP d’un enregistrement basé sur les endpoints vers un enregistrement in-process. Cela a contourné entièrement le codec binaire, en réutilisant un marshaler JSON existant.
Le second incident est celui que je raconte le plus souvent. Nous avons ajouté un champ de corrélation reconcileID aux huit contrôleurs, avec un appel .WithValues("controller", ..., "reconcileID", uuid.New()) en tête de chaque Reconcile. Deux jours plus tard, chaque ligne de log du contrôleur le plus actif d’un environnement avait silencieusement disparu de Cloud Logging.
La cause racine était une clé JSON dupliquée. Votre suite de tests l’aurait-elle attrapée ? La nôtre, non. Controller-runtime injecte déjà automatiquement controller, name, namespace et reconcileID dans le logger de contexte. Notre appel ajouté re-ajoutait donc les mêmes clés. Cela produisait des objets JSON à clés dupliquées. L’agent de log fluentbit de GKE parse ces logs via msgpack. Et l’implémentation Go de msgpack ne sait pas hacher un objet à clés dupliquées. Il a donc laissé tomber la ligne entière, plutôt que d’échouer bruyamment. Un environnement s’en est sorti : son contrôleur le plus actif utilisait une variante de logger qui remplaçait les clés au lieu de les ajouter. Le correctif était simple. Nous avons retiré l’appel redondant, car le framework portait déjà chaque champ que nous essayions d’ajouter.
Piège : Avant d’ajouter des champs à un logger structuré, vérifiez ce que votre framework injecte déjà. Les clés dupliquées sont du JSON valide pour votre programme, et du poison pour votre pipeline de logs.
Ce qui fiabilise les contrôleurs à grande échelle
Les contrôleurs sur mesure échouent d’une manière que les tutoriels mentionnent rarement. Et presque toutes ces défaillances vivent à une même frontière : entre votre logique de réconciliation et le cache du framework. Structurez donc les réconciliateurs en étapes ordonnées et testables indépendamment. Cela garde chaque unité de logique de provisionnement petite et la progression partielle visible. Centralisez ensuite le patch de statut dans un unique helper sûr face aux conflits. Lisez via un lecteur sans cache sur ce chemin, pour qu’une lecture de cache périmée ne puisse pas écraser une condition que vous venez de définir.
Surveillez le framework, pas seulement votre code. OperationResultUpdated n’est pas un signal de requeue. C’est un signal pour revérifier la readiness. Un NotFound en lecture-après-création veut un retry, pas une fenêtre de cache plus large. Et un changement de logging peut être aussi destructeur qu’un changement de schéma. Si vous exploitez des contrôleurs à grande échelle, adoptez donc tôt la détection de péremption du cache de Kubernetes v1.36. Et traitez chaque montée de version du framework comme le changement en production qu’elle est.
Réponses directes
Questions fréquentes
Qu'est-ce que le cache informer et pourquoi devient-il périmé ?
Le cache informer est une réplique locale, en mémoire, des objets de l'API server que controller-runtime maintient pour éviter de marteler l'API server à chaque lecture. Il devient périmé sous charge quand la delta queue qui l'alimente s'engorge et que la contention de mutex retarde les mises à jour, si bien qu'une lecture peut renvoyer une version d'objet plus ancienne que la vôtre.
Chaque contrôleur devrait-il utiliser un lecteur sans cache ?
Non. Utilisez le cache pour les lectures à fort volume où une vue légèrement périmée est acceptable, soit la plupart des lectures. Réservez le lecteur sans cache au chemin lecture-puis-patch du statut, où lire votre propre écriture récente importe pour la correction. Sur Kubernetes v1.36 et versions ultérieures, la détection intégrée de péremption en réduit le besoin.
En quoi la réconciliation par étapes diffère-t-elle d'une machine à états ?
Une machine à états modélise une ressource se déplaçant entre des états nommés. La réconciliation par étapes exécute une chaîne ordonnée d'étapes idempotentes à chaque réconciliation, chacune revérifiant sa précondition et rapportant une condition. Les étapes ne sont pas des transitions ; ce sont des unités testables qui s'exécutent toujours dans l'ordre. Cela convient mieux à la nature level-triggered des contrôleurs.
Kubernetes v1.36 supprime-t-il le besoin de lectures sans cache ?
Pas entièrement, mais il aide considérablement. L'atténuation de v1.36 permet à un contrôleur de comparer le resourceVersion du cache à sa propre dernière écriture et de sauter une réconciliation quand le cache est en retard, refermant l'écart « lis ta propre écriture » le plus courant. Les lectures sans cache restent utiles pour les patchs de statut.