Why NextWhy Next
Zur Übersicht
💻 Entwicklung13 Min. Lesezeit

Wenn ein Bucket zwei Besitzer hat, gewinnt der, der zuletzt apply ausführt

Ich fügte eine einzige S3-lifecycle-Regel hinzu, und terraform apply schlug fehl. Die Ursache: eine Struktur, in der zwei Ressourcen jeweils die lifecycle-Konfiguration desselben Buckets besitzen. S3 lifecycle arbeitet nicht auf Regel-, sondern auf Dokumentebene - der letzte Gewinner löscht die Regeln der Gegenseite stillschweigend.

Das Wichtigste

Um Upload-Objekte zurückzuholen, die nur presigned, aber nie committed wurden, entwarf ich einen tmp/-Prefix mit einer lifecycle-Ablaufregel. Nach dem Merge der Regel schlug der staging apply fehl. Die Ursache: Ein Modul und die Umgebungskonfiguration besaßen jeweils die lifecycle desselben physischen Buckets. Die lifecycle-API von S3 ersetzt das Dokument als Ganzes, also überschreiben zwei Besitzer bei jedem apply gegenseitig ihre Regeln. Mit einem removed-Block (destroy = false) ließ ich terraform die Modul-Ressource nur aus dem state vergessen und machte so einen einzigen Besitzer daraus. Eine Ressource auf Dokumentebene darf nur einen Besitzer haben.

Auf dieser Seite

Am Anfang stand ein ganz gewöhnliches Aufräumproblem. Der Pfad, über den Nutzer Mediendateien (Aufnahmen, Bilder) hochladen, läuft über presigned URLs. Der Server stellt eine Upload-URL aus, der Client lädt direkt nach S3 hoch und ruft anschließend die commit-API auf, um zu melden: "Bitte registriere diesen Key als offizielles Asset." Das Problem sind die Fälle, in denen nur presign passiert und kein commit folgt. Stirbt die App zwischendurch, bricht das Netz ab oder verlässt der Nutzer den Bildschirm, bleibt im Bucket ein Objekt zurück, das nirgendwo registriert ist.

Ich wollte das mit einer lifecycle-Regel wegräumen, konnte aber keine setzen. Committete und nicht committete Objekte lagen gemischt im selben Prefix, sodass eine Regel "lösche alles Alte" auch die offiziellen Assets gelöscht hätte. Ein tägliches Upload-Kontingent bremste zwar das Wachstum, aber die Tatsache, dass es keinen Rückholpfad gab, blieb bestehen.

Design: Was nicht committed ist, lebt in tmp

Das Gerüst der Lösung ist die Trennung der Key-Namespaces.

  1. presign stellt einen temporären Key unter dem Prefix tmp/ aus.
  2. Besteht commit die Prüfungen (Existenzcheck per HEAD, Content-Type, Größenlimit), befördert es das Objekt per CopyObject auf den endgültigen Key und löscht das tmp-Original.
  3. Objekte, für die nie ein commit kommt, bleiben in tmp/, und eine lifecycle-Regel lässt sie nach 7 Tagen ablaufen.

Damit muss die lifecycle nur noch auf tmp/ schauen. Offizielle Assets liegen von Anfang an außerhalb der Reichweite der Regel. Die Clients mussten nicht angefasst werden. Ich habe alle drei Upload-Flows gelesen und geprüft: Alle verwenden für Folgeaufrufe den Key aus der commit-Antwort, sodass sie nichts davon merken, wenn der Server die Key-Form ändert. Auch commits für Keys im alten Format, die zum Deploy-Zeitpunkt bereits unterwegs waren, laufen über den bestehenden Pfad weiter.

Hier lauert eine Falle. Dieser Bucket hat Versionierung aktiviert. In einem versionierten Bucket löscht expiration das Objekt nicht, sondern legt nur einen Löschmarker darauf, und die ursprünglichen Bytes bleiben als noncurrent-Version erhalten. Ohne ein gepaartes noncurrent_version_expiration (1 Tag) schrumpft der Speicher trotz Aufräumens um kein einziges Byte.

