Fin de lab un vendredi : terraform destroy répond « Destroy complete! », tout le monde rentre. Le lundi, la facture montre un dépôt Artifact Registry de 4 Go et un compte de service orphelin, créés à la main pendant un débogage et inconnus de l'état. Un destroy ne supprime que ce que Terraform connaît. Cette leçon crée réellement la plateforme de dev, l'appelle, la détruit, puis vérifie par labels et préfixes qu'il ne reste rien, y compris ce que Terraform ne connaît pas.
Le mécanisme
sequenceDiagram participant M as "make cloud-up" participant T as "Terraform (envs/dev)" participant AR as "Artifact Registry" participant CR as "Cloud Run" participant V as "Vertex AI" M->>T: apply 1 (image = null) : socle, registre, secret, IAM M->>AR: docker build + push, lecture du digest M->>T: apply 2 (image = …@sha256) : service + révision Note over CR: rôle aiplatform.user accordé il y a moins d'une minute M->>CR: make e2e : appel authentifié (jeton d'identité) CR->>V: generate_content V-->>CR: 403 tant que l'IAM n'est pas propagé, puis 200 M->>T: make cloud-down : destroy dev, destroy bucket d'état M->>M: cloud-status : labels + préfixes, code 1 s'il reste quelque chose
Deux points font échouer un premier déploiement qui « marchait sur mon poste » :
- La propagation IAM. Un rôle accordé par Terraform est enregistré tout de suite, mais la documentation Google annonce en général deux minutes avant qu'il soit effectif partout, et parfois sept minutes ou plus. Le premier appel du service à Vertex AI tombe dans cette fenêtre.
- L'authentification de l'appelant. Le service est privé : aucun
allUsers.make e2eprésente un jeton d'identité (gcloud auth print-identity-token) ; votre compte arun.invokerpar son rôle de projet. Sans jeton : 403, et c'est ce que le test vérifie en premier.
La mise en œuvre
cloud-up: state-up init ## Crée dev : socle, image, puis service
@terraform -chdir=$(DEV) apply -input=false -auto-approve -no-color | tail -1
@$(MAKE) --no-print-directory image TAG=$(TAG)
@terraform -chdir=$(DEV) apply -input=false -auto-approve -no-color \
-var image=$$(cat .image) -var app_version=$(TAG) | tail -4make image pousse avec un DOCKER_CONFIG temporaire (aucun identifiant ne reste dans ~/.docker) et écrit le digest dans .image. Le test de bout en bout (e2e.py) réessaie les réponses 502 et 503 pendant 300 s au plus : 502 couvre la propagation IAM (le service reçoit un 403 de Vertex AI), 503 un quota partagé épuisé. Au-delà, il échoue.
cd files
make cloud-up PROJECT=<votre-projet> TICKETING_TOKEN=<jeton>
make e2e PROJECT=<votre-projet>
make cloud-down PROJECT=<votre-projet> TICKETING_TOKEN=<jeton>Les variables passées en ligne de commande l'emportent sur .env : le Makefile ne lit dans .env que GOOGLE_CLOUD_PROJECT et TICKETING_TOKEN, sans l'exporter.
make cloud-status ne lit pas l'état Terraform : il interroge Google Cloud par label (formation-lab=plateforme-ia-modulaire) et par préfixe (lab-pia) sur huit types de ressources, liaisons IAM du projet comprises. C'est ce qui attrape les ressources créées à la main.
Résultat obtenu (2026-10-16)
$ make cloud-up # 56 s
state_bucket = "lab-pia-tfstate-…"
image : europe-west1-docker.pkg.dev/<projet>/lab-pia-dev-images/triage@sha256:9a662e38f069…53d1cc
archive_bucket = "lab-pia-dev-archive-8cb630"
registry_url = "europe-west1-docker.pkg.dev/<projet>/lab-pia-dev-images"
service_name = "lab-pia-dev-triage"
service_uri = "https://lab-pia-dev-triage-<hash>-ew.a.run.app"
$ make e2e # 63 s
sans jeton -> HTTP 403
/health -> {'status': 'ok', 'version': '20261016-152533', 'model': 'gemini-2.5-flash', 'ticketing_token_loaded': True}
56528 ms {'categorie': 'incident', 'priorite': 'P1', 'equipe': 'infra', 'resume': "Scanner quai 4 HS, 30 camions bloqués, impact majeur d'opérations de déchargement."}
2535 ms {'categorie': 'acces', 'priorite': 'P2', 'equipe': 'support-poste', 'resume': "Accès au dossier partagé Achats impossible depuis le nouveau poste de l'utilisateur."}
2141 ms {'categorie': 'achat', 'priorite': 'P3', 'equipe': 'achats', 'resume': "Commande de deux écrans 27 pouces pour l'équipe planification."}
E2E OKLes 56 secondes du premier appel sont l'attente de la propagation IAM. Les journaux du service le montrent, avec le filtre même de la métrique lab-pia-dev/errors :
$ gcloud logging read 'resource.labels.service_name="lab-pia-dev-triage" AND severity>=ERROR'
13:18:54 403 ClientError 403 PERMISSION_DENIED. Permission 'aiplatform.endpoints.predict' denied …
13:19:04 403 ClientError 403 PERMISSION_DENIED. …
13:19:14 403 ClientError 403 PERMISSION_DENIED. …Le premier cycle du 2026-10-15 avait donné le même motif (71 s, avec en plus un 429 de quota). Juste après, un plan sur dev répond No changes. Your infrastructure matches the configuration., et la valeur du jeton est absente de l'état distant (grep -c : 0).
Puis la destruction :
$ make cloud-down # 55 s
Destroy complete! Resources: 14 destroyed.
Destroy complete! Resources: 2 destroyed.
RESTE Comptes de service : lab-pia-dev-run@<projet>.iam.gserviceaccount.com
make[1]: *** [Makefile:76 : cloud-status] Erreur 1
Aucune ressource plateforme-ia-modulaire restante.La ligne RESTE n'est pas une fuite : le compte de service venait d'être supprimé, et la liste IAM est à cohérence différée. La deuxième vérification, 15 s plus tard, est vide. C'est pour cela que cloud-down revérifie trois fois avant d'échouer.
Coût de ce cycle : trois appels gemini-2.5-flash de quelques centaines de jetons, une image de quelques dizaines de Mo stockée quelques minutes, Cloud Run à zéro instance au repos. De l'ordre du centime.
Ce qui casse en production
- 403 au premier appel après un déploiement. Symptôme :
PERMISSION_DENIEDsur un rôle pourtant visible dans la console. Cause : propagation IAM. Parade : accorder les rôles dans un apply antérieur au déploiement du service (c'est le cas ici : apply 1 puis apply 2, mais l'écart n'est que de 30 s) ; côté client, réessayer avec un délai borné. destroyqui laisse des ressources. Tout ce qui a été créé hors Terraform (image poussée, compte de débogage, bucket de test) survit. Parade : une vérification par label et préfixe, indépendante de l'état, qui échoue s'il reste quelque chose.- Vérification trop rapide. À l'inverse, une liste lue juste après une suppression peut encore afficher la ressource. Parade : revérifier après un délai avant de conclure.
- Sortie du modèle non déterministe. Même avec
temperature = 0, la demande d'accès au dossier Achats a été classéeincidentlors de deux cycles etacceslors d'un troisième. Le test vérifie donc la priorité, pas la catégorie, et seulement sur les cas sans ambiguïté (P1, P3). Un test de bout en bout de LLM affirme des invariants, pas des chaînes exactes. - Le bucket d'état détruit avant l'environnement. On perd la trace de ce qui existe. Parade : l'ordre de
cloud-down(dev d'abord, bucket d'état ensuite), et en vrai, un bucket d'état qu'on ne détruit jamais.
La règle de décision
Un environnement éphémère se crée et se détruit par la même commande, et sa destruction se prouve par une vérification indépendante de l'état. En prod, on ne détruit pas : deletion_policy = "PREVENT" fait échouer toute tentative, et c'est voulu. Surveillez le délai entre l'octroi d'un rôle et son premier usage ; s'il est de quelques secondes, prévoyez l'échec.
Pratique
Juste après make cloud-up, le premier appel à /v1/triage répond 502 ; les journaux montrent 403 PERMISSION_DENIED sur aiplatform.endpoints.predict. Le plan montre bien roles/aiplatform.user sur le compte run. Que faites-vous ?
Un rôle tout juste accordé met en général deux minutes à être effectif, parfois sept ou plus. Ajouter un rôle plus large ne raccourcit pas la propagation et viole le moindre privilège. allUsers concerne l'appel du service, pas son accès à Vertex AI. Un nouvel apply ne change rien : l'état est déjà correct.
make cloud-down affiche « Destroy complete! » puis « RESTE Buckets : lab-pia-debug-logs ». Ce bucket n'est pas dans le code. Quelle est la bonne réaction ?
La vérification par label existe pour ce cas : une ressource hors état qui continue de coûter. On vérifie avant de supprimer (son contenu peut être utile). L'importer pour la détruire marche aussi, mais c'est plus long que de la supprimer. Retirer le label masque le problème.
Exercice. Ajoutez à make cloud-status la vérification des images restantes dans les dépôts Artifact Registry du lab, au cas où un dépôt aurait été recréé à la main.
Voir la solution
chk "Images" bash -c 'for r in $$(gcloud artifacts repositories list --project $(PROJECT) --location $(REGION) --filter="name~/lab-pia" --format="value(name)"); do gcloud artifacts docker images list $(REGION)-docker.pkg.dev/$(PROJECT)/$$(basename $$r) --format="value(package)"; done'; \La ligne s'insère avant le test final de cloud-status. Un dépôt supprimé emporte ses images : la vérification ne sert que si un dépôt existe encore, ce que la ligne « Artifact Registry » signale déjà. Elle précise ce qu'il contient, donc ce qu'il coûte.
- Je déploie un service en deux applications : socle, puis service avec une image par digest.
- Je teste un service privé avec un jeton d'identité, et je vérifie qu'il refuse un appel anonyme.
- Je sais reconnaître un 403 de propagation IAM et le distinguer d'un droit manquant.
- Je détruis un environnement et je prouve, par labels et préfixes, qu'il ne reste rien.