# La limite de 500 ressources de CloudFormation, et comment nous avons découpé un stack sans perdre de données

`cdk deploy` a échoué avec un message que nous n'avions jamais vu : *Template format error: Number of resources, 503, is greater than maximum allowed, 500*. Rien dans le diff n'était gros. Nous avions ajouté une fonction Lambda avec un groupe de logs et un rôle, et ça a suffi pour pousser un stack que nous construisions depuis huit mois au-dessus d'un plafond dont nous ignorions l'existence.

La limite est réelle, elle est par stack, et elle n'est pas négociable via une augmentation de quota. Cet article raconte comment nous avons découvert ce qu'il y avait dans le stack, où nous avons coupé, et comment nous avons déplacé une base de données, trois tables DynamoDB et une clé KMS vers un nouveau stack sans en supprimer aucune.

## Comment on arrive à 500 sans s'en apercevoir

Notre code CDK déclarait peut-être 60 choses : un VPC, un cluster Aurora, trois services App Runner, une douzaine de Lambdas, quelques tables DynamoDB, une distribution CloudFront, un WAF, quelques secrets. CloudFormation en voyait 503. La différence, c'est tout ce que CDK génère pour vous :

```bash
npx cdk synth platform-prod --quiet
grep -h '"Type": "AWS::' cdk.out/platform-prod.template.json | sort | uniq -c | sort -rn | head
```

| Type de ressource | Nombre | D'où ça vient |
|---|---|---|
| `AWS::IAM::Policy` | 71 | chaque appel `grant*()`, une politique par rôle et par groupe de permissions |
| `AWS::IAM::Role` | 48 | un par Lambda, par service App Runner, par custom resource |
| `AWS::Lambda::Function` | 31 | nos 12, plus les providers de custom resources de CDK pour la rétention des logs, le déploiement de buckets, la rotation des secrets du cluster |
| `AWS::Logs::LogGroup` | 29 | un par fonction, explicitement, parce que nous fixons la rétention |
| `AWS::EC2::*` | 58 | le VPC : sous-réseaux, tables de routage, routes, associations, NAT, endpoints, security groups, règles d'entrée |
| `AWS::Lambda::Permission` | 24 | chaque source d'événements et chaque route API Gateway |
| `Custom::*` | 19 | rétention des logs, déploiements S3, rotation du mot de passe du cluster |
| Tout le reste | 223 | |

Un VPC sur trois zones de disponibilité, c'est à lui seul plus de cinquante ressources. Chaque Lambda est une fonction, un rôle, une à trois politiques, un groupe de logs, une custom resource de rétention et une permission : sept ressources par fonction que vous pensiez être une. IAM seul représentait un quart du stack. Rien de tout cela n'est du gaspillage ; c'est la bonne quantité d'infrastructure. C'est juste bien plus que le décompte mental.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Graphique à barres horizontales des 503 ressources du stack unique par type : politiques IAM 71, réseau EC2 58, rôles IAM 48, fonctions Lambda 31, groupes de logs 29, permissions Lambda 24, custom resources 19, tout le reste 223.">
<g font-family="Inter,system-ui,sans-serif" font-size="13">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">503 ressources dans un stack, par type</text>
<g fill="#9aa3c7">
<text x="20" y="58">Politiques IAM</text><rect x="190" y="46" width="213" height="16" rx="3" fill="#7b8cff"/><text x="412" y="58" fill="#f1f3ff">71</text>
<text x="20" y="84">Réseau EC2</text><rect x="190" y="72" width="174" height="16" rx="3" fill="#7b8cff"/><text x="373" y="84" fill="#f1f3ff">58</text>
<text x="20" y="110">Rôles IAM</text><rect x="190" y="98" width="144" height="16" rx="3" fill="#7b8cff"/><text x="343" y="110" fill="#f1f3ff">48</text>
<text x="20" y="136">Fonctions Lambda</text><rect x="190" y="124" width="93" height="16" rx="3" fill="#4fffb0"/><text x="292" y="136" fill="#f1f3ff">31 · seulement 12 sont à nous</text>
<text x="20" y="162">Groupes de logs</text><rect x="190" y="150" width="87" height="16" rx="3" fill="#7b8cff"/><text x="286" y="162" fill="#f1f3ff">29</text>
<text x="20" y="188">Permissions Lambda</text><rect x="190" y="176" width="72" height="16" rx="3" fill="#7b8cff"/><text x="271" y="188" fill="#f1f3ff">24</text>
<text x="20" y="214">Custom resources</text><rect x="190" y="202" width="57" height="16" rx="3" fill="#ffd166"/><text x="256" y="214" fill="#f1f3ff">19 · aides CDK</text>
<text x="20" y="240">Tout le reste</text><rect x="190" y="228" width="669" height="16" rx="3" fill="#2a3150"/><text x="700" y="240" fill="#f1f3ff">223</text>
</g>
</g>
</svg>
</div>

## Où couper

La contrainte qui décide du découpage n'est pas le nombre, c'est le rayon d'explosion et la fréquence de changement. Les choses qui changent à chaque déploiement (Lambdas, tags d'image App Runner) ne devraient pas partager un stack avec les choses qu'on ne doit jamais toucher par accident (la base de données). Nous l'avions écrit comme principe [en mettant en place trois comptes](/fr/blog/aws-three-accounts-one-cdk-codebase), puis nous avons quand même tout mis dans un seul stack, parce qu'un seul stack est plus simple jusqu'au jour où il ne l'est plus.

La frontière que nous avons tracée, dans l'ordre de déploiement :

| Stack | Contenu | Ressources | Changements |
|---|---|---|---|
| `network` | VPC, sous-réseaux, NAT, endpoints, security groups de base | ~70 | presque jamais |
| `data` | cluster Aurora, tables DynamoDB, clés KMS, secrets, coffre de sauvegarde | ~60 | rarement, et avec précaution |
| `app` | Lambdas, services App Runner, files, règles d'événements, rôles | ~300 | à chaque déploiement |
| `edge` | CloudFront, WAF, certificats, enregistrements Route 53 | ~50 | mensuel |

Quatre stacks au lieu d'un, le plus gros à 300 avec de la marge. Le stack `data` est celui qui reçoit la `termination protection` et le rôle de déploiement le plus strict ; le stack `app` est celui que la CI touche chaque jour.

Nous avons envisagé les nested stacks et les avons rejetés. Un `NestedStack` CDK lève la limite de 500 (chaque nested stack a la sienne), mais un nested stack est déployé comme partie de son parent, donc le rayon d'explosion ne rétrécit pas du tout : un mauvais changement sur une Lambda exécute toujours un change set qui inclut la base de données. Des stacks séparés, c'était tout l'intérêt.

## Déplacer les ressources sans état : il suffit de les déplacer

Lambdas, rôles, règles d'événements, services App Runner sans état : couper le construct d'un fichier, le coller dans l'autre, déployer les deux. CloudFormation supprime la ressource de l'ancien stack et la crée dans le nouveau. Deux choses à vérifier avant :

- **Les noms physiques.** Une Lambda avec un `functionName` explicite ne peut pas exister deux fois, donc la suppression doit précéder la création, ce qui signifie déployer l'ancien stack d'abord. Les ressources sans nom explicite reçoivent un nouveau nom généré et peuvent coexister brièvement. Nous avons retiré les noms explicites sur tout ce qui n'en avait pas besoin, c'est-à-dire presque tout.
- **Les choses qui pointent vers la ressource par ARN depuis l'extérieur.** Le service App Runner avait un domaine personnalisé, donc le recréer aurait signifié un nouveau nom d'hôte `*.awsapprunner.com`, un changement DNS et une validation de certificat. Nous avons laissé les trois services App Runner dans le stack `app`, en place, là où ils devaient être de toute façon.

Les Lambdas ont reçu de nouveaux ARN. Rien en dehors du stack ne les référençait par ARN sauf une règle EventBridge dans le même stack, donc rien ne l'a remarqué.

## Déplacer les ressources avec état : l'import en quatre étapes

La base de données, les tables et la clé KMS ne peuvent pas être recréées. Elles devaient changer de stack tout en restant exactement où elles étaient. CloudFormation le permet via l'import de ressources, et CDK l'enveloppe en `cdk import`. La séquence, par ressource :

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Quatre étapes pour déplacer une ressource avec état entre stacks. Étape 1 : mettre RemovalPolicy RETAIN dans l'ancien stack et déployer. Étape 2 : supprimer le construct de l'ancien stack et déployer ; CloudFormation oublie la ressource mais ne la supprime pas. Étape 3 : ajouter le construct au nouveau stack avec le même nom physique et lancer cdk import ; CloudFormation adopte la ressource existante. Étape 4 : déployer le nouveau stack normalement ; le diff doit être vide.">
<defs><marker id="arrI" 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="12">
<rect x="15" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="115" y="64" text-anchor="middle" fill="#ffd166" font-weight="700">1 · RETAIN</text><text x="115" y="84" text-anchor="middle" fill="#f1f3ff">ancien stack : removalPolicy</text><text x="115" y="100" text-anchor="middle" fill="#f1f3ff">= RETAIN, déployer</text><text x="115" y="120" text-anchor="middle" fill="#9aa3c7">rien ne change encore</text>
<line x1="217" y1="85" x2="240" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="243" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="343" y="64" text-anchor="middle" fill="#ff6b8a" font-weight="700">2 · orpheline</text><text x="343" y="84" text-anchor="middle" fill="#f1f3ff">supprimer le construct,</text><text x="343" y="100" text-anchor="middle" fill="#f1f3ff">déployer l'ancien stack</text><text x="343" y="120" text-anchor="middle" fill="#9aa3c7">la ressource reste vivante, non gérée</text>
<line x1="445" y1="85" x2="468" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="471" y="40" width="200" height="90" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="64" text-anchor="middle" fill="#4fffb0" font-weight="700">3 · cdk import</text><text x="571" y="84" text-anchor="middle" fill="#f1f3ff">même construct, même</text><text x="571" y="100" text-anchor="middle" fill="#f1f3ff">nom physique, nouveau stack</text><text x="571" y="120" text-anchor="middle" fill="#9aa3c7">CloudFormation l'adopte</text>
<line x1="673" y1="85" x2="696" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="699" y="40" width="185" height="90" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="64" text-anchor="middle" fill="#7b8cff" font-weight="700">4 · vérifier</text><text x="791" y="84" text-anchor="middle" fill="#f1f3ff">cdk diff doit être</text><text x="791" y="100" text-anchor="middle" fill="#f1f3ff">vide, puis déployer</text><text x="791" y="120" text-anchor="middle" fill="#9aa3c7">de nouveau gérée</text>
<text x="450" y="170" text-anchor="middle" fill="#9aa3c7">Entre les étapes 2 et 3, la ressource existe mais aucun stack ne la possède. Enchaînez les deux étapes, en une seule séance, avec un snapshot pris avant.</text>
<text x="450" y="196" text-anchor="middle" fill="#ff6b8a">Si l'étape 1 est sautée, l'étape 2 supprime la base de données. C'est toute la raison d'être de l'étape 1.</text>
</g>
</svg>
</div>

L'étape un est celle que les gens sautent. Sans `RemovalPolicy.RETAIN` déployé *d'abord*, retirer le construct à l'étape deux supprime la ressource, et pour un cluster Aurora, c'est au mieux un snapshot final. Nous avions déjà vécu [un incident de suppression](/fr/blog/cloudformation-deleted-our-app-runner-services) cette année-là et n'avions aucune envie d'un second, donc nous avons fait l'étape un, déployé, puis vérifié dans la console que `DeletionPolicy: Retain` était sur la ressource dans le template avant de faire quoi que ce soit d'autre.

L'étape trois exige que le construct du nouveau stack produise exactement les propriétés de la ressource existante. Pour DynamoDB, c'est le nom de la table, le schéma de clés et le mode de facturation ; pour Aurora, l'identifiant du cluster, le moteur et quelques autres ; pour KMS, l'identifiant de la clé. `cdk import` demande les identifiants qu'il ne peut pas déduire, puis exécute un change set d'import. Si une propriété ne correspond pas, l'import échoue proprement et rien n'est modifié, ce qui est le bon type d'échec.

L'étape quatre est la preuve. `cdk diff` sur le nouveau stack après l'import doit être vide. Le nôtre ne l'était pas, la première fois, pour le cluster Aurora : nous avions déclaré `deletionProtection: true` dans le nouveau stack, et le vrai cluster l'avait désactivée, parce que l'ancien stack ne l'avait jamais définie. Le diff l'a montré, nous l'avons déployé, et le cluster a fini mieux protégé qu'avant.

## Les références entre stacks, et le piège qu'elles contiennent

Une fois les ressources dans des stacks différents, le stack `app` a besoin du VPC de `network` et des noms de tables de `data`. Le comportement par défaut de CDK est de passer l'objet et de générer une paire export/import CloudFormation. Ça marche, puis ça vous enferme : une valeur exportée ne peut pas changer tant qu'un autre stack l'importe, donc un changement de VPC qui modifie un identifiant de sous-réseau exporté échoue tant que vous n'avez pas retiré chaque consommateur. Nous l'avons subi le deuxième jour.

Nous sommes passés aux paramètres SSM pour tout ce qui traverse une frontière de stack :

```ts
// stack data
new ssm.StringParameter(this, 'OrdersTableName', {
  parameterName: `/platform/${env}/orders-table-name`,
  stringValue: ordersTable.tableName,
});

// stack app
const ordersTableName = ssm.StringParameter.valueForStringParameter(this, `/platform/${env}/orders-table-name`);
const ordersTable = dynamodb.Table.fromTableName(this, 'OrdersTable', ordersTableName);
```

Le consommateur résout le paramètre au déploiement ; il n'y a pas d'export, donc rien n'est verrouillé. Le coût, c'est que CDK ne connaît plus la dépendance, donc vous déployez les stacks dans l'ordre vous-même. Notre script de déploiement les liste : `network data app edge`. Pour le VPC, `Vpc.fromLookup` par tag fait le même travail, avec la recherche mise en cache dans `cdk.context.json`.

## Tout le déplacement, chronométré

| Étape | Temps | Interruption |
|---|---|---|
| Inventaire et tracé de la frontière | 2 heures | aucune |
| Code découpé en quatre stacks | 3 heures | aucune |
| Ressources sans état déplacées (Lambdas, règles, rôles) | 20 minutes de déploiements | ~1 minute pour les fonctions événementielles |
| RETAIN déployé sur 5 ressources avec état | 5 minutes | aucune |
| Orphelinage et import, 5 ressources | 40 minutes | aucune |
| Références entre stacks passées sur SSM | 1 heure | aucune |
| Vérification des diffs vides sur les quatre stacks | 15 minutes | aucune |

Un après-midi et une matinée, d'abord en staging puis en production le lendemain avec un runbook. La minute d'interruption concernait les Lambdas qui consomment des files : entre suppression et création, les messages ont attendu. Ils ont été traités quand les nouvelles fonctions sont arrivées.

## Ce que nous dirions à nos anciens nous

Découpez avant 300, pas à 500. Comptez les ressources avec `grep` après chaque changement significatif, et mettez le compte en CI comme avertissement à 350. Gardez les choses avec état dans un stack qui change aussi rarement que possible, dès le premier déploiement. Et ne retirez jamais un construct avec état d'un stack sans avoir vu `Retain` dans le template déployé d'abord.

Si vous regardez l'erreur des 500 en ce moment et que le stack contient une base de données, [parlons-en](/contact) avant de lancer le prochain déploiement.