Auch das Design-Review hat sich gelohnt. Ich hängte ein Review an die Beförderungslogik, und drei bestätigte Defekte kamen heraus. Ein Loch, bei dem über den Custom-Key-Pfad für Administratoren ein tmp/-Key als endgültiger Key committed werden konnte, sodass die lifecycle ein Live-Asset gelöscht hätte (tmp-Endkeys werden jetzt an der gemeinsamen commit-Grenze blockiert). Das Problem, dass der sourceKey der Beförderung zu einem beliebigen Copy-Primitiv wurde (die Gleichung von tmp-Key und endgültigem Key wird jetzt erzwungen). Und ein Idempotenzproblem, bei dem beim gleichzeitigen commit desselben tmp-Keys die copy einer Seite mit 404 stirbt. Alle drei wurden im Code behoben, bevor es weiterging.

Und dann schlug apply fehl

Ich fügte die lifecycle-Regel per terraform hinzu und mergte. Der staging apply schlug fehl.

Der Fehler war ein Timeout beim Schreiben der lifecycle-Konfiguration, aber beim Graben zeigte sich: Das Timeout war nur das Symptom, das Problem war die Struktur. Zwei terraform-Ressourcen besaßen jeweils die lifecycle dieses Buckets. Eine steckte im Modul für die Bildskalierung (das Modul setzte die Regeln für seinen eigenen Zweck direkt), die andere lag in der umgebungsspezifischen Bucket-Konfiguration (dort, wo ich diesmal die tmp-Regel hinzugefügt hatte). Derselbe physische Bucket, zwei Besitzer.

Die lifecycle-API von S3 kennt kein "füge eine Regel hinzu". PutBucketLifecycleConfiguration ersetzt das gesamte Dokument. terraforms aws_s3_bucket_lifecycle_configuration steht auf genau dieser API, also überschreibt diese Ressource das komplette lifecycle-Dokument des Buckets mit den Regeln, die sie kennt. Bei zwei Besitzern schiebt jeder ein Dokument hinein, das nur seine eigenen Regeln enthält. Treffen beide im selben apply aufeinander, gibt es durch gleichzeitige Schreibzugriffe ein Timeout; treffen sie getrennt aufeinander, ist es schlimmer: Der apply gelingt ohne Fehler, und der letzte Gewinner löscht die Regeln des anderen.

Das ist der frostigste Teil dieses Vorfalls. Das Timeout war zum Glück ein lauter Fehlschlag. Hätte diese Struktur weitergelebt, hätte der nächste beliebige apply, der das Bildskalierungs-Modul anfasst, die gerade eingefügte tmp-Ablaufregel stillschweigend gelöscht. Im plan wäre das nur als eine einzige Zeile in-place update dieser Bucket-Ressource sichtbar gewesen, und die nicht committeten Objekte hätten wieder angefangen, sich für immer anzusammeln. Ein Monitoring, das das Verschwinden der Aufräumregel bemerkt hätte, gab es nirgends.

Tatsächlich war genau dieser Punkt im Code-Review schon einmal angemerkt worden, mit dem Verdacht "die zwei Ressourcen scheinen denselben Bucket anzufassen". Damals stuften wir es als latentes Risiko ein; als der apply direkt nach dem Merge fehlschlug, dauerte es keine paar Minuten, bis aus latent real wurde.

Die Lösung: ein einziger Besitzer

Die Richtung der Korrektur war klar. Pro Bucket darf es nur einen lifecycle-Besitzer geben. Ich entfernte die lifecycle-Ressource aus dem Modul, machte die umgebungsspezifische Konfigurationsdatei zum alleinigen Besitzer und zog auch die Regeln, die das Modul gesetzt hatte, dorthin um.

