Der Admission-Webhook, der Ja sagte und uns trotzdem lahmlegte
Wir betreiben einen Auth-Dienst, deshalb ist die Frage "ist der Container, der gerade gestartet ist, wirklich der Container, den wir gebaut haben?" nicht akademisch. Unsere Antwort waren Signaturen: jedes Image in der CI signieren, vor dem Deploy verifizieren und dann, als zweite Schicht, einen Webhook in den Cluster setzen, damit selbst ein von Hand getipptes kubectl nichts Unsigniertes starten konnte. Das ging an einem Dienstagnachmittag live. Bis zum Abend hatte unser Auth-Deployment auf dem Dev-Cluster 2.242 ReplicaSets angesammelt, erzeugte ungefähr alle drei Sekunden ein neues, meldete Available=False und lieferte nichts aus.
Die naheliegende Hypothese ist, dass der neue Webhook unsere Images ablehnte. Tat er nicht. Seine Logs sagten für jede Anfrage allowed: true. Er ließ alles zu, was wir ihm schickten, den ganzen Abend, während das Deployment, das er zuließ, auseinanderfiel.
Die Schicht, die wir hinzufügten
Die Pipeline signierte und verifizierte bereits. Jedes Image erhält zur Build-Zeit eine schlüssellose Signatur, gebunden an die OIDC-Identität von GitHub Actions, und der Deploy-Job führt eine Verifikation gegen diese Identität aus, bevor irgendetwas den Cluster erreicht. Dieses Tor ist fail-closed, und es ist die eigentliche Kontrolle.
Die Schicht auf Cluster-Seite ist Defence in Depth: sigstores policy-controller, der als Admission-Webhook läuft und zwei Policies hält. Eine besagt, dass Images, die zu unserem eigenen Registry-Pfad passen, eine schlüssellose Signatur unserer Workflow-Identität tragen müssen. Die andere ist ein Catch-all, das alles Übrige durchlässt, denn "keine Policy passte" bedeutet ablehnen, und ohne das Catch-all verliert der Cluster seine Vault-Agenten, seine CSI-Treiber und jeden Sidecar, den er nicht selbst gebaut hat. Installieren, den Namespace labeln, fertig. Das Label ist der Schalter: kein Label, keine Durchsetzung.
Der erste Ausfall, der langweilig war
Ihn mit failurePolicy: Fail und dem im Chart voreingestellten Timeout von zehn Sekunden einzuschalten, legte Dev fast sofort lahm, und zwar auf genau die Weise, wie jeder erwartet, dass ein Admission-Webhook einen lahmlegt. Ein kalter Controller muss Fulcio und Rekor erreichen, um eine Signatur zu verifizieren, die er noch nicht gesehen hat. Kalt ist diese Arbeit in zehn Sekunden nicht erledigt. Fail-closed plus überschrittene Frist heißt, dass die Pod-Erstellung verweigert wird, was heißt, dass der Rollout keine Pods platzieren kann, was heißt, dass der Dienst keine Replicas hat.
Dieser Fehlermodus ist gut dokumentiert, und die Lösung ist die dokumentierte: das Webhook-Timeout auf dreißig Sekunden anheben und failurePolicy: Ignore setzen. Ignore klingt nach Aufgeben, und in einem einschichtigen Design wäre es das auch. In unserem ist das Pipeline-Tor die fail-closed-Primärkontrolle, und die Cluster-Schicht existiert, um das aufzufangen, was überhaupt nie durch die Pipeline gelaufen ist. Ein Webhook, der den Cluster niemals lahmlegen kann, ist uns mehr wert als ein Webhook, der das letzte eine Prozent abfängt, denn das letzte eine Prozent ist stromaufwärts bereits abgefangen worden.
Das haben wir um 17:40 ausgeliefert. Es behob den ersten Ausfall vollständig. Es erzeugte auch den zweiten, und das ist der Teil, der sich zu lesen lohnt.
Mutating, nicht nur validating
Jeder stellt sich einen Admission-Webhook als Türsteher vor: er prüft das Objekt und gibt Ja oder Nein zurück. Policy-controller ist nicht nur das. Er ist ein mutating Webhook, und was er mutiert, ist genau das, was er gerade verifiziert hat. Wenn er eine Pod-Spec zulässt, die ein Image über ein Tag referenziert, schreibt er diese Referenz so um, dass sie den Digest enthält, den er aufgelöst und geprüft hat. myregistry.io/authagonal-auth:abc123 geht hinein. myregistry.io/authagonal-auth:abc123@sha256:... kommt heraus.
Das ist eine wirklich gute Idee. Ein Tag ist ein veränderlicher Zeiger, also lässt das Verifizieren eines Tags und das spätere erneute Auflösen durch das kubelet ein Fenster offen, in dem beide voneinander abweichen könnten. Den Digest zum Zeitpunkt der Admission festzunageln, schließt dieses Fenster. Das Objekt, das läuft, ist das Objekt, das verifiziert wurde.
Nun stelle man das neben die Funktionsweise eines Deployments. Der Deployment-Controller bildet einen Hash über dein Pod-Template, und dieser Hash identifiziert das ReplicaSet, dem die Pods gehören. Er hasht das Template, das du deklariert hast. Der Webhook schreibt das Template auf dem ReplicaSet um, das er zulässt. Wenn das Template deines Deployments also :abc123 sagt, sagt sein eigenes Kind-ReplicaSet :abc123@sha256:..., und beide stimmen nicht mehr überein.
Der Controller gleicht ab, hasht das Template, sucht nach einem ReplicaSet mit diesem Hash und findet eines, dessen Template anders ist. In Kubernetes bedeutet das genau eine Sache: eine Hash-Kollision, zwei verschiedene Templates, die auf demselben Hash landen. Der Controller behandelt Kollisionen so, wie er es soll. Er erhöht collisionCount, was den Hash verändert, und erstellt ein neues ReplicaSet. Drei Sekunden später gleicht er erneut ab, und das neue ReplicaSet ist ebenfalls mutiert worden. Die Schleife konvergiert nicht, weil die Diskrepanz, die er aufzulösen versucht, vom Webhook bei jedem Versuch neu erzeugt wird.
Zweitausendzweihundertzweiundvierzig ReplicaSets, so sieht eine Endlosschleife aus, wenn man sie am Abend statt am Morgen bemerkt.
Warum nur ein Deployment kaputtging
In diesem Namespace laufen vier Workloads. Eine geriet in eine Spirale. Drei waren völlig in Ordnung, und genau dieses Detail ließ uns an der falschen Stelle suchen, denn eine systemische Fehlkonfiguration sollte nicht selektiv sein.
failurePolicy: Ignore ist der Grund. Wenn der Webhook langsam oder kalt ist und eine Anfrage in ein Timeout läuft, bedeutet Ignore, dass das Objekt unmutiert zugelassen wird. Ob ein bestimmtes Apply mit eingebranntem Digest oder mit dem nackten Tag zurückkam, mit dem es hineinging, hing davon ab, ob der Webhook rechtzeitig antwortete. Die drei gesunden Deployments waren zugelassen worden, während er warm war, ihre Templates trugen also bereits Digests, und das Umschreiben des Webhooks war ein No-op, das dem entsprach, was schon da war. Das eine, das in die Spirale geriet, war zugelassen worden, während der Webhook kalt war, behielt sein nacktes Tag im Template und ließ dann jedes Kind-ReplicaSet unter sich weg mutieren.
Der Auslöser war also ein Race, pro Apply entschieden, das jede der vier bei jedem Deploy hätte verlieren können. Ignore verursachte den Bug nicht. Es verwandelte einen deterministischen Bug in einen sporadischen und versteckte ihn hinter drei gesunden Workloads.
Die Lösung ist ein Fixpunkt
Der Instinkt sagt, man solle den Webhook am Mutieren hindern. Der bessere Instinkt: gib ihm nichts zu mutieren. Wenn das Pod-Template, das wir einreichen, bereits genau das ist, was der Webhook erzeugen würde, ändert das Umschreiben nichts, der Hash bleibt stabil, und die Schleife kann gar nicht erst anlaufen.
Der Deploy-Job löst den Digest deshalb jetzt selbst auf, bevor er anwendet. Für jedes Image fragt er die Registry, worauf das Tag aktuell zeigt, und schreibt dann die vollständig festgenagelte Referenz in das Overlay:
digest=$(az acr repository show --name "$acr" \
--image "authagonal-${img}:${tag}" --query digest -o tsv)
kustomize edit set image "${ref}=${ref}:${tag}@${digest}"
Was ausgeliefert wird, ist :tag@sha256:..., Tag und Digest zusammen. Das Tag bleibt für die menschliche Lesbarkeit, der Digest ist das, was tatsächlich aufgelöst wird. Der Webhook verifiziert es, findet nichts zum Umschreiben und gibt das Objekt unverändert zurück. collisionCount ist seither flach geblieben.
Am Ende steckt darin eine kleine Falle. Weil die CI den Digest schreibt, sendet das Anwenden des Overlays von Hand von einem Laptop stattdessen das Platzhalter-Tag der Basis-Manifeste, und die Policy weist es nun korrekt mit "must be an image digest" ab. Der Cluster sagt dir die Wahrheit: das, was du gerade getippt hast, wurde nie verifiziert.
Ein mutating Webhook ist ein zweiter Schreiber, kein Kontrollpunkt
Ein mutating Admission-Webhook ist kein Kontrollpunkt. Er ist ein Schreiber innerhalb einer Steuerungsschleife, die bereits vergleicht, was du angefordert hast, mit dem, was existiert. Zwei Schreiber mit unterschiedlichen Meinungen über dasselbe Feld sind keine Policy-Frage, sondern eine Frage verteilter Systeme, und das Einzige, was das sicher macht, ist Idempotenz: der Zustand, den du deklarierst, muss ein Fixpunkt der Mutation sein, sodass das Anwenden der Mutation auf ihn ihn erneut erzeugt.
Diese Neubetrachtung geht über sigstore hinaus. Alles, was deine Specs zum Zeitpunkt der Admission umschreibt, ob es Sidecars injiziert, Standard-Ressourcenlimits hinzufügt oder Image-Referenzen normalisiert, ist in derselben Lage, und dieselbe Frage gilt. Wenn du dein eigenes Manifest durch dieses Ding laufen ließest, bekämst du dein eigenes Manifest zurück? Wenn nicht, wird irgendetwas den Unterschied immer wieder bemerken. Es wird geduldig sein, und es wird viel schneller sein als du.
Und die kleinere Lektion, die, die wir in jener Nacht tatsächlich verlernen mussten: allowed: true bedeutet nicht, dass der Webhook nicht das Problem ist. Wir haben eine Stunde damit verbracht, diese Logs als Alibi zu behandeln. Der Webhook sagte die ganze Zeit die Wahrheit. Wir stellten ihm die falsche Frage, weil wir ihn immer nur als etwas gedacht hatten, das Nein sagt.
Wenn dir lieber wäre, dein Identity-Provider käme mit einer bereits durchdachten Lieferkette, Authagonal ist ein gehosteter Auth-Dienst, dessen Images in der CI signiert, vor dem Deploy verifiziert und am Punkt der Admission per Digest festgenagelt werden, ein Satz, den wir schreiben können, weil wir ihn uns bereits mit einem verlorenen Abend verdient haben.