Skip to content
Gleiches Tag, kein Deploy: warum cdk deploy mit unverändertem Image nichts tut, und das 300-Zeilen-Skript, das wir darum herum geschrieben haben
← ← Zurück zu Gedanken Cloud

Gleiches Tag, kein Deploy: warum cdk deploy mit unverändertem Image nichts tut, und das 300-Zeilen-Skript, das wir darum herum geschrieben haben

Zwei Geschichten über dieselbe Deploy-Pipeline, gemeinsam erzählt, weil die zweite wegen der ersten existiert.

Die erste ist ein Bug, der keiner ist: wir haben ein Container-Image neu gebaut, unter demselben Tag gepusht, cdk deploy ausgeführt, zugesehen, wie es no changes meldete, und eine Stunde damit verbracht zu verstehen, warum Produktion immer noch den alten Code ausführte. Die zweite ist das, was passierte, als unsere CI eine Weile nicht verfügbar war, aus Abrechnungsgründen außerhalb unserer Kontrolle, und wir einen Weg brauchten, von einem Laptop aus zu deployen, der sicherer war als der, mit dem wir einmal drei Services von einem Laptop gelöscht hatten.

Teil eins: CloudFormation vergleicht Strings, nicht Bytes

Der App-Runner-Service ist eine CloudFormation-Ressource, und das Image, das er ausführt, ist eine String-Eigenschaft: ImageIdentifier: 123456.dkr.ecr.us-east-1.amazonaws.com/api:abc1234. Wenn Sie cdk deploy ausführen, vergleicht CloudFormation das neue Template mit dem zuletzt angewendeten. Ist der String identisch, gibt es kein Diff, kein Update, und App Runner liefert weiter aus, was es zuletzt gezogen hat.

Das ist korrektes Verhalten, und es beißt in genau einer Situation: das Tag ist dasselbe, aber das Image dahinter nicht. Wir trafen es, als wir denselben Commit mit einem geänderten Build-Argument neu bauten. Gleicher Git-SHA, gleiches Tag api:abc1234, andere Bytes in ECR, und CloudFormation sah, korrekt, nichts zu tun. Das Tag der Registry zeigte jetzt auf einen neuen Digest; der Service lief noch mit dem alten; nirgends war etwas falsch außer unserer Erwartung.

Pinnen per Tag ECR-Tag api:abc1234sha256:1111 → Rebuild → sha256:2222 TemplateImageIdentifier: …/api:abc1234 kein Diffgleicher String wie zuvor App Runnerläuft weiter mit sha256:1111 Pinnen per Digest Deploy-Skriptlöst Tag → sha256:2222 auf TemplateImageIdentifier: …/api@sha256:2222 DiffDigest geändert App Runnerrollt sha256:2222 aus

Es gibt zwei ehrliche Lösungen. Die erste ist eine Regel: jeder Deploy bewegt den SHA. Wenn Sie ein anderes Image wollen, committen Sie etwas. Sie ist einfach, wir haben sie eine Weile befolgt, und sie scheitert in dem Moment, in dem jemand ein Image aus einem Grund neu bauen muss, der keine Codeänderung ist, etwa ein Sicherheitspatch des Basis-Images.

Die zweite ist das Pinnen per Digest. Nachdem das Image gepusht ist, fragt das Deploy-Skript ECR, worauf das Tag zeigt, und übergibt den Digest, nicht das Tag, ins Template:

digest=$(aws ecr describe-images --repository-name api --image-ids imageTag="$TAG" \
  --query 'imageDetails[0].imageDigest' --output text)
npx cdk deploy platform-prod --context apiImage="$REPO/api@$digest"
// im Stack
imageRepository: { imageIdentifier: this.node.tryGetContext('apiImage'), imageRepositoryType: 'ECR' }

Jetzt ändert sich das Template, wann immer sich die Bytes ändern, und nur dann. Ein Rebuild desselben Commits mit anderem Inhalt ist ein echter Deploy. Ein erneuter Lauf mit identischem Inhalt ist ein echtes No-op. CloudFormations Verhalten hat sich nicht geändert; wir haben ihm nur den String gegeben, der tatsächlich „das Image“ bedeutet.

Der Digest erscheint in der CloudFormation-Konsole und in cdk diff als unlesbarer 64-Zeichen-Hash. Wir geben Tag → Digest in der Deploy-Ausgabe aus, damit ein Mensch trotzdem erkennt, was hinausgeht.

Teil zwei: sicher vom Laptop deployen

CI war eine Weile weg. Die Plattform brauchte trotzdem Deploys. Wir hatten bereits teuer gelernt, dass cdk deploy von einer Entwicklermaschine mit falschem Kontext Produktionsressourcen löscht, also war „einfach lokal ausführen“ keine Option, die wir noch einmal akzeptieren würden. Die Alternative war ein Skript, dessen Aufgabe es ist, einen Laptop-Deploy wie einen CI-Deploy zu verhalten: gleiche Eingaben, gleiche Leitplanken, kein Weg, etwas auszuliefern, das nicht in Git ist.