Das Werkzeug dafür war terraforms removed-Block. Löscht man die Ressourcendeklaration einfach aus dem Modul, will terraform die reale lifecycle-Konfiguration destroyen. Es entstünde ein Fenster, in dem die live laufenden Regeln gelöscht und danach neu angelegt werden. Gibt man dem removed-Block destroy = false, vergisst terraform die Ressource nur im state. Das Reale bleibt stehen, nur das Buch wird bereinigt - so wandert die Besitzerschaft unterbrechungsfrei zur Umgebungskonfiguration.

Nach der Korrektur ging der staging apply durch, und nachdem auch prod nachgezogen war, prüfte ich den tatsächlichen Bucket-Zustand direkt über die AWS-API. In beiden Buckets lebte die tmp-Ablaufregel, und die drei bestehenden Regeln waren intakt. Warum ich nicht bei terraform plan aufgehört habe, hat mir dieser Vorfall selbst beigebracht: plan ist nur das Buch des jeweiligen Besitzers, und in einer Struktur, die Dokumente überschreibt, wissen die zwei Bücher nichts voneinander.

Was bleibt

Eine Ressource auf Dokumentebene darf nur einen Besitzer haben. Das gilt nicht nur für S3 lifecycle. Bucket-Policies, CORS-Konfigurationen, Notification-Konfigurationen: Alle terraform-Ressourcen, die auf einer "alles ersetzen"-API stehen, haben dieselbe Eigenschaft. Stehen zwei Deklarationen für dasselbe Ziel, eine im Modul und eine in der Umgebungskonfiguration, bekommt man keinen Compile-Fehler, sondern ein stilles Überschreiben zur Laufzeit. Bevor man eine neue Regel hinzufügt, muss man zuerst suchen, ob dieses Dokument schon einen Besitzer hat.

Noch etwas. Eine Ressourcendeklaration zu löschen und das Reale zu löschen sind zwei verschiedene Dinge, und terraform drückt diesen Unterschied mit dem removed-Block aus. Wenn das Ziel die Übertragung der Besitzerschaft ist, zerbricht die Unterbrechungsfreiheit in dem Moment, in dem ein destroy dazwischenkommt.

Und zuletzt: Ein Rückholpfad beginnt beim Key-Design. Wenn Daten mit unterschiedlicher Lebensdauer, wie "committed und nicht committed", im selben Namespace leben, lässt sich keine Aufräumregel sicher setzen. Unterschiedliche Lebensdauer heißt: zuerst die Wohnorte trennen. Zur Einordnung: Dieser Ansatz holt die in der Vergangenheit bereits angesammelten, nicht committeten Objekte nicht rückwirkend zurück. Diese Objekte lassen sich von offiziellen Assets nicht unterscheiden (genau das war der Ausgangspunkt des Problems), also habe ich bewusst den Geltungsbereich "ab neuen Uploads" gewählt.

Häufige Fragen

Meine S3-lifecycle-Regeln verschwinden nach terraform apply. Woran liegt das?

aws_s3_bucket_lifecycle_configuration ist eine Ressource, die das gesamte lifecycle-Konfigurationsdokument eines Buckets besitzt. Existiert diese Ressource für denselben Bucket an zwei Stellen (z. B. in einem Modul und in der Umgebungskonfiguration), ersetzt jede das gesamte Dokument nur mit den Regeln, die sie kennt - die zuletzt applyte Seite löscht also die Regeln der anderen. Stehen beide im selben apply, kann es durch gleichzeitige Schreibzugriffe auch zu Timeouts kommen. Konsolidieren Sie auf eine einzige besitzende Ressource pro Bucket.

Ich habe in einem versionierten S3-Bucket eine lifecycle-Ablaufregel gesetzt, aber der Speicherverbrauch sinkt nicht.

In einem versionierten Bucket löscht expiration das Objekt nicht, sondern legt nur einen Löschmarker an und macht die bisherige Version noncurrent. Ohne ein zusätzliches noncurrent_version_expiration werden die Bytes nie zurückgeholt. Ablaufregel und noncurrent-Versions-Ablauf müssen als Paar entworfen werden.

Ähnliche Beiträge

0 Kommentare