# 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](/de/blog/cloudformation-deleted-our-app-runner-services).

## 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.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zwei Zeilen. Oben, Pinnen per Tag: das ECR-Tag api:abc1234 wird durch einen Rebuild von Digest sha256:1111 auf sha256:2222 verschoben; das Template sagt weiterhin api:abc1234, CloudFormation sieht kein Diff, App Runner läuft weiter mit sha256:1111. Unten, Pinnen per Digest: das Deploy-Skript löst das Tag zu sha256:2222 auf und setzt den Digest ins Template; CloudFormation sieht ein Diff und App Runner rollt sha256:2222 aus.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#ff6b8a" font-size="14" font-weight="700">Pinnen per Tag</text>
<rect x="20" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="62" text-anchor="middle" fill="#f1f3ff">ECR-Tag api:abc1234</text><text x="120" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">sha256:1111 → Rebuild → sha256:2222</text>
<rect x="260" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="62" text-anchor="middle" fill="#f1f3ff">Template</text><text x="360" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api:abc1234</text>
<rect x="500" y="40" width="170" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="585" y="62" text-anchor="middle" fill="#ff6b8a" font-weight="700">kein Diff</text><text x="585" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">gleicher String wie zuvor</text>
<rect x="710" y="40" width="175" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="797" y="62" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="82" text-anchor="middle" fill="#ff6b8a" font-size="11">läuft weiter mit sha256:1111</text>
<text x="20" y="136" fill="#4fffb0" font-size="14" font-weight="700">Pinnen per Digest</text>
<rect x="20" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="172" text-anchor="middle" fill="#f1f3ff">Deploy-Skript</text><text x="120" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">löst Tag → sha256:2222 auf</text>
<rect x="260" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="172" text-anchor="middle" fill="#f1f3ff">Template</text><text x="360" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api@sha256:2222</text>
<rect x="500" y="150" width="170" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="172" text-anchor="middle" fill="#4fffb0" font-weight="700">Diff</text><text x="585" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">Digest geändert</text>
<rect x="710" y="150" width="175" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="797" y="172" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="192" text-anchor="middle" fill="#4fffb0" font-size="11">rollt sha256:2222 aus</text>
</g>
</svg>
</div>

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:

```bash
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"
```

```ts
// 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.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline des Deploy-Skripts in acht Schritten: 1 Preflight verweigert einen schmutzigen Baum, den falschen Branch oder fehlendes --yes; 2 ein Git-Worktree wird bei origin/main ausgecheckt; 3 git archive dieses Baums wird nach S3 hochgeladen; 4 drei CodeBuild-Jobs bauen die Images parallel aus dem Archiv; 5 Tags werden zu Digests aufgelöst; 6 cdk diff wird angezeigt und muss bestätigt werden; 7 cdk deploy läuft mit den Digests als Kontext; 8 Nach-Deploy-Prüfung von Health, Release-SHA und Client-Bundle, dann eine Nachricht in den Team-Kanal.">
<defs><marker id="arrDs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="15" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="115" y="52" text-anchor="middle" fill="#ff6b8a" font-weight="700">1 · Preflight</text><text x="115" y="70" text-anchor="middle" fill="#9aa3c7">schmutzig? falscher Branch? kein --yes? → exit</text>
<line x1="217" y1="60" x2="240" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="343" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">2 · Worktree @ origin/main</text><text x="343" y="70" text-anchor="middle" fill="#9aa3c7">nie die Arbeitskopie</text>
<line x1="445" y1="60" x2="468" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="571" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">3 · git archive → S3</text><text x="571" y="70" text-anchor="middle" fill="#9aa3c7">genau die Bytes, die CodeBuild sieht</text>
<line x1="673" y1="60" x2="696" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="30" width="185" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">4 · 3 × CodeBuild</text><text x="791" y="70" text-anchor="middle" fill="#9aa3c7">parallel · ~3 Min.</text>
<path d="M791,92 L791,120 L115,120 L115,148" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="15" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">5 · Tags → Digests</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7">für den Menschen ausgegeben</text>
<line x1="217" y1="180" x2="240" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="343" y="172" text-anchor="middle" fill="#ffd166" font-weight="700">6 · cdk diff, bestätigen</text><text x="343" y="190" text-anchor="middle" fill="#9aa3c7">jede Löschung → anhalten und lesen</text>
<line x1="445" y1="180" x2="468" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">7 · cdk deploy</text><text x="571" y="190" text-anchor="middle" fill="#9aa3c7">Digests als Kontext</text>
<line x1="673" y1="180" x2="696" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="150" width="185" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="791" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">8 · prüfen + melden</text><text x="791" y="190" text-anchor="middle" fill="#9aa3c7">Health · SHA · Bundle-grep</text>
<text x="450" y="238" text-anchor="middle" fill="#9aa3c7">Ein Laptop-Deploy und ein CI-Deploy führen dieselben Schritte auf denselben Bytes aus. Der Laptop hat nur einen Menschen bei Schritt 6.</text>
</g>
</svg>
</div>

**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](/de/blog/nextjs-build-time-vs-runtime-config-on-containers) 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

```bash
./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](/contact).
