Ich habe denselben DNS-Eintrag zweimal gelöscht. Beim dritten Mal habe ich gefragt, warum.

dns aws route53 kubernetes devops

Ein öffentlicher Endpoint fiel aus. Kein 500er, kein Timeout, der Name löste sich einfach nicht mehr auf. Einen verwaisten DNS-Eintrag zu löschen behob es in unter fünf Minuten, und ich machte weiter. Drei Tage später starb derselbe Endpoint auf dieselbe Weise, und derselbe Fix funktionierte erneut. Diese Wiederholung ist der einzige Grund, warum ich das Ticket nicht ein zweites Mal geschlossen und es Zufall genannt habe: Ein Fix, der sich nach Plan wiederholen muss, ist kein Fix, sondern ein Symptom, und der eigentliche Bug war ein DNS-Ownership-Eintrag, der sich niemals selbst erschreiben kann.

Der erste Ausfall sieht nach einem Überbleibsel aus

Das Symptom war ein Go-Service, der jeden ausgehenden Aufruf an den Endpoint in etwa 4,5 Millisekunden scheitern ließ, viel zu schnell für einen echten Timeout, was das typische Zeichen für einen DNS-Auflösungsfehler statt eines Backend-Fehlers ist. dig auf den Namen lieferte einen verwaisten AAAA-Eintrag, der auf den Ingress-Load-Balancer zeigte, und sonst nichts. Kein A-Eintrag.

Der Load Balancer selbst war in Ordnung. Bevor ich irgendetwas an DNS anfasste, bewies ich das Ziel unabhängig von dem, was die Zone behauptete:

curl --resolve checkout.example.com:443:<load-balancer-ip> https://checkout.example.com/

Ein gültiges Zertifikat und eine echte Anwendungsantwort kamen zurück. Das Backend stand also nie zur Debatte, nur der Eintragstyp war falsch. Der AAAA-Eintrag war totes Gewicht, weil der Load Balancer nur IPv4 spricht und auf dieser Adresse nie geantwortet hat.

external-dns läuft mit --policy=sync und --registry=txt, was bedeutet, dass es einen Namen besitzt, indem es einen passenden TXT-Eintrag daneben hält, und es fasst nichts an, was es nicht besitzt. Die Logs erklärten den Rest:

Skipping endpoint checkout.example.com ... AAAA ... because owner id does not
match for one or more items to create, found: "", required: "eks-prod-cluster"

external-dns wollte den A-Eintrag aus dem Ingress veröffentlichen, aber der verwaiste AAAA-Eintrag hatte keinen TXT-Eintrag, den es als Owner erkannte, also wurde das gesamte Changeset für diesen Namen abgelehnt, nicht nur der AAAA-Eintrag, sondern auch der A-Eintrag. Unter sync bleibt ein nicht besessener Eintrag nicht einfach als Waise liegen; er blockiert jeden weiteren Create auf demselben Namen, bis ein Mensch ihn entfernt. Das Löschen des AAAA-Eintrags ließ den nächsten Reconcile den A-Eintrag veröffentlichen, und der Endpoint war wieder da.

Drei Tage später derselbe Fix, und die Entscheidung, tatsächlich nachzuschauen

Derselbe Endpoint, dieselben schnellen Fehler, derselbe fehlende A-Eintrag. Das Löschen des verwaisten AAAA-Eintrags behob es erneut. Diesmal zog ich CloudTrail für die Hosted Zone, bevor ich irgendetwas schloss, denn ein Fix, der sich nach einem eigenen Zeitplan wiederholt, ist ein Zeichen dafür, dass die Automatisierung das Problem selbst neu erzeugt, nicht dass jemand den Eintrag von Hand kaputt macht.

CloudTrail zeigte, wie external-dns den A-Eintrag alle zehn Minuten per UPSERT setzte, bis zu einem einzelnen DELETE auf dem AAAA-Eintrag, gefolgt von Stille. Kein einmaliger Fehler, sondern eine Schleife, die eine Weile sauber lief und sich dann selbst aussperrte. Die Frage war nicht mehr “was hat den A-Eintrag gelöscht”, sondern “warum erzeugt external-dns ständig einen Eintrag, den es nie behalten kann”.

Der Eintrag, der sich niemals selbst gehören kann

Der Endpoint sitzt an der Spitze seiner eigenen delegierten Hosted Zone: eine eigene Zone, nicht nur ein Name innerhalb einer größeren, wobei die Parent-Zone in einem komplett anderen AWS-Account liegt, dieselbe Art von Account-Grenze, die Peering günstiger macht als ein geteilter Transit-Hub, sobald man Cross-Account-Traffic statt DNS-Writes bepreist. Genau diese Trennung bricht das Ownership-Modell.

Der AWS-Provider von external-dns entfaltet ein Ingress zu einem Dual-Stack-Ziel: einem A- und einem AAAA-Eintrag, selbst wenn nur der A-Eintrag je zu etwas auflösen wird. Die TXT-Registry unterscheidet Ownership pro Eintragstyp mit einem typ-präfixierten TXT-Namen: für den AAAA-Eintrag auf checkout.example.com ist das ein TXT-Eintrag, der buchstäblich aaaa-checkout.example.com heißt. Bei einer gewöhnlichen Subdomain landet dieser Eintrag in derselben Zone wie alles andere. An der Zonen-Spitze ist er ein Sibling des Apex-Namens selbst, was ihn eine Ebene höher schiebt, in die Parent-Zone example.com, auf die external-dns keinen Zugriff hat. Die Logs sagen es klar:

Skipping record aaaa-checkout.example.com because no hosted zone matching record DNS Name was detected

external-dns kann den AAAA-Eintrag erstellen, aber es kann niemals den Ownership-TXT-Eintrag schreiben, der ihm erlauben würde, den Eintrag als seinen eigenen zu beanspruchen. Beim allernächsten Reconcile liest es seinen eigenen AAAA-Eintrag ohne passenden Owner zurück, genau die Situation “nicht besessener Eintrag blockiert den Create” aus dem ersten Ausfall, nur dass der nicht besessene Eintrag diesmal einer ist, den external-dns selbst geschrieben hat. Das löst dieselbe Sperre aus, die das gesamte Changeset ablehnt, den A-Eintrag eingeschlossen. Der Eintrag, der den Endpoint am Leben hält, wird von einem Eintrag als Geisel genommen, den niemand je besitzen kann, in einer Zone, in die niemand je schreiben kann.

Das ist keine Eigenheit einer einzelnen Zone. Es ist eine allgemeine Eigenschaft der typ-präfixierten TXT-Registry: Sie kann keinen Eintrag an der Spitze einer Zone besitzen, deren Parent sie nicht kontrolliert. Nur das ältere TXT-Format ohne Präfix (also ohne a-/aaaa-/cname-) landet innerhalb der Zone selbst, weil es kein Sibling des Apex ist, sondern schlicht der Apex-Name mit einem anderen Eintragstyp. Eine Subdomain ein paar Ebenen tiefer trifft das nie, weil jeder Ownership-TXT-Eintrag, den sie braucht, in derselben Zone wie der Eintrag selbst liegt.

Der Fix, der tatsächlich hält

Die dauerhafte Version fasst die Zone gar nicht an; sie sorgt dafür, dass external-dns von vornherein keinen AAAA-Eintrag mehr will:

# external-dns chart values
managedRecordTypes:
  - A
  - CNAME

Das rendert zu --managed-record-types=A --managed-record-types=CNAME und ersetzt das Standard-[A, AAAA, CNAME]. Ohne AAAA im gewünschten Zustand gibt es keinen Apex-Ownership-TXT-Eintrag mehr, den man nicht schreiben kann, und nichts mehr, worauf man sich verklemmen könnte. Das ist hier speziell sicher, weil jedes Backend hinter dieser Zone ein reiner IPv4-Load-Balancer ist, AAAA hat also nie Traffic bedient, sondern nur das kaputt gemacht, was funktionierte.

Zwei Fragen waren es wert, vor dem Vertrauen in diesen Fix beantwortet zu werden, nicht nur vermutet:

Bricht das die TXT-Registry für alles andere? Nein. managedRecordTypes filtert, welche Eintragstypen aus Kubernetes-Quellen veröffentlicht werden; es sagt nichts über die eigene Buchführung der Registry. TXT-Einträge standen von Anfang an nicht in dieser Liste (der Standard enthält TXT ebenfalls nicht), und die Registry funktioniert für die verbleibenden A- und CNAME-Einträge genau wie zuvor weiter.

Braucht external-dns überhaupt TXT-Ownership? Nur unter --registry=txt. Die Alternative, die diese ganze Problemklasse vermeidet, ist eine DynamoDB-gestützte Registry, die Ownership out-of-band statt als Sibling-Einträge in der Zone speichert. Das ist eine clusterweite Änderung mit einem größeren Blast Radius als ein einzelner Chart-Wert, also blieb sie eine vermerkte Option statt einer sofort umgesetzten.

Was man daraus mitnimmt

Der erste Fix war richtig und trotzdem falsch. Den verwaisten Eintrag zu löschen war beide Male der richtige Schritt, und er würde niemals der letzte gewesen sein, weil er die Wirkung behandelte und die Ursache übrig ließ, die bei jedem Reconcile eine frische Wirkung erzeugte. Das verräterische Zeichen stand nicht in der Fehlermeldung; es lag darin, dass dieselbe Fünf-Minuten-Aktion aus demselben Grund zweimal nötig war. Ein Bug, der von selbst wiederkehrt, ohne dass jemand dazwischen am System gearbeitet hat, bedeutet, dass etwas ihn in einer Schleife produziert, und CloudTrail ist das, was aus “es ist wieder kaputt” ein “hier ist die Schleife” macht. Es ist dieselbe Art von Falle wie ein Helm-Release, das Erfolg meldet, während die laufende Config veraltet bleibt: Das Tooling lügt nicht, es berichtet nur von einer Ebene oberhalb der, die tatsächlich kaputtging.

Es ist auch eine Erinnerung daran, dass Ownership-Systeme, die auf Konventionen aufbauen, ein TXT-Eintrag, der etwas bedeutet, weil er an einer bestimmten Stelle sitzt und sein Name ein bestimmtes Präfix trägt, jede Grenze der darunterliegenden Namenshierarchie erben. An der delegierten Zone war nichts falsch konfiguriert. Die Zone funktionierte genau so, wie delegierte Zonen funktionieren sollen; es war die Annahme, dass der Owner jedes Eintrags immer direkt daneben leben kann, die den Kontakt mit einem Apex nicht überlebte. Kubernetes liefert mehr solcher konventionsbasierter Defaults aus, als den meisten bewusst ist, auf dieselbe Art, wie enableServiceLinks stillschweigend Umgebungsvariablen injiziert, die niemand verlangt hat, bis an dem Tag, an dem es mit etwas Echtem kollidiert.