Une révision Cloud Run part en production avec image = "…/triage:latest". Trois semaines plus tard, un redémarrage d'instance tire une image poussée entre-temps par une branche de test : le tri des tickets change de comportement sans qu'aucun apply n'ait eu lieu. Le même mois, un max_instances laissé à la valeur par défaut de Cloud Run (100) laisse un client mal configuré multiplier la facture Vertex AI par trente en une nuit. Le module cloud-run-service existe pour rendre ces deux erreurs impossibles par défaut ; le module platform pour que dev et prod les évitent de la même façon.
Le mécanisme
flowchart TB
subgraph platform["module platform (composition)"]
L["locals : prefix = lab-pia-dev<br/>protect = env == prod"]
ID["identities"] -- "emails.run" --> SV
SEC["secrets"] -- "ids, versions" --> SV
NW["network (count = with_network)"] -. "one(module.network[*])" .-> SV
SV["service (count = image != null)"]
L --> ID & SEC & SV
end
SV --> RUN["google_cloud_run_v2_service<br/>image @sha256, max N instances"]
SV --> INV["iam_member run.invoker<br/>(une identité, jamais allUsers)"]
Trois décisions portent la composition :
local.protect = var.env == "prod": une seule variable dérive toutes les protections (tags immuables,deletion_policy = "PREVENT",force_destroy = false,deletion_protection). Personne ne peut oublier l'une d'elles en prod en se souvenant des autres.- Le service n'existe que s'il y a une image (
count = var.image == null ? 0 : 1). Le premierapplycrée le registre ; l'image y est poussée ; le secondapplydéploie le service. Sans cette condition, le premierapplyéchoue sur une image introuvable. - Le réseau est optionnel et passé au service par
one(module.network[*]):nullsans réseau, l'objet{ network, subnetwork }sinon. Le module service n'a pas à savoir pourquoi.
La mise en œuvre
Le module service : des défauts qui protègent
resource "google_cloud_run_v2_service" "this" {
ingress = var.ingress # défaut : INGRESS_TRAFFIC_INTERNAL_ONLY
deletion_protection = var.deletion_protection # défaut : true
deletion_policy = var.deletion_protection ? "PREVENT" : "DELETE"
template {
service_account = var.service_account_email
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances # plafond de coût
}
containers {
image = var.image
resources {
cpu_idle = true # CPU facturé seulement pendant les requêtes
startup_cpu_boost = true
}
startup_probe {
http_get { path = "/health" } # /healthz est réservé par Cloud Run
}
}
}
}Les défauts du module sont ceux de la prod : privé, protégé, plafonné à 3 instances. C'est le dev qui doit demander à être plus ouvert, dans son terraform.tfvars, ce qui se voit en revue. Le contrat refuse ce qui ne doit jamais arriver :
variable "invokers" {
validation {
condition = alltrue([for m in var.invokers : !contains(["allUsers", "allAuthenticatedUsers"], m)])
error_message = "Service public interdit par ce module : utilisez une identité précise."
}
}
variable "max_instances" {
validation {
condition = var.max_instances >= 1 && var.max_instances <= 20
error_message = "max_instances entre 1 et 20 (au-delà, revue de capacité et de coût)."
}
}Le secret arrive par référence, version épinglée, jamais par valeur :
module "service" {
count = var.image == null ? 0 : 1
source = "../cloud-run-service"
name = local.service_name
image = var.image
service_account_email = module.identities.emails["run"]
deletion_protection = local.protect
vpc = one(module.network[*])
secret_env = {
TICKETING_TOKEN = {
secret = module.secrets.ids["ticketing-token"]
version = module.secrets.versions["ticketing-token"]
}
}
depends_on = [module.secrets, module.identities]
}version épinglé plutôt que latest : une nouvelle valeur du jeton crée une nouvelle révision, visible dans le plan, au lieu d'être prise au hasard du prochain démarrage d'instance.
Le service lui-même
app/main.py est un service FastAPI de 150 lignes : POST /v1/triage appelle gemini-2.5-flash sur Vertex AI avec une sortie JSON contrainte par un schéma Pydantic (categorie, priorite, equipe, resume), journalise une ligne JSON par demande, et traduit un quota épuisé (429) en 503 avec Retry-After. Il s'authentifie avec son compte de service : aucune clé dans l'image. Les tests remplacent le modèle par un faux :
cd files && make testRésultat obtenu (2026-10-16)
5 passed, 1 warning in 0.23s
fmt : OK
infra/bootstrap : Success! The configuration is valid.
infra/envs/dev : Success! The configuration is valid.
infra/envs/prod : Success! The configuration is valid.Et le contrat tient face à un appel public, tenté sur le plan de prod :
$ terraform -chdir=infra/envs/prod plan -var 'invokers=["allUsers"]' ...
Error: Invalid value for variable
95: invokers = var.invokers
│ var.invokers is list of string with 1 element
Service public interdit par ce module : utilisez une identité précise.Ce qui casse en production
- Tag mutable.
:latestou:v1réécrit : deux instances d'une même révision exécutent deux codes différents. Parade : déployer par digest (@sha256:…) ;make imageécrit le digest dans.image, quecloud-uppasse à Terraform. - La révision ne démarre pas.
The user-provided container failed the configured startup probe checks. Causes vues : un chemin de sonde/healthz(intercepté par l'infrastructure de Cloud Run), un conteneur qui écoute sur 8000 au lieu de$PORT. Diagnostic : journaux de la révision, filtrés surresource.labels.revision_name. destroyrefusé sur le service.cannot destroy service without setting deletion_protection=false. Voulu en prod. Pour vraiment supprimer : passer la protection àfalse,apply, puisdestroy: deux changements relus, pas un.- Le cycle de dépendances. Un module service qui lit le nom du bucket d'archive, un module logging qui lit le nom du service :
Error: Cycle. Parade : le nom du service est calculé dansplatform(local.service_name) et passé aux deux, sans dépendance de ressource.
La règle de décision
Mettez dans le module unitaire les défauts de la prod et les validations qui ne souffrent pas d'exception (pas d'allUsers, un plafond d'instances) ; mettez dans la composition les choix qui dépendent de l'environnement, dérivés d'une seule variable. Une racine ne doit contenir que des valeurs. Surveillez dans les revues tout ingress = "INGRESS_TRAFFIC_ALL" et toute image sans digest.
Pratique
La prod tourne avec min_instances = 1. Un collègue propose de passer le défaut du module cloud-run-service à min_instances = 1 « pour que tout le monde évite les démarrages à froid ». Que répondez-vous ?
Un défaut s'applique à chaque appelant qui ne l'a pas changé, y compris les environnements de test que personne ne surveille. Une instance toujours allumée a un coût fixe : c'est une décision par environnement, visible dans le tfvars de prod. cpu_idle ne change rien à ce coût de base.
Premier make cloud-up sur un projet vierge, avec count = 1 forcé sur le module service. L'apply échoue : « Image '…/triage@sha256:…' not found ». Pourquoi le code du lab ne rencontre-t-il pas ce problème ?
Terraform ne sait pas construire ni attendre une image. La condition sur var.image découpe la création en deux temps, dans le même code. Le build depuis la source existe (gcloud run deploy --source) mais sort du périmètre de Terraform.
Exercice. Ajoutez au module cloud-run-service une validation qui refuse toute image non épinglée par digest, sauf en dev. Où placez-vous l'exception ?
Voir la solution
Le module ne connaît pas l'environnement, et ne doit pas le connaître. On lui ajoute une entrée explicite :
variable "require_digest" {
type = bool
default = true
}
variable "image" {
type = string
}
resource "google_cloud_run_v2_service" "this" {
# ...
lifecycle {
precondition {
condition = !var.require_digest || strcontains(var.image, "@sha256:")
error_message = "Image à épingler par digest (@sha256:...)."
}
}
}Une precondition de ressource combine deux variables et s'évalue au plan ; une validation le permet aussi depuis Terraform 1.9, mais la règle porte ici sur la ressource, pas sur une entrée isolée. Dans platform : require_digest = local.protect. Le dev peut ainsi tester un tag, la prod non. Vérifié avec un test natif (expect_failures = [google_cloud_run_v2_service.this] sur une image :latest) : voir le projet suivant.
- J'écris un module de service dont les défauts sont ceux de la prod.
- Je dérive toutes les protections d'une seule variable dans la composition.
- Je crée une ressource optionnelle avec
countet je la passe plus loin avecone(...[*]). - Je déploie par digest et j'injecte un secret par référence, version épinglée.