Le plan affichait « 1 to change ». L'ingénieur a validé sans dérouler le détail : c'était un -/+ sur le bucket d'archive, suite au renommage d'une clé de map. Un « change » qui est en réalité une destruction suivie d'une recréation, sur un bucket de journaux d'audit. Le plan est le seul moment où l'on voit ce que Terraform va faire avant qu'il le fasse ; cette leçon apprend à le lire, puis compare les plans réels de dev et de prod produits par le même code.
Le mécanisme
flowchart LR
CODE["code + tfvars"] --> PLAN["terraform plan"]
ETAT[("état distant")] --> PLAN
API["API Google<br/>(refresh)"] --> PLAN
PLAN -- "-out=dev.tfplan" --> FICHIER["plan figé"]
FICHIER --> REVUE["revue humaine<br/>+ politiques (projet suivant)"]
FICHIER --> APPLY["terraform apply dev.tfplan<br/>exactement ces actions"]
Un plan compare trois choses : le code, l'état, et la réalité lue par le provider (le refresh). Les symboles à connaître :
| Symbole | Sens | À vérifier |
|---|---|---|
+ | création | noms, labels, région |
~ | modification sur place | l'attribut qui change, et pourquoi |
-/+ | destruction puis recréation | # forces replacement : quelle donnée est perdue |
- | destruction | toujours justifiée dans la description de la PR |
(known after apply) | valeur calculée par l'API | normal pour un id, suspect pour un nom |
(write-only attribute) | valeur envoyée, jamais stockée | les secrets, leçon 3 |
Un plan non sauvegardé n'est qu'un aperçu : entre le plan et l'apply, le code ou la réalité peuvent changer. En CI, on écrit le plan dans un fichier (-out), on le fait relire, et apply exécute ce fichier et rien d'autre.
La mise en œuvre
make state-up # bucket d'état (leçon 2)
make plan # dev : init sur le bucket, puis plan
make plan-prod # prod : même code, autre tfvars, autre préfixe d'étatLa prod de Logi-Express vit dans un projet fictif, logi-ia-prod. Le plan fonctionne quand même : aucune source de données (data) ne lit le projet, le provider n'a besoin que des identifiants pour lire l'état et rafraîchir des ressources qui n'existent pas encore. C'est un bon test de la portabilité du code : rien ne dépend du projet du lab.
L'image de prod est un digest factice passé par la ligne de commande (-var image=…@sha256:000…) : sans image, le service n'apparaîtrait pas dans le plan (leçon 5).
Résultat obtenu (2026-10-16)
Plan de dev, sur un projet vide (identifiant masqué) :
$ make plan
# module.platform.module.buckets.google_storage_bucket.this["archive"] will be created
# module.platform.module.identities.google_project_iam_member.this["run/roles/aiplatform.user"] will be created
# module.platform.module.identities.google_project_iam_member.this["run/roles/logging.logWriter"] will be created
# module.platform.module.identities.google_project_iam_member.this["run/roles/monitoring.metricWriter"] will be created
# module.platform.module.identities.google_service_account.this["run"] will be created
# module.platform.module.logging.google_logging_metric.errors will be created
# module.platform.module.logging.google_logging_project_sink.archive will be created
# module.platform.module.logging.google_storage_bucket_iam_member.sink_writer will be created
# module.platform.module.registry.google_artifact_registry_repository.this will be created
# module.platform.module.secrets.google_secret_manager_secret.this["ticketing-token"] will be created
# module.platform.module.secrets.google_secret_manager_secret_iam_member.accessors["ticketing-token/run"] will be created
# module.platform.module.secrets.google_secret_manager_secret_version.this["ticketing-token"] will be created
# (+ random_id.suffix)
Plan: 13 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ archive_bucket = (known after apply)
+ registry_url = "europe-west1-docker.pkg.dev/<projet>/lab-pia-dev-images"
+ service_name = "lab-pia-dev-triage"Pas de service en dev à ce stade : pas d'image. Plan de prod : Plan: 18 to add, soit les 13 mêmes ressources, plus le réseau (3), le service et son invoker. Les écarts relevés dans les deux plans réels :
| Attribut (plan réel) | dev | prod |
|---|---|---|
bucket d'archive force_destroy / deletion_policy | true / DELETE | false / PREVENT |
règle de cycle de vie age | 7 | 365 |
registre immutable_tags | (bloc absent) | true |
service ingress | INGRESS_TRAFFIC_ALL (au 2e apply) | INGRESS_TRAFFIC_INTERNAL_ONLY |
service min_instance_count / max_instance_count | 0 / 2 | 1 / 10 |
service deletion_protection | false | true |
label env | dev | prod |
Chaque ligne de ce tableau vient d'une seule source : local.protect ou une valeur de terraform.tfvars. Aucune n'est un if dans une racine.
Ce qui casse en production
- Le
-/+caché dans un « change ». Renommer une clé defor_each(archive→archives) ou changer un attribut immuable (locationd'un bucket) détruit la ressource. Diagnostic : chercherforces replacementdans le plan. Parade : un blocmoved { from = …["archive"] to = …["archives"] }pour un renommage ; pour un attribut immuable, une migration explicite. - Plan valide, apply refusé. Le plan ne vérifie pas les droits d'écriture ni les quotas :
Error 403: Permission 'iam.serviceAccounts.create' deniedn'apparaît qu'à l'apply. Parade : planifier avec le compte de déploiement réel, pas avec un compte personnel plus large. - Plan non vide juste après un apply. Une différence que l'API normalise (bloc vide, casse d'une région, ordre d'une liste) revient à chaque plan. Vu sur ce lab : le bloc
docker_configdu registre (leçon 3). Parade : corriger le code pour qu'il décrive ce que l'API renvoie ; unignore_changesseulement si la valeur est réellement gérée ailleurs. (known after apply)sur un nom. Le nom d'un bucket dépend d'unrandom_id: il n'est connu qu'à l'apply, et tout ce qui en dépend aussi (le filtre du sink, par exemple). Acceptable pour un suffixe ; à éviter pour un nom qu'une autre équipe doit connaître à l'avance.
La règle de décision
Plan sauvegardé (-out) pour tout ce qui touche un environnement partagé ; apply du fichier relu, jamais un apply qui recalcule. Lisez d'abord le résumé, puis chaque - et -/+, puis les ~ sur les ressources IAM. Comparez dev et prod en lisant les tfvars, pas en lisant le code : si un écart n'apparaît dans aucun tfvars, c'est qu'il est caché dans une condition.
Pratique
Le plan d'une PR affiche « Plan: 1 to add, 0 to change, 1 to destroy » sur google_storage_bucket.this["archive"]. La PR renomme seulement la clé archive en audit-logs. Que demandez-vous ?
Pour Terraform, une nouvelle clé est une nouvelle ressource : l'ancienne est détruite avec ses objets (ou le destroy échoue en prod grâce à PREVENT). Le bloc moved déplace l'adresse dans l'état sans toucher à la ressource. state rm laisserait un bucket orphelin ; -target contourne le problème sans le résoudre. Ici le nom réel du bucket change aussi (il contient la clé) : renommer revient à migrer, ce qui se décide explicitement.
Juste après un apply réussi, le plan suivant annonce encore « 1 to change » sur le dépôt Artifact Registry, toujours le même attribut. Quelle est la cause la plus probable ?
Une modification manuelle se produit une fois, pas à chaque plan immédiatement après un apply. Un écart permanent sur le même attribut est une différence de représentation entre le code et l'API. Un état corrompu donnerait des erreurs ou des recréations, un verrou bloquerait le plan.
Exercice. Produisez un plan de prod sauvegardé, puis affichez uniquement les actions de destruction ou de remplacement qu'il contient, en une commande.
Voir la solution
cd files
terraform -chdir=infra/envs/prod plan -input=false -out=$PWD/prod.tfplan \
-var image=europe-west1-docker.pkg.dev/logi-ia-prod/lab-pia-prod-images/triage@sha256:$(printf '0%.0s' {1..64})
terraform -chdir=infra/envs/prod show -json $PWD/prod.tfplan \
| jq -r '.resource_changes[] | select(.change.actions | index("delete")) | "\(.change.actions|join("/")) \(.address)"'Le JSON du plan donne actions : ["delete"], ["delete","create"] ou ["create","delete"] pour un remplacement. Sur un plan initial, la sortie est vide. C'est la base des politiques du projet suivant. make clean supprime les *.tfplan.
- Je lis un plan en commençant par les destructions et les remplacements.
- Je sauvegarde un plan et j'applique ce fichier, pas un nouveau calcul.
- J'explique chaque écart dev/prod par une valeur de tfvars ou par
local.protect. - Je reconnais une différence permanente due à une normalisation de l'API.