Level-based vs edge-based & Informers, watches et cache local
Comprendre pourquoi Kubernetes raisonne sur l'état courant plutôt que sur les événements, et comment les informers maintiennent un cache local fiable via le mécanisme List-Watch.
Level-based vs edge-based
L’idée en une phrase
Un système edge-triggered réagit aux transitions (« un pod vient d’être supprimé »), tandis qu’un système level-triggered raisonne sur l’état courant (« il y a 2 pods, il en faut 3 ») — et c’est ce second modèle que Kubernetes adopte pour ses boucles de réconciliation, car il rend les contrôleurs naturellement tolérants aux événements perdus, dupliqués ou arrivés dans le désordre.
Analogie : Considérons deux façons de gérer le niveau d’eau d’une piscine. Un système edge-triggered installe un capteur qui déclenche une alarme chaque fois que le niveau baisse : si l’alarme ne retentit pas (panne du capteur, bruit ambiant), la piscine se vide sans que personne n’intervienne. Un système level-triggered mesure le niveau actuel toutes les minutes et ouvre la vanne de remplissage dès que le niveau passe sous le seuil — peu importe pourquoi il a baissé, peu importe combien de mesures ont été manquées entre-temps, la prochaine lecture suffit à corriger l’écart.
Points clés
- Un contrôleur edge-triggered encode dans sa logique la nature de l’événement (create, update, delete) et agit différemment selon le cas. Si un événement est perdu — coupure réseau, redémarrage du contrôleur —, l’action correspondante n’est jamais exécutée et l’état réel diverge silencieusement de l’état désiré.
- Un contrôleur level-triggered ignore le type d’événement déclencheur. Sa fonction Reconcile (comme vu au chapitre 11-12) reçoit une clé, lit l’état complet, calcule l’écart et agit. Un événement perdu retarde la réconciliation mais ne la compromet pas : le prochain déclenchement — ou le resync périodique — observe le même écart et le comble.
- La terminologie vient de l’électronique numérique : un circuit edge-triggered se déclenche sur un front montant ou descendant du signal, un circuit level-triggered sur le niveau haut ou bas. En ingénierie logicielle, le modèle level-triggered est synonyme de convergence : le système tend vers l’état désiré quel que soit le chemin emprunté.
- Kubernetes n’est pas purement level-triggered au sens strict — les watches transmettent bien des événements (second sous-thème de ce chapitre). Mais la fonction Reconcile, elle, traite toujours l’état courant complet. Les événements servent de signal de réveil, pas de source de vérité.
Exemple concret
Un Deployment déclare 3 replicas. À t₀, deux pods sont supprimés simultanément. Dans un modèle edge-triggered, le contrôleur recevrait deux événements DELETE et créerait deux pods — mais si le second événement est perdu (file saturée, redémarrage), il n’en crée qu’un et l’état reste à 2/3. Dans le modèle level-triggered de Kubernetes, la work queue déduplique les notifications en une seule clé (comme vu au chapitre 11-12). La fonction Reconcile observe 1 pod Running, calcule l’écart 3 − 1 = 2, et crée 2 pods. Résultat identique que l’on ait reçu un événement, deux, ou zéro (via resync) : seul l’état courant compte.
Edge-triggered vs level-triggered
| Critère | Edge-triggered | Level-triggered |
|---|---|---|
| Déclencheur | La transition (événement) | L’état courant |
| Information traitée | « Que s’est-il passé ? » | « Quel est l’écart maintenant ? » |
| Événement perdu | Action manquée, dérive silencieuse | Corrigé au prochain tick ou resync |
| Événements dupliqués | Action exécutée deux fois (risque de surcréation) | Écart recalculé, pas de double action |
| Complexité du code | Logique par type d’événement (switch/case) | Logique unique : observer, comparer, agir |
Code YAML — un état désiré indépendant de l’historique
# deployment-level.yaml — le contrôleur ne se demande pas
# "combien de pods ont été supprimés" mais "combien en manque-t-il"
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: server
image: myregistry/api:2.1
# Que 1 ou 3 pods aient disparu, la boucle level-triggered
# calcule le même écart et converge vers 3 replicas.
Code kubectl — observer la convergence level-triggered
# Supprimer tous les pods d'un coup (situation extrême)
kubectl delete pods -l app=api --wait=false
# pod "api-abc12" deleted
# pod "api-def34" deleted
# pod "api-ghi56" deleted
# Le contrôleur ne reçoit pas "3 événements à traiter séparément" :
# il observe 0 pods, calcule l'écart 3 - 0 = 3, et crée 3 pods.
kubectl get pods -l app=api -w
# NAME READY STATUS AGE
# api-jkl78 0/1 Pending 0s
# api-mno90 0/1 Pending 0s
# api-pqr12 0/1 Pending 0s
# api-jkl78 1/1 Running 4s
# api-mno90 1/1 Running 4s
# api-pqr12 1/1 Running 5s
# → convergence atteinte en un seul tick de réconciliation
Piège courant : « Level-triggered signifie que le contrôleur fait du polling régulier » est un raccourci trompeur. Le contrôleur ne scrute pas l’API server en boucle — il est réveillé par les événements du watch (edge), mais traite l’état courant (level). Le modèle est hybride : les edges servent de notification efficace, le level garantit la correction. Le resync périodique n’est qu’un filet de sécurité, pas le mécanisme principal.
Informers, watches et cache local
L’idée en une phrase
Un informer est le composant qui alimente la boucle de réconciliation en données : il effectue un List initial pour remplir un cache local, puis maintient ce cache à jour via un Watch continu sur l’API server, de sorte que la fonction Reconcile peut lire l’état réel sans aucun appel réseau.
Analogie : Considérons un journal mural dans le hall d’une entreprise. Chaque matin, un employé (le reflector) recopie intégralement le tableau des projets depuis le bureau central (le List). Ensuite, un système de notification interne (le Watch) lui transmet chaque modification en temps réel — un projet ajouté, une ligne barrée. Il met à jour le journal mural au fil de la journée. Les collègues (les fonctions Reconcile) consultent le journal mural (le cache) sans jamais se déplacer au bureau central. Si le système de notification tombe, l’employé redemande la copie complète et recommence.
Points clés
- Le mécanisme Watch repose sur une connexion HTTP long-lived (chunked transfer) vers l’API server. Le serveur envoie un flux d’événements typés — ADDED, MODIFIED, DELETED — pour chaque changement sur les objets surveillés. Chaque événement porte un resourceVersion, un compteur monotone qui identifie la version de l’objet dans etcd.
- Le pattern List-Watch garantit la cohérence : le List initial retourne tous les objets existants avec leur resourceVersion. Le Watch reprend à partir de cette version, sans trou. Si la connexion Watch est coupée, l’informer tente de reprendre au dernier resourceVersion connu ; si etcd a compacté cette version (événements trop anciens), il refait un List complet — c’est le relist.
- Le cache (aussi appelé Store ou Indexer) est une structure en mémoire indexée par clé (
namespace/name). Il permet des lectures en O(1) et des requêtes par index (par label, par champ). La fonction Reconcile lit toujours depuis ce cache — un accès local, sans latence réseau. - Le SharedInformer mutualise le Watch entre plusieurs contrôleurs surveillant le même type de ressource. Dix contrôleurs intéressés par les pods partagent un seul SharedInformer (et donc un seul Watch), ce qui réduit la charge sur l’API server. Chaque contrôleur enregistre ses propres event handlers (AddFunc, UpdateFunc, DeleteFunc) auprès du SharedInformer.
Exemple concret
Le kube-controller-manager exécute une trentaine de contrôleurs dans un seul processus. Sans SharedInformer, chaque contrôleur ouvrirait son propre Watch sur les pods — 30 connexions HTTP parallèles transmettant les mêmes données. Avec le SharedInformerFactory, un seul Watch alimente un cache unique. Lorsqu’un pod passe de Pending à Running, le SharedInformer met à jour le cache et invoque les handlers de chaque contrôleur concerné (ReplicaSet controller, Endpoint controller, etc.). Chacun enfile la clé de son objet primaire dans sa propre work queue — le contrôleur ReplicaSet enfile la clé du ReplicaSet propriétaire, le contrôleur Endpoint enfile la clé du Service associé. Un seul événement Watch déclenche plusieurs réconciliations indépendantes, sans duplication de trafic réseau.
Composants du mécanisme List-Watch
| Composant | Rôle | Détail technique |
|---|---|---|
| Reflector | Effectue le List initial puis le Watch | Maintient le resourceVersion courant, gère la reconnexion |
| Store (cache) | Stocke les objets en mémoire, indexés par clé | Lectures O(1), mises à jour atomiques par événement |
| SharedInformer | Mutualise un Watch entre plusieurs consommateurs | Un seul flux réseau, distribution locale des événements |
| Event handler | Callback enregistré par un contrôleur | Convertit l’événement en clé et l’enfile dans la work queue |
| ResourceVersion | Identifiant de version dans etcd | Permet au Watch de reprendre sans rejouer l’historique |
Code YAML — l’objet tel qu’il transite dans le Watch
# Événement Watch de type MODIFIED, tel que reçu par l'informer.
# Le resourceVersion identifie précisément cette version de l'objet.
apiVersion: v1
kind: Pod
metadata:
name: api-jkl78
namespace: default
resourceVersion: "48217" # le Watch reprend après ce numéro
labels:
app: api
status:
phase: Running # transition : Pending → Running
conditions:
- type: Ready
status: "True"
# L'informer met à jour le cache local avec cet objet,
# puis invoque les event handlers enregistrés.
Code kubectl — observer le Watch en temps réel
# Le flag -w (--watch) simule côté client ce que fait l'informer :
# un List initial suivi d'un Watch continu.
kubectl get pods -l app=api -w
# NAME READY STATUS AGE
# api-jkl78 1/1 Running 10m ← résultat du List initial
# api-mno90 1/1 Running 10m
# api-pqr12 1/1 Running 10m
# api-jkl78 1/1 Running 10m ← événement Watch (MODIFIED)
# Observer le resourceVersion d'un objet — le curseur du Watch
kubectl get pod api-jkl78 -o jsonpath='{.metadata.resourceVersion}'
# 48217
# En interne, l'informer fait l'équivalent de :
# GET /api/v1/namespaces/default/pods?labelSelector=app%3Dapi
# → List (tous les pods, avec resourceVersion de la liste)
# GET /api/v1/namespaces/default/pods?watch=true&resourceVersion=48200
# → Watch (flux HTTP chunked à partir de cette version)
Piège courant : « Si le Watch se déconnecte, l’informer perd des événements et le cache devient incohérent » est une crainte légitime mais gérée par conception. À la reconnexion, l’informer reprend le Watch au dernier resourceVersion connu. Si ce resourceVersion a expiré (etcd a compacté l’historique, erreur
410 Gone), l’informer effectue un relist complet — il repart de zéro avec un cache garanti cohérent. C’est le même principe de réconciliation appliqué au mécanisme de cache lui-même : en cas de doute, relire l’état complet plutôt que tenter de reconstruire l’historique.