Leçon 1 — Concevoir

Découper en modules

7 min de lecture

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 :

NiveauExempleRègle
Module unitairebuckets, secretsune responsabilité, aucun provider, aucun backend
Compositionplatformassemble les unitaires, applique les conventions (nommage, protections)
Racineenvs/dev, envs/prodbackend, 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 :

files/infra/modules/buckets/main.tf
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 :

files/infra/modules/service-accounts/variables.tf
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).

files/infra/modules/service-accounts/main.tf
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 test

Ré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-project avec 60 variables et des count = var.enable_x ? 1 : 0 partout. 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 count sur 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_each avec des clés stables, comme ci-dessus.
  • Le provider dans 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.hcl des 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 ?

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 ?

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() et validation.
  • J'utilise for_each avec des clés stables plutôt que count sur une liste.
  • Je borne les versions dans les modules et je les épingle dans les racines.
Suivant