cdk deploy scheiterte mit einer Meldung, die wir noch nie gesehen hatten: Template format error: Number of resources, 503, is greater than maximum allowed, 500. Nichts im Diff war groß. Wir hatten eine Lambda-Funktion mit Log-Gruppe und Rolle hinzugefügt, und das reichte, um einen Stack, den wir seit acht Monaten aufbauten, über eine Decke zu schieben, von der wir nichts wussten.
Das Limit ist real, gilt pro Stack und lässt sich nicht per Quota-Erhöhung verhandeln. Dieser Artikel beschreibt, wie wir herausfanden, was im Stack steckte, wo wir geschnitten haben und wie wir eine Datenbank, drei DynamoDB-Tabellen und einen KMS-Schlüssel in einen neuen Stack verschoben haben, ohne eines davon zu löschen.
Wie man unbemerkt auf 500 kommt
Unser CDK-Code deklarierte vielleicht 60 Dinge: ein VPC, einen Aurora-Cluster, drei App-Runner-Services, ein Dutzend Lambdas, ein paar DynamoDB-Tabellen, eine CloudFront-Distribution, eine WAF, einige Secrets. CloudFormation sah 503. Die Differenz ist alles, was CDK für Sie generiert:
npx cdk synth platform-prod --quiet
grep -h '"Type": "AWS::' cdk.out/platform-prod.template.json | sort | uniq -c | sort -rn | head
| Ressourcentyp | Anzahl | Woher er kommt |
|---|---|---|
AWS::IAM::Policy |
71 | jeder grant*()-Aufruf, eine Policy pro Rolle pro Berechtigungsgruppe |
AWS::IAM::Role |
48 | eine pro Lambda, pro App-Runner-Service, pro Custom Resource |
AWS::Lambda::Function |
31 | unsere 12, plus CDKs Custom-Resource-Provider für Log-Aufbewahrung, Bucket-Deployment, Secret-Rotation des Clusters |
AWS::Logs::LogGroup |
29 | eine pro Funktion, explizit, weil wir die Aufbewahrung setzen |
AWS::EC2::* |
58 | das VPC: Subnetze, Routing-Tabellen, Routen, Zuordnungen, NAT, Endpunkte, Security Groups, Ingress-Regeln |
AWS::Lambda::Permission |
24 | jede Event-Quelle und jede API-Gateway-Route |
Custom::* |
19 | Log-Aufbewahrung, S3-Deployments, Passwort-Rotation des Clusters |
| Alles andere | 223 |
Ein VPC mit drei Availability Zones sind allein über fünfzig Ressourcen. Jede Lambda ist eine Funktion, eine Rolle, ein bis drei Policies, eine Log-Gruppe, eine Custom Resource für die Aufbewahrung und eine Permission: sieben Ressourcen pro Funktion, die Sie für eine gehalten haben. IAM allein war ein Viertel des Stacks. Nichts davon ist Verschwendung; es ist die richtige Menge Infrastruktur. Nur weit mehr als die Zählung im Kopf.
Wo man schneidet
Die Bedingung, die die Aufteilung entscheidet, ist nicht die Anzahl, sondern der Explosionsradius und die Änderungsrate. Dinge, die sich bei jedem Deploy ändern (Lambdas, App-Runner-Image-Tags), sollten keinen Stack mit Dingen teilen, die niemals versehentlich berührt werden dürfen (die Datenbank). Wir hatten das beim Aufbau der drei Konten als Prinzip aufgeschrieben und dann trotzdem alles in einen Stack gepackt, weil ein Stack einfacher ist, bis er es nicht mehr ist.
Die Grenze, die wir gezogen haben, in Deploy-Reihenfolge:
| Stack | Inhalt | Ressourcen | Änderungen |
|---|---|---|---|
network |
VPC, Subnetze, NAT, Endpunkte, Basis-Security-Groups | ~70 | fast nie |
data |
Aurora-Cluster, DynamoDB-Tabellen, KMS-Schlüssel, Secrets, Backup-Vault | ~60 | selten, und vorsichtig |
app |
Lambdas, App-Runner-Services, Queues, Event-Regeln, Rollen | ~300 | bei jedem Deploy |
edge |
CloudFront, WAF, Zertifikate, Route-53-Einträge | ~50 | monatlich |
Vier Stacks statt einem, der größte bei 300 mit Luft nach oben. Der data-Stack bekommt termination protection und die strengere Deploy-Rolle; der app-Stack ist der, den CI täglich anfasst.
Nested Stacks haben wir erwogen und verworfen. Ein CDK-NestedStack hebt das 500er-Limit auf (jeder Nested Stack hat sein eigenes), aber ein Nested Stack wird als Teil seines Eltern-Stacks ausgerollt, sodass der Explosionsradius überhaupt nicht schrumpft: eine schlechte Änderung an einer Lambda führt trotzdem ein Change Set aus, das die Datenbank enthält. Getrennte Stacks waren der Punkt.
Zustandslose Ressourcen verschieben: einfach verschieben
Lambdas, Rollen, Event-Regeln, App-Runner-Services ohne Zustand: das Construct aus einer Datei ausschneiden, in die andere einfügen, beide deployen. CloudFormation löscht die Ressource aus dem alten Stack und erstellt sie im neuen. Zwei Dinge vorher prüfen:
- Physische Namen. Eine Lambda mit explizitem
functionNamekann nicht zweimal existieren, also muss das Löschen vor dem Erstellen passieren, was bedeutet, zuerst den alten Stack zu deployen. Ressourcen ohne explizite Namen bekommen einen neuen generierten Namen und können kurz koexistieren. Wir haben explizite Namen bei allem entfernt, was keine brauchte, und das war das meiste. - Dinge, die von außen per ARN auf die Ressource zeigen. Der App-Runner-Service hatte eine Custom Domain, also hätte eine Neuerstellung einen neuen
*.awsapprunner.com-Hostnamen, eine DNS-Änderung und eine Zertifikatsvalidierung bedeutet. Wir haben alle drei App-Runner-Services imapp-Stack an Ort und Stelle gelassen, wo sie ohnehin hingehörten.
Die Lambdas bekamen neue ARNs. Nichts außerhalb des Stacks referenzierte sie per ARN außer einer EventBridge-Regel im selben Stack, also merkte niemand etwas.
Zustandsbehaftete Ressourcen verschieben: der Import in vier Schritten
Die Datenbank, die Tabellen und der KMS-Schlüssel lassen sich nicht neu erstellen. Sie mussten den Stack wechseln und dabei genau dort bleiben, wo sie waren. CloudFormation unterstützt das über Resource Import, und CDK verpackt es als cdk import. Die Abfolge, pro Ressource:
Schritt eins ist der, den Leute überspringen. Ohne zuerst deploytes RemovalPolicy.RETAIN löscht das Entfernen des Constructs in Schritt zwei die Ressource, und für einen Aurora-Cluster ist das bestenfalls ein finaler Snapshot. Wir hatten in dem Jahr bereits einen Löschvorfall hinter uns und kein Interesse an einem zweiten, also machten wir Schritt eins, deployten und prüften in der Konsole, dass DeletionPolicy: Retain auf der Ressource im Template stand, bevor wir irgendetwas anderes taten.
Schritt drei verlangt, dass das Construct im neuen Stack genau die Eigenschaften der bestehenden Ressource erzeugt. Für DynamoDB sind das Tabellenname, Key-Schema und Billing Mode; für Aurora Cluster-Identifier, Engine und ein paar mehr; für KMS die Key-ID. cdk import fragt nach den Identifikatoren, die es nicht ableiten kann, und führt dann ein Import-Change-Set aus. Passt eine Eigenschaft nicht, scheitert der Import sauber und nichts wird geändert, was die gute Art von Scheitern ist.
Schritt vier ist der Beweis. cdk diff auf dem neuen Stack nach dem Import sollte leer sein. Unserer war es beim ersten Mal nicht, beim Aurora-Cluster: wir hatten im neuen Stack deletionProtection: true deklariert, und der echte Cluster hatte es aus, weil der alte Stack es nie gesetzt hatte. Das Diff zeigte es, wir deployten es, und der Cluster war am Ende besser geschützt als zuvor.
Cross-Stack-Referenzen und die Falle darin
Sobald Ressourcen in verschiedenen Stacks leben, braucht der app-Stack das VPC aus network und die Tabellennamen aus data. CDKs Standard ist, das Objekt hinüberzureichen und ein CloudFormation-Export/Import-Paar zu generieren. Es funktioniert, und dann sperrt es Sie ein: ein exportierter Wert kann sich nicht ändern, solange ein anderer Stack ihn importiert, sodass eine VPC-Änderung, die eine exportierte Subnetz-ID verändert, scheitert, bis Sie jeden Konsumenten entfernt haben. Das traf uns an Tag zwei.
Wir sind für alles, was eine Stack-Grenze überquert, auf SSM-Parameter umgestiegen:
// data-Stack
new ssm.StringParameter(this, 'OrdersTableName', {
parameterName: `/platform/${env}/orders-table-name`,
stringValue: ordersTable.tableName,
});
// app-Stack
const ordersTableName = ssm.StringParameter.valueForStringParameter(this, `/platform/${env}/orders-table-name`);
const ordersTable = dynamodb.Table.fromTableName(this, 'OrdersTable', ordersTableName);
Der Konsument löst den Parameter zur Deploy-Zeit auf; es gibt keinen Export, also ist nichts gesperrt. Der Preis ist, dass CDK die Abhängigkeit nicht mehr kennt, sodass Sie die Stacks selbst in Reihenfolge deployen. Unser Deploy-Skript listet sie: network data app edge. Für das VPC erledigt Vpc.fromLookup per Tag dieselbe Aufgabe, mit dem Lookup in cdk.context.json gecacht.
Der ganze Umzug, gestoppt
| Schritt | Zeit | Ausfall |
|---|---|---|
| Inventar und Ziehen der Grenze | 2 Stunden | keiner |
| Code in vier Stacks aufgeteilt | 3 Stunden | keiner |
| Zustandslose Ressourcen verschoben (Lambdas, Regeln, Rollen) | 20 Minuten Deploys | ~1 Minute für eventgetriebene Funktionen |
| RETAIN auf 5 zustandsbehafteten Ressourcen deployt | 5 Minuten | keiner |
| Verwaisen und importieren, 5 Ressourcen | 40 Minuten | keiner |
| Cross-Stack-Referenzen auf SSM umgestellt | 1 Stunde | keiner |
| Leere Diffs auf allen vier Stacks verifiziert | 15 Minuten | keiner |
Ein Nachmittag und ein Vormittag, zuerst in Staging und am nächsten Tag mit Runbook in Produktion. Die eine Minute Ausfall betraf die Lambdas, die Queues konsumieren: zwischen Löschen und Erstellen warteten die Nachrichten. Sie wurden verarbeitet, als die neuen Funktionen hochkamen.
Was wir unserem früheren Ich sagen würden
Vor 300 aufteilen, nicht bei 500. Ressourcen nach jeder nennenswerten Änderung per grep zählen und die Zahl in CI als Warnung bei 350 hinterlegen. Zustandsbehaftete Dinge vom ersten Deploy an in einem Stack halten, der sich so selten wie möglich ändert. Und niemals ein zustandsbehaftetes Construct aus einem Stack entfernen, ohne vorher Retain im deployten Template gesehen zu haben.
Wenn Sie gerade auf den 500er-Fehler starren und der Stack eine Datenbank enthält, sprechen Sie mit uns, bevor Sie den nächsten Deploy starten.