Es wuchs auf etwa 300 Zeilen Bash. Hier ist, was diese Zeilen tun, der Reihe nach.

1 · Preflightschmutzig? falscher Branch? kein --yes? → exit 2 · Worktree @ origin/mainnie die Arbeitskopie 3 · git archive → S3genau die Bytes, die CodeBuild sieht 4 · 3 × CodeBuildparallel · ~3 Min. 5 · Tags → Digestsfür den Menschen ausgegeben 6 · cdk diff, bestätigenjede Löschung → anhalten und lesen 7 · cdk deployDigests als Kontext 8 · prüfen + meldenHealth · SHA · Bundle-grep Ein Laptop-Deploy und ein CI-Deploy führen dieselben Schritte auf denselben Bytes aus. Der Laptop hat nur einen Menschen bei Schritt 6.

Preflight. Das Skript verweigert die Ausführung, wenn der Arbeitsbaum uncommittete Änderungen hat, wenn --env prod ohne --yes angegeben ist, wenn der aktuelle Branch bei einem Prod-Deploy nicht main ist oder wenn die AWS-Identität, die es gleich verwendet, nicht die erwartete Deploy-Rolle für dieses Konto ist. Jede Verweigerung gibt einen Satz aus, der sagt, was zu tun ist. Etwa 40 Zeilen, und die wertvollsten 40 der Datei.

Worktree. Es erstellt einen losgelösten git worktree bei origin/main (nach einem git fetch) in einem temporären Verzeichnis und arbeitet von dort. Was auch immer in der Arbeitskopie des Entwicklers liegt, gestasht, halb editiert oder experimentell, kann nicht ausgeliefert werden. Der SHA dieses Worktrees ist der Release-Identifikator für alles Weitere.

Archiv nach S3. git archive des Worktrees, hochgeladen in einen Build-Bucket unter dem SHA. CodeBuild baut aus diesem Archiv, nicht aus einer GitHub-Verbindung, sodass die Build-Eingabe dieselben Bytes sind, ob von CI oder vom Skript ausgelöst.

Drei Builds parallel. Ein CodeBuild-Projekt pro Anwendung, gemeinsam gestartet, gemeinsam abgefragt. Drei Next.js-Images auf dem Docker eines Laptops zu bauen würde acht Minuten dauern und den Raum heizen; auf drei CodeBuild-Instanzen dauert es drei. Die Projekte sind dieselben, die CI nutzt.

Digests. Wie in Teil eins. Das Skript löst jedes Tag auf und gibt eine Tabelle App → Tag → Digest aus.

Diff und Bestätigung. cdk diff gegen die Zielumgebung, vollständig ausgegeben. Enthält das Diff eine Ressourcenentfernung, hebt das Skript sie hervor und verlangt vom Operator, den Stack-Namen einzutippen, um fortzufahren, nicht nur y. Das ist der Schritt, der den Vorfall verhindert hätte.

Deploy. cdk deploy mit den Digests als Kontext und --require-approval never, weil die Freigabe bereits im vorigen Schritt erfolgt ist, mit besseren Informationen, als CDKs eigener Prompt liefert.

Prüfen und melden. Die Health-Route abfragen, bis sie den neuen SHA meldet, das Client-Bundle nach demselben SHA greppen und eine Nachricht mit Umgebung, SHA, Name des Operators und Diff-Zusammenfassung in den Team-Kanal posten. Scheitert die Prüfung, sagt das Skript es laut und beendet sich mit einem Fehlercode; es rollt nicht automatisch zurück, denn eine gescheiterte Prüfung heißt meist „sieh es dir an“, nicht „mach es rückgängig“.

Wie es sich anfühlt

./scripts/deploy.sh --env staging
./scripts/deploy.sh --env prod --yes
./scripts/deploy.sh --env prod --dry-run     # synth + diff, kein Build, kein Deploy

Drei Flags. Alles andere entscheidet das Skript aus Git und aus dem Konto. Es gibt absichtlich keine Möglichkeit, ein eigenes Image, einen eigenen Branch oder einen eigenen Kontextwert zu übergeben.

Würden wir es noch einmal schreiben?

Ja, und wir würden es am ersten Tag schreiben statt nach dem Vorfall und dem CI-Ausfall. Nicht weil Laptop-Deploys gut wären, sondern weil das Skript die Pipeline ist. Als CI zurückkam, wurde ihr Workflow: auschecken, die OIDC-Rolle annehmen, ./scripts/deploy.sh --env prod --yes ausführen. Ein Codepfad, von beiden Seiten geübt, mit den Leitplanken im Code statt in einer YAML-Datei, die nur auf der Maschine eines anderen läuft.

Die 300 Zeilen sind lang für ein Shell-Skript. Sie ersetzten eine Runbook-Seite, auf der „sei vorsichtig“ stand, was kürzer ist und nicht funktioniert.

Wenn Ihr Deploy ein cdk deploy ist, das jemand von dort ausführt, wo er gerade ist, helfen wir Ihnen, ihn langweilig zu machen.