Un main.tf de 1 400 lignes ne se relit plus. Une revue de code sur ce fichier se résume à « LGTM » parce que personne ne voit ce qu'un changement de 3 lignes touche réellement. Et quand la prod diverge du dev, il faut comparer deux copies du même fichier modifiées par cinq personnes. Le découpage en modules sert d'abord à ça : rendre chaque changement petit et lisible.
Le mécanisme : un module est une fonction
Un module Terraform est un dossier de fichiers .tf. Il a des entrées (variable), des sorties (output) et des ressources internes. Un appelant ne voit que le contrat entrées/sorties ; c'est ce contrat qu'on conçoit, pas la liste des ressources.
flowchart LR
subgraph racine["Racine : envs/dev"]
V["terraform.tfvars"] --> P["module platform"]
end
P -- "project_id, prefix, accounts" --> SA["service-accounts"]
SA -- "emails, members" --> P
P -- "accessors = { run = member }" --> SM["secrets"]
SM -- "ids, versions" --> P
P -- "image, service_account_email, secret_env" --> CR["cloud-run-service"]
CR -- "uri, name" --> P
Trois niveaux, avec une règle par niveau :
| Niveau | Exemple | Règle |
|---|---|---|
| Module unitaire | buckets, secrets | une responsabilité, aucun provider, aucun backend |
| Composition | platform | assemble les unitaires, applique les conventions (nommage, protections) |
| Racine | envs/dev, envs/prod | backend, provider, valeurs ; quasiment pas de logique |
Le module unitaire ne déclare pas de bloc provider : il hérite de celui de la racine. Un module qui configure son propre provider ne peut plus être appelé avec count ou for_each, et il fige le projet et la région à la place de l'appelant.
La mise en œuvre : sept modules, trois fichiers chacun
infra/modules/
network/ VPC, sous-réseau, pare-feu deny-all
service-accounts/ comptes de service + rôles de projet
artifact-registry/ dépôt Docker + politique de nettoyage
secrets/ Secret Manager, valeurs write-only
buckets/ buckets privés, cycle de vie
logging/ métrique d'erreurs + sink d'archive
cloud-run-service/ service Cloud Run v2 + invokers
platform/ la composition
Chaque module a main.tf (le bloc terraform, les ressources), variables.tf, outputs.tf. Le bloc terraform d'un module fixe une borne de version, la racine fixe la version exacte :
terraform {
required_version = ">= 1.11"
required_providers {
google = { source = "hashicorp/google", version = ">= 8.0, < 9.0" }
random = { source = "hashicorp/random", version = ">= 3.6, < 4.0" }
}
}La racine écrit version = "~> 8.7" et versionne .terraform.lock.hcl : tout le monde, CI comprise, installe exactement le même provider.
Le contrat se protège avec des validation. Le module service-accounts refuse ce qui a causé l'incident d'ouverture :
variable "accounts" {
type = map(object({
description = string
project_roles = optional(list(string), [])
}))
validation {
condition = alltrue([for k, v in var.accounts : !contains(v.project_roles, "roles/owner") && !contains(v.project_roles, "roles/editor")])
error_message = "Rôles primitifs (owner, editor) interdits : donnez des rôles prédéfinis."
}
}Un map(object) avec optional() plutôt que dix variables séparées : l'appelant décrit ce qu'il veut (un compte run avec trois rôles), le module décide comment (une ressource IAM par couple compte/rôle).
locals {
bindings = {
for pair in flatten([
for name, sa in var.accounts : [for role in sa.project_roles : { name = name, role = role }]
]) : "${pair.name}/${pair.role}" => pair
}
}La clé "run/roles/aiplatform.user" est lisible dans le plan et stable : retirer un rôle supprime une seule liaison, au lieu de décaler tous les index d'une liste (ce que ferait count).
Vérifiez que l'ensemble est syntaxiquement juste, sans aucun accès au cloud :
cd files && make setup && make testRésultat obtenu (2026-10-15)
5 passed, 1 warning in 0.24s
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.Les 5 tests sont ceux du service Python ; terraform validate couvre la racine et, à travers elle, tous les modules appelés.
Ce qui casse en production
- Le module « couteau suisse ». Un module
gcp-projectavec 60 variables et descount = var.enable_x ? 1 : 0partout. Symptôme : chaque évolution casse un appelant que personne n'a testé. Parade : un module par responsabilité ; la composition (platform) porte les choix. - Les clés
countsur une liste.count = length(var.roles)puis on retire le premier rôle : Terraform détruit et recrée toutes les liaisons suivantes, avec une coupure d'accès de quelques secondes à quelques minutes. Parade :for_eachavec des clés stables, comme ci-dessus. - Le
providerdans le module. Symptôme :Module is incompatible with count, for_each, and depends_on. Parade : supprimer le bloc, laisser la racine fournir le provider. - Pas de lock file versionné. Deux postes installent deux versions mineures du provider, l'une ajoute un attribut calculé : plans différents pour le même code. Parade : commiter
.terraform.lock.hcldes racines.
La règle de décision
Extrayez un module quand un groupe de ressources a un cycle de vie commun et un contrat à exprimer (« un service privé avec ses invokers »). Ne l'extrayez pas pour une seule ressource sans logique : un module bucket qui transmet 25 variables à google_storage_bucket n'apporte qu'une indirection. Surveillez le nombre de variables : au-delà d'une quinzaine, le module fait probablement deux choses.
Pratique
Une équipe veut réutiliser votre module cloud-run-service dans trois régions avec for_each. terraform init échoue : « Module is incompatible with count, for_each, and depends_on ». Que modifiez-vous ?
Un module qui contient sa propre configuration de provider ne peut pas être instancié avec count ou for_each. Le provider appartient à la racine ; la région devient une entrée. Dupliquer le module multiplie la maintenance ; un alias interne garde le problème ; count est soumis à la même restriction.
Le module service-accounts reçoit project_roles en liste et crée les liaisons avec count. On retire roles/aiplatform.user, premier de la liste. Que montre le plan ?
Avec count, l'identité d'une instance est son index. Retirer l'élément 0 décale tous les autres : l'index 0 change de rôle, le dernier disparaît. Avec for_each et une clé « compte/rôle », seul le rôle retiré est détruit.
Exercice. Ajoutez au module service-accounts une validation qui refuse un rôle qui ne commence pas par roles/ (une faute de frappe comme aiplatform.user passe aujourd'hui validate et n'échoue qu'à l'apply).
Voir la solution
validation {
condition = alltrue(flatten([
for k, v in var.accounts : [for r in v.project_roles : startswith(r, "roles/") || startswith(r, "projects/")]
]))
error_message = "Chaque rôle commence par roles/ (prédéfini) ou projects/ (personnalisé)."
}projects/ couvre les rôles personnalisés de projet. Un bloc variable accepte plusieurs validation : ajoutez celui-ci à côté de celui des rôles primitifs. Vérifiez avec make test, puis en passant volontairement "aiplatform.user" dans platform/main.tf : terraform validate doit échouer avec votre message.
- Je distingue module unitaire, composition et racine, et je sais ce que chacun peut contenir.
- J'écris un contrat de module avec
map(object),optional()etvalidation. - J'utilise
for_eachavec des clés stables plutôt quecountsur une liste. - Je borne les versions dans les modules et je les épingle dans les racines.