Es kommt immer etwas dazwischen
Eine gelöschte Maschine, die weiterläuft, und ein Snapshot, der nicht verschwinden will: zwei Geschichten, eine Ursache. Über Teilausfälle, Idempotency und Reconciliation in verteilten Systemen.
Zwei Geschichten über Bugs, die ich selbst gebaut habe. In der einen läuft eine Maschine weiter, die längst gelöscht sein sollte. In der anderen scheitert ein Auftrag stundenlang an einem Überbleibsel, das er selbst hinterlassen hat. Die Symptome haben nichts gemeinsam. Die Ursache schon: Code, der nur funktioniert, wenn alles so läuft wie geplant.
Diese Art Fehler ist älter als das Web.
Die Maschine, die es nicht geben sollte
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:8pLZ0r3nKqVxe2Tj9wMb1c...
Host key verification failed.
Wer öfter per SSH arbeitet, kennt diese eindringliche Warnung, und meistens ist sie harmlos: Der Server wurde neu installiert, der Schlüssel hat sich geändert, man räumt eine Zeile aus der known_hosts und macht weiter. Diesmal war sie nicht so einfach zu lösen.
Die Meldung kam bei einem System, das weder neu installiert wurde noch sich etwas geändert hat. Der Login per SSH wenige Minuten zuvor hat noch ohne Probleme funktioniert. Die Meldung war also äußerst ernst zu nehmen, da der Fingerabdruck hinter der Adresse wohl wirklich nicht zu der Maschine gehörte, die dort laufen sollte. Verbunden wird man über die IP-Adresse, und die zeigt eindeutig auf genau eine VM. Daher war auch mein erster, recht ungläubiger Impuls: »Das kann nicht sein«.
Aber natürlich wurde ich eines Besseren belehrt: Es war genau so. Nach kurzer Recherche kam heraus, dass sich unter derselben Adresse noch eine zweite VM meldete. Da sich beide Maschinen dieselbe Adresse teilten, antworteten unter einer IP zwei verschiedene Server, mal der eine, mal der andere. Genau diesen Wechsel meldet die SSH-Warnung von oben.
Was war passiert? Bei der zweiten VM handelte es sich nach weiterer Analyse um ein Artefakt, das nicht korrekt aufgeräumt wurde. Theoretisch hätte diese VM vor Wochen abgeräumt werden sollen. Ich konnte herausfinden, dass die VM sich genau im Löschvorgang befand, als ich vor Wochen ein Produktionsdeployment gemacht hatte und dabei etwas unsanft ein paar Container vorzeitig beenden musste. Etwas, das normalerweise nicht passieren sollte, aber in der Realität trotzdem vorkommt. Nicht immer durch menschlichen Eingriff: Systeme fallen aus, das Netzwerk bricht weg, die Gründe sind vielfältig. Ein Löschauftrag sollte deshalb in keinen korrupten Zustand kommen.
Was genau war das Problem? Das Löschen einer VM besteht aus mehreren Teilschritten, die in ganz verschiedenen Systemen passieren müssen: Datenbank, Billing, DNS, Hypervisor und mehr. Vereinfacht dargestellt sieht der Ablauf so aus:
func (s *Service) DeleteVM(ctx context.Context, vmID string) error {
vm, err := s.db.GetVM(ctx, vmID)
if err != nil {
return err
}
if err := s.ipam.Release(ctx, vm.IP); err != nil {
return err
}
if err := s.db.DeleteVM(ctx, vm.ID); err != nil {
return err
}
return s.hypervisor.Destroy(ctx, vm.HostID, vm.InstanceID)
}
Man könnte sagen, alles daran ist falsch. Zuerst wird die geteilte Ressource, die IP, freigegeben, während die Maschine, der sie zugewiesen ist, noch läuft. Dann wird aus der Datenbank der Verweis gelöscht, welche Maschine auf welchem Hypervisor zu dieser IP gehört. Stirbt ein Prozess genau in diesem Moment, nach dem Löschen aus der Datenbank und vor dem Löschen auf dem Hypervisor, dann bleibt eine verwaiste VM zurück.
Die zuständige Lösch-Aktion hat zwar einen Retry-Mechanismus, doch auch der kommt nicht mehr weiter: Auf die Frage nach der Maschine gibt es nur noch die Antwort »gibt es nicht«, weil der Eintrag, der Datenbank und Hypervisor verband, bereits gelöscht ist.
Das Klonen, das sich selbst im Weg stand
Die zweite Geschichte beginnt in den Logs. Dort stapelte sich dieselbe Zeile: »snapshot ‘clone-…’ already exists«. Es sollte eine VM mehrfach geklont werden, also mehrere Kopien ein und derselben VM erstellt werden. Eigentlich ein no-brainer.
Technisch funktioniert das so: Für jeden Klon legt die Plattform einen temporären Snapshot der Quell-VM an, kopiert davon und löscht ihn wieder. Der Name entsteht deterministisch aus der Auftrags-ID. Vereinfacht, und auch hier ist der Fehler echt:
func (s *Service) cloneFromSource(ctx context.Context, a Action) error {
snapName := "clone-" + a.ID
if err := s.hypervisor.CreateSnapshot(ctx, a.SourceID, snapName); err != nil {
return err
}
defer func() {
if err := s.hypervisor.DeleteSnapshot(ctx, a.SourceID, snapName); err != nil {
s.log.Warn("snapshot cleanup failed", "name", snapName, "err", err)
}
}()
if err := s.hypervisor.Clone(ctx, a.SourceID, snapName, a.TargetID); err != nil {
return err
}
return s.provision(ctx, a.TargetID)
}
Das sieht auf den ersten Blick vernünftig aus, das defer läuft schließlich in fast jedem Fehlerfall. Fast. Das defer ist die einzige Stelle, die den Snapshot wieder wegräumt, und dieses Aufräumen kann auf zwei Arten ausfallen: Das defer läuft gar nicht erst, oder es läuft, aber der DeleteSnapshot-Aufruf darin scheitert. Drei Wege führen in das eine oder das andere.
Der erste ist die Parallelität. Die Plattform stößt mehrere Klone derselben Quelle nebenläufig an, doch der Hypervisor lässt pro VM nur eine Operation gleichzeitig zu und sperrt die Quell-VM, solange ein Klon läuft. Die Aufträge stauen sich also an dieser Sperre, und das Aufräumen wartet womöglich minutenlang auf sie, bis sein DeleteSnapshot-Aufruf im Zweifel an ihr scheitert.
Der zweite ist ein Deployment. Beendet sich ein Worker, stirbt das Klonen beim Prozessende mitten im Aufruf. In einem toten Prozess wird kein defer mehr ausgeführt.
Der dritte ist schlicht ein vorübergehender Fehler, bei dem auch der Aufräum-Aufruf nicht durchkommt.
Welcher Weg hier zuerst zuschlug, weiß ich nicht sicher. Das Ergebnis ist jedes Mal dasselbe: Ein Snapshot bleibt auf der Quelle zurück.
Und ab diesem ersten Überbleibsel griff die eigentliche Falle, denn jeder neue Versuch desselben Auftrags leitete denselben Namen ab:
Versuch 1: Snapshot anlegen ✓ → Klonen ✗ (Quelle gesperrt) → Aufräumen ✗ (Quelle gesperrt)
Versuch 2: Snapshot anlegen ✗ "already exists"
Versuch 3: Snapshot anlegen ✗ "already exists"
…
Irgendwann gab das System dann auf. Der Snapshot blieb auf der Quelle liegen.
Exkurs: Warum fällt es uns so schwer, verteilte Systeme stabil zu bauen?
Ein zu naiv gedachtes Löschen und ein Klonen, das sich selbst im Weg steht: zwei Probleme, dieselbe Anatomie. Beide Abläufe bestehen aus mehreren Schritten über Systemgrenzen hinweg, und beide waren nur für den Fall gebaut, dass jeder Teilschritt zuverlässig gelingt. Wenn man den Code so seziert, wirken die Probleme dahinter fast offensichtlich. Wie kommt es dann trotzdem dazu?
Der Grund liegt zu einem guten Teil in uns selbst. Wir denken in Abläufen: erst dieser Schritt, dann der nächste, alles auf einer einzigen Zeitachse. Auf einem einzelnen Rechner trägt diese Vorstellung. Stürzt ein Programm ab, räumt das Betriebssystem den Speicher ab und die Datenbank macht ihre offene Transaktion rückgängig; danach ist alles wieder konsistent, als wäre nichts geschehen.
Ein verteiltes System gibt einem diese Sicherheit nicht. Ein Befehl wie »lösche diese VM« betrifft gleich mehrere Systeme: die Datenbank, den IP-Pool und den Hypervisor. Keine Transaktion klammert sie zusammen. Bricht er nach dem zweiten von drei Schritten ab, bleibt der erste wirksam und der dritte passiert nie. Es gibt nicht einmal eine gemeinsame Uhr, an der man die Schritte ausrichten könnte: Leslie Lamport hat schon 1978 in »Time, Clocks, and the Ordering of Events« gezeigt, dass es in einem verteilten System kein objektives »jetzt« gibt, nur einzelne Rechner mit eigener Uhr und eigener Sicht. Für eine Welt ohne gemeinsame Zeit und ohne Rückspulknopf ist unsere Intuition nicht gemacht.
Dazu kommt, dass dieser Teilausfall unsichtbar bleibt, bis er zuschlägt. Jim Waldo und seine Kollegen haben ihn 1994 in »A Note on Distributed Computing« zum eigentlichen Unterschied erklärt: Eine Komponente fällt aus, der Rest läuft weiter, und niemand weiß zuverlässig, wie weit der ausgefallene Teil gekommen ist. Wir schreiben und testen trotzdem fast immer nur den einen Pfad, auf dem alles gelingt. Aber jeder Schritt, der für sich scheitern kann, vervielfacht die Zahl der möglichen Zustände, und dieser Raum wächst schneller, als ein Mensch ihn im Kopf behalten kann. Schon der erste der berühmten acht Irrtümer der verteilten Datenverarbeitung bringt es auf den Punkt: »Das Netzwerk ist zuverlässig.« Ist es nicht. Auch der Aufruf nicht, der am Ende aufräumen soll.
Die gute Nachricht: Gegen all das gibt es eine Handvoll bewährter Muster. Keines ist neu, und drei davon genügen für unsere beiden Geschichten.
Idempotency. Eine Operation heißt idempotent, wenn ihr zweiter, dritter, n-ter Aufruf nichts Weiteres bewirkt als der erste. Damit wird ein Retry harmlos, selbst wenn der vorige Versuch mitten in der Arbeit abgebrochen ist: Der nächste Durchlauf setzt keinen sauber aufgeräumten Zustand voraus, sondern kommt mit den Resten des vorigen zurecht. Amazon hat dieses Vorgehen in »Making retries safe with idempotent APIs« zur Hausregel gemacht.
Reconciliation. Statt sich darauf zu verlassen, dass jedes einzelne Ereignis ankommt und verarbeitet wird, vergleicht eine Schleife regelmäßig den Soll- mit dem Ist-Zustand und korrigiert die Differenz, egal wie sie entstanden ist. Kubernetes ist beispielsweise um genau dieses Prinzip gebaut, seine Controller laufen endlos gegen den Zustand. Der Fachbegriff dafür ist level-triggered statt edge-triggered: auf den Zustand schauen, nicht auf das einzelne Signal.
Coordination. Wo mehrere Akteure dieselbe Ressource anfassen, muss eine Stelle entscheiden, wer wann darf. Die naheliegende Lösung ist ein eigener Lock-Dienst, doch der bringt eine ganze Klasse neuer Fehler mit. Martin Kleppmann hat in »How to do distributed locking« auseinandergenommen, woran solche Locks scheitern: an pausierten Prozessen, die nach Ablauf ihres Locks weiterschreiben, an fehlenden Fencing-Tokens, an Uhren, denen man nicht trauen darf. Robuster ist es oft, gar kein zweites System einzuführen, sondern durch die Datenbank zu koordinieren, der man ohnehin vertraut: Sie ordnet konkurrierende Schreibzugriffe zuverlässig, also kann ihr eigener Zustand die Rolle des Locks übernehmen.
Erfunden hat das alles längst jemand anderes. Anwenden muss man es trotzdem selbst.
Erst die Maschine, dann die Adresse
Die Reparatur dreht die Reihenfolge um und macht jeden Schritt idempotent:
func (s *Service) DeleteVM(ctx context.Context, vmID string) error {
vm, err := s.db.GetVM(ctx, vmID)
if errors.Is(err, ErrNotFound) {
return nil
}
if err != nil {
return err
}
if err := s.hypervisor.Destroy(ctx, vm.HostID, vm.InstanceID); err != nil {
if !errors.Is(err, hypervisor.ErrNotFound) {
return err
}
}
if err := s.ipam.Release(ctx, vm.IP); err != nil {
if !errors.Is(err, ipam.ErrAlreadyFree) {
return err
}
}
return s.db.DeleteVM(ctx, vm.ID)
}
Jetzt gibt das System die IP erst frei, wenn die Maschine, der sie zugewiesen war, wirklich weg ist. Bricht der Prozess irgendwo ab, bleibt der Eintrag stehen, und mit ihm die Information, was noch zu tun ist. Eine Wiederholung läuft sauber durch, weil Erledigtes als Erfolg zählt, nicht als Fehler.
Ein Problem bleibt: Wie viele solcher Artefakte gibt es schon, und wie fängt man die, die der reparierte Code immer noch übersieht? Die Antwort ist Reconciliation. Ich habe einen Abgleich gebaut, der den Soll-Zustand aus der Datenbank gegen den Ist-Zustand auf den Hypervisoren prüft und jede Abweichung meldet. Manche werden sofort behoben, manche brauchen einen Blick von Hand. So tauchen verwaiste Maschinen verlässlich auf, statt unbemerkt weiterzulaufen.
Löschen vor Anlegen
Der erste Fehler beim Klonen war, dass das Anlegen nicht idempotent war. Genau das wurde geändert. Liegt schon ein temporärer Snapshot eines früheren Versuchs herum? Kein Problem: Vor dem Anlegen räumt jeder Versuch erst auf:
func (s *Service) prepareSnapshot(ctx context.Context, a Action) error {
snapName := "clone-" + a.ID
existing, err := s.hypervisor.GetSnapshots(ctx, a.SourceID)
if err != nil {
return err
}
for _, snap := range existing {
if !strings.HasPrefix(snap.Name, "clone-") {
continue
}
if snap.Name == snapName {
if err := s.hypervisor.DeleteSnapshot(ctx, a.SourceID, snap.Name); err != nil {
return err
}
continue
}
s.cleanupOrphan(ctx, a.SourceID, snap.Name)
}
return s.hypervisor.CreateSnapshot(ctx, a.SourceID, snapName)
}
Der zweite Fehler war, dass der Lock-Mechanismus des Hypervisors ignoriert wurde. Von derselben Quelle kann nicht gleichzeitig geklont werden, also muss die Plattform die Aufträge selbst in eine Reihenfolge bringen und nacheinander abarbeiten. Das ist die Coordination von oben, und sie braucht keinen eigenen Lock-Dienst. Die Reihenfolge bestimmt die Auftrags-Tabelle selbst: Pro Quelle läuft immer nur der älteste aktive Klon, die übrigen warten, bis er fertig ist. Der Status eines Auftrags ist damit zugleich sein Lock.
Was bleibt
Keines dieser Probleme braucht eine Flotte aus Millionen Maschinen oder Aufträgen in der Queue. Die Tücken verteilter Systeme treffen früh, oft schon bei der ersten Handvoll Worker, und meist dann, wenn man am wenigsten damit rechnet.
Ein Allheilmittel gibt es nicht. Die bewährten Muster helfen, aber sie garantieren keine vollständige Sicherheit, dafür sind verteilte Systeme zu komplex. Was bleibt, ist Selbstdisziplin. Bei jeder mehrstufigen Operation vom schlechtesten Fall ausgehen. Der Prozess stirbt nach genau diesem Schritt. Zwei Aufträge treffen gleichzeitig auf dieselbe Ressource. Der Aufruf, der aufräumen soll, kommt nie an. Die Safeguards fangen auf, was man trotzdem übersieht.
Newsletter
Neue Beiträge per E-Mail.
Jederzeit abbestellbar.