Le webhook d'admission qui a dit oui et nous a quand même mis à terre
Nous exploitons un service d'authentification, donc la question "le conteneur qui vient de démarrer est-il vraiment le conteneur que nous avons construit ?" n'a rien d'académique. Notre réponse, c'étaient les signatures : signer chaque image dans la CI, la vérifier avant le déploiement, puis, en deuxième couche, placer un webhook dans le cluster pour que même un kubectl tapé à la main ne puisse pas lancer quelque chose de non signé. Cela a été mis en place un mardi après-midi. Au soir, notre Deployment d'authentification sur le cluster de dev avait accumulé 2 242 ReplicaSets, en créait un nouveau environ toutes les trois secondes, signalait Available=False et ne servait rien.
L'hypothèse évidente, c'est que le nouveau webhook rejetait nos images. Ce n'était pas le cas. Ses logs, pour chaque requête, disaient allowed: true. Il a admis tout ce que nous lui avons envoyé, toute la soirée, pendant que le Deployment qu'il admettait se désagrégeait.
La couche que nous ajoutions
La pipeline signait et vérifiait déjà. Chaque image reçoit une signature sans clé au moment du build, liée à l'identité OIDC de GitHub Actions, et le job de déploiement exécute une vérification contre cette identité avant que quoi que ce soit n'atteigne le cluster. Ce contrôle est fail-closed et c'est le vrai garde-fou.
La couche côté cluster, c'est de la défense en profondeur : le policy-controller de sigstore, qui tourne comme webhook d'admission et détient deux policies. L'une dit que les images correspondant à notre propre chemin de registre doivent porter une signature sans clé de notre identité de workflow. L'autre est un fourre-tout qui laisse passer tout le reste, car "aucune policy ne correspond" veut dire refuser, et sans ce fourre-tout le cluster perd ses agents Vault, ses pilotes CSI et tout sidecar qu'il n'a pas construit. Installer, étiqueter le namespace, terminé. L'étiquette est l'interrupteur : pas d'étiquette, pas d'application.
La première panne, celle qui était ennuyeuse
L'activer avec failurePolicy: Fail et le timeout par défaut de dix secondes du chart a mis le dev à terre presque immédiatement, exactement de la façon dont tout le monde s'attend à ce qu'un webhook d'admission vous mette à terre. Un controller froid doit joindre Fulcio et Rekor pour vérifier une signature qu'il n'a jamais vue. À froid, ce travail ne se termine pas en dix secondes. Fail-closed plus un délai dépassé, cela veut dire que la création de pod est refusée, ce qui veut dire que le rollout ne peut pas placer de pods, ce qui veut dire que le service n'a aucune replica.
Ce mode de défaillance est bien documenté et le correctif est celui qui est documenté : porter le timeout du webhook à trente secondes et mettre failurePolicy: Ignore. Ignore a des airs d'abandon, et dans une conception à une seule couche ce le serait. Dans la nôtre, le contrôle de la pipeline est le garde-fou fail-closed principal, et la couche cluster existe pour rattraper ce qui n'est jamais passé par la pipeline du tout. Un webhook qui ne peut jamais mettre le cluster à terre vaut plus pour nous qu'un webhook qui rattrape le dernier pour cent, car ce dernier pour cent a déjà été rattrapé en amont.
Nous avons livré cela à 17h40. Cela a entièrement corrigé la première panne. Cela a aussi créé la seconde, et c'est la partie qui vaut la peine d'être lue.
Mutating, pas seulement validating
Tout le monde se représente un webhook d'admission comme un videur : il inspecte l'objet et renvoie oui ou non. Le policy-controller n'est pas que cela. C'est un webhook mutating, et ce qu'il mute, c'est la chose qu'il vient de vérifier. Quand il admet une spec de pod qui référence une image par tag, il réécrit cette référence pour y inclure le digest qu'il a résolu et vérifié. myregistry.io/authagonal-auth:abc123 entre. myregistry.io/authagonal-auth:abc123@sha256:... ressort.
C'est une idée réellement bonne. Un tag est un pointeur mutable, donc vérifier un tag puis laisser le kubelet le résoudre à nouveau plus tard laisse une fenêtre où les deux pourraient différer. Épingler le digest au moment de l'admission la referme. L'objet qui s'exécute est l'objet qui a été vérifié.
Maintenant, mettez cela en regard du fonctionnement d'un Deployment. Le controller de Deployment hache votre template de pod, et ce hash est ce qui identifie le ReplicaSet propriétaire des pods. Il hache le template que vous avez déclaré. Le webhook réécrit le template sur le ReplicaSet qu'il admet. Donc si le template de votre Deployment dit :abc123, son propre ReplicaSet enfant dit :abc123@sha256:..., et les deux ne concordent plus.
Le controller réconcilie, hache le template, cherche un ReplicaSet ayant ce hash, et en trouve un dont le template est différent. En Kubernetes, cela ne signifie qu'une seule chose : une collision de hash, deux templates différents tombant sur le même hash. Le controller gère les collisions comme il est censé le faire. Il incrémente collisionCount, ce qui perturbe le hash, et crée un nouveau ReplicaSet. Trois secondes plus tard il réconcilie de nouveau, et le nouveau ReplicaSet a été muté lui aussi. La boucle ne converge pas, car le désaccord qu'elle tente de résoudre est recréé par le webhook à chaque tentative.
Deux mille deux cent quarante-deux ReplicaSets, voilà à quoi ressemble une boucle infinie quand on la surprend le soir plutôt que le matin.
Pourquoi un seul Deployment a cassé
Quatre workloads tournent dans ce namespace. Un seul est parti en vrille. Trois allaient parfaitement bien, et c'est le détail qui nous a fait chercher au mauvais endroit, car une mauvaise configuration systémique ne devrait pas être sélective.
failurePolicy: Ignore en est la raison. Quand le webhook est lent ou froid et qu'une requête expire, Ignore veut dire que l'objet est admis non muté. Qu'un apply donné revienne avec un digest incrusté ou avec le tag nu avec lequel il est entré dépendait de la réponse du webhook dans les temps. Les trois Deployments sains avaient été appliqués pendant qu'il était chaud, donc leurs templates portaient déjà des digests, et la réécriture du webhook était un no-op qui correspondait à ce qui était déjà là. Celui qui est parti en vrille avait été appliqué pendant que le webhook était froid, a gardé son tag nu dans le template, puis a vu chaque ReplicaSet enfant se faire muter sous ses pieds.
Le déclencheur était donc une course, tranchée à chaque apply, que n'importe lequel des quatre aurait pu perdre à n'importe quel déploiement. Ignore n'a pas causé le bug. Il a transformé un bug déterministe en bug intermittent et l'a caché derrière trois workloads sains.
Le correctif est un point fixe
L'instinct, c'est d'empêcher le webhook de muter. Meilleur instinct : ne lui donnez rien à muter. Si le template de pod que nous soumettons est déjà exactement ce que le webhook produirait, la réécriture ne change rien, le hash reste stable, et la boucle ne peut pas démarrer.
Le job de déploiement résout donc désormais le digest lui-même avant d'appliquer. Pour chaque image, il demande au registre vers quoi le tag pointe actuellement, puis écrit la référence entièrement épinglée dans l'overlay :
digest=$(az acr repository show --name "$acr" \
--image "authagonal-${img}:${tag}" --query digest -o tsv)
kustomize edit set image "${ref}=${ref}:${tag}@${digest}"
Ce qui est livré, c'est :tag@sha256:..., le tag et le digest ensemble. Le tag reste pour la lisibilité humaine, le digest est ce qui se résout réellement. Le webhook le vérifie, ne trouve rien à réécrire, et renvoie l'objet inchangé. collisionCount est resté plat depuis.
Il y a un petit piège dans la queue de tout cela. Comme c'est la CI qui écrit le digest, appliquer l'overlay à la main depuis un ordinateur portable envoie à la place le tag d'espace réservé des manifestes de base, et la policy le refuse désormais correctement avec "must be an image digest". Le cluster vous dit la vérité : ce que vous venez de taper n'a jamais été vérifié.
Un webhook mutating est un second écrivain, pas un point de contrôle
Un webhook d'admission mutating n'est pas un point de contrôle. C'est un écrivain à l'intérieur d'une boucle de contrôle qui compare déjà ce que vous avez demandé à ce qui existe. Deux écrivains ayant des avis différents sur le même champ, ce n'est pas une question de policy, c'est une question de systèmes distribués, et la seule chose qui rend cela sûr, c'est l'idempotence : l'état que vous déclarez doit être un point fixe de la mutation, de sorte qu'appliquer la mutation à cet état le reproduise à l'identique.
Ce recadrage se généralise au-delà de sigstore. Tout ce qui réécrit vos specs au moment de l'admission, que cela injecte des sidecars, ajoute des limites de ressources par défaut, ou normalise des références d'image, est dans la même position, et la même question s'applique. Si vous passiez votre propre manifeste dans ce truc, récupéreriez-vous votre propre manifeste ? Sinon, quelque chose va continuer de remarquer la différence. Ce sera patient, et ce sera bien plus rapide que vous.
Et la plus petite leçon, celle que nous avons vraiment dû désapprendre cette nuit-là : allowed: true ne veut pas dire que le webhook n'est pas le problème. Nous avons passé une heure à traiter ces logs comme un alibi. Le webhook disait la vérité tout du long. Nous lui posions la mauvaise question, car nous ne l'avions jamais envisagé que comme quelque chose qui dit non.
Si vous préférez que votre fournisseur d'identité arrive avec sa chaîne d'approvisionnement déjà débattue, Authagonal est un service d'authentification hébergé dont les images sont signées dans la CI, vérifiées avant le déploiement et épinglées par digest au point d'admission, une phrase que nous pouvons écrire parce que nous avons déjà perdu une soirée à la mériter.