Les modules : pourquoi et comment
Pourquoi
À la fin du premier cours, le dépôt signalements-iac tient dans un répertoire : une trentaine de ressources, découpées par fichiers (reseau.tf, calcul.tf, base.tf...), et un fichier de valeurs par environnement. Cela fonctionne tant qu'une seule équipe maintient une seule application. Trois événements cassent cet équilibre.
Une deuxième application arrive. Lyneko héberge bientôt un service de suivi des commandes pour un autre client. Il lui faut un réseau privé, avec la même plage 172.16.20.0/22 déclinée autrement, les mêmes règles de pare-feu, la même passerelle. La tentation est de copier le fichier reseau.tf dans un autre dépôt. Six mois plus tard, les deux copies ont divergé : l'une a reçu un correctif de sécurité, l'autre non, et personne ne sait laquelle fait foi.
Une revue devient impossible. Quand toute l'infrastructure est dans un seul répertoire, une modification de la passerelle est relue au même niveau de détail que celle d'un nom de bucket. Le relecteur doit comprendre tout le périmètre pour juger une ligne.
Les noms se télescopent. Dans un seul espace de noms, scaleway_vpc.principal ne peut exister qu'une fois. Pour décrire deux réseaux, il faut inventer principal_2, puis principal_client_b, et la configuration devient un inventaire de cas particuliers.
Un module répond à ces trois problèmes de la même façon que la fonction répond à la répétition dans un langage de programmation : on écrit une fois un groupe de ressources avec une interface (des entrées, des sorties), puis on l'appelle autant de fois que nécessaire avec des valeurs différentes. Mais la comparaison s'arrête vite. Une fonction mal découpée coûte quelques lignes de refactorisation. Un module mal découpé, déjà appliqué sur un environnement de production, coûte des destructions et recréations de ressources réelles, parce que le module fait partie de l'adresse de chaque ressource dans l'état. Cette leçon explique le mécanisme, montre comment extraire un module sans rien recréer, et dit aussi quand ne pas en faire.
Les concepts
Module racine et modules enfants
Tout répertoire contenant des fichiers .tf est un module. Celui dans lequel vous lancez terraform plan est le module racine (root module). Il peut en appeler d'autres, ses modules enfants (child modules), avec un bloc module. Un enfant peut lui-même en appeler, formant un arbre, mais la racine reste unique : c'est elle qui porte la configuration des fournisseurs et du backend, et c'est son état qui enregistre tout.
Ce que vous avez écrit dans le premier cours était donc déjà un module : un module racine sans enfant. Créer un module enfant n'introduit aucune nouvelle syntaxe de ressource : un module enfant est un répertoire qui ressemble au module racine, avec ses propres variables, valeurs locales et sorties.
Le bloc module
Le module racine appelle un enfant ainsi :
module "reseau" {
source = "./modules/reseau"
environnement = var.environnement
adresses_instances = { for nom, i in local.instances : nom => i.adresse }
etiquettes = local.etiquettes
}- L'étiquette (
"reseau") est le nom local de cet appel. Il sert à référencer ses sorties (module.reseau.private_network_id) et il entre dans les adresses d'état. sourcedit où trouver le code : ici un chemin local, relatif au module appelant, qui doit commencer par./ou../. La leçon 3 traite des autres sources (Git, registre).- Tous les autres arguments sont les valeurs des variables d'entrée du module. Chaque variable déclarée sans valeur par défaut est obligatoire.
- Quatre méta-arguments s'appliquent aux modules, comme aux ressources :
countetfor_each(appeler le module plusieurs fois),depends_on(dépendance explicite) etproviders(choisir quelle configuration de fournisseur le module reçoit).lifecyclen'est pas disponible sur les modules.
Un appel avec source identique mais des valeurs différentes crée une autre instance du module, avec ses propres ressources et ses propres entrées d'état.
Entrées, sorties, valeurs locales
L'interface d'un module est composée de trois éléments :
- les variables d'entrée (
variable) : ce que l'appelant fournit ; - les sorties (
output) : ce que l'appelant peut lire, et rien d'autre ; - les fournisseurs : ce que l'appelant autorise le module à utiliser.
Tout le reste est privé. Un module n'a pas accès aux variables, aux ressources ni aux valeurs locales de son appelant : il ne voit que ce qu'on lui passe en argument. Inversement, l'appelant ne peut pas écrire module.reseau.scaleway_vpc.principal.id : une ressource d'un module n'est accessible que si le module la publie par une sortie. C'est cette étanchéité qui rend un module remplaçable : tant que l'interface ne change pas, on peut réécrire l'intérieur.
Ce qu'un module encapsule
Un bon module cache une décision de conception : une façon d'assembler plusieurs ressources qui ont du sens ensemble et qui changent ensemble. Pour Signalements, le réseau en est un exemple naturel : un VPC, un réseau privé avec sa plage, les adresses réservées dans IPAM pour les instances et le répartiteur, éventuellement la passerelle et son bastion. Ces ressources se créent dans cet ordre, se détruisent dans l'ordre inverse, et partagent des étiquettes. Les appelants n'ont besoin de connaître que l'identifiant du réseau privé et ceux des adresses réservées.
Kief Morris (Infrastructure as Code) parle de cohésion forte et de couplage faible : un module « réseau » est cohésif par son rôle, un module « tout ce qu'on crée à Paris » ne l'est que par la géographie.
L'adresse d'une ressource dans l'état
Voici le point qui distingue Terraform d'un simple langage de macros. Dans le module racine, une ressource a pour adresse scaleway_vpc.principal. Placée dans un module enfant appelé reseau, la même ressource a pour adresse module.reseau.scaleway_vpc.principal. Si le module est appelé avec for_each, l'adresse contient aussi la clé : module.reseau["preprod"].scaleway_vpc.principal. Les modules imbriqués s'enchaînent : module.client.module.reseau.scaleway_vpc.principal.
L'état (voir la leçon 6 du premier cours) enregistre pour chaque adresse l'identifiant de l'objet réel. Quand l'adresse change, Terraform ne sait pas que scaleway_vpc.principal et module.reseau.scaleway_vpc.principal sont le même VPC : il voit une adresse disparue (donc à détruire) et une adresse nouvelle (donc à créer). Extraire des ressources dans un module sans précaution, c'est donc demander la destruction et la recréation de tout ce qu'on a déplacé. Pour un réseau privé, cela signifie couper les instances, la base et le répartiteur qui y sont attachés. Le bloc moved existe pour éviter cela.
En pratique
Voir le problème avant la solution
La meilleure façon de comprendre l'adresse d'état est de la modifier sur des ressources qui ne coûtent rien. Le bac à sable utilise random_pet (un nom aléatoire) et local_file (un fichier local) pour jouer le rôle du VPC et d'un fichier de configuration. Le module racine d'origine :
terraform {
required_version = ">= 1.11"
required_providers {
random = { source = "hashicorp/random", version = "~> 3.7" }
local = { source = "hashicorp/local", version = "~> 2.5" }
}
}
resource "random_pet" "nom" {
length = 2
separator = "-"
keepers = { env = "preprod" }
}
resource "local_file" "marqueur" {
filename = "${path.module}/sortie/${random_pet.nom.id}.txt"
content = "reseau de preprod\n"
}Après terraform init et terraform apply, l'état contient deux ressources :
$ terraform state list
local_file.marqueur
random_pet.nom
On extrait ces deux ressources dans modules/reseau/, avec une variable environnement et une sortie nom. Le fichier modules/reseau/main.tf reprend les deux blocs, en remplaçant la valeur en dur par var.environnement et path.module par path.root (le fichier est écrit dans le répertoire racine, pas dans celui du module) :
resource "random_pet" "nom" {
length = 2
separator = "-"
keepers = { env = var.environnement }
}
resource "local_file" "marqueur" {
filename = "${path.root}/sortie/${random_pet.nom.id}.txt"
content = "reseau de ${var.environnement}\n"
}Le module racine devient :
module "reseau" {
source = "./modules/reseau"
environnement = "preprod"
}
output "nom_reseau" {
value = module.reseau.nom
}Un premier piège se présente tout de suite : Terraform refuse de planifier tant que le module n'est pas installé.
$ terraform plan -no-color
Error: Module not installed
on main.tf line 9:
9: module "reseau" {
This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.
Il faut relancer terraform init à chaque ajout, suppression ou changement de source d'un module. Pour un module local, init ne copie rien : il enregistre dans .terraform/modules/modules.json que reseau pointe vers modules/reseau. Une modification ultérieure du code du module local est prise en compte sans nouvel init, puisque Terraform lit directement le répertoire.
$ terraform init -no-color
Initializing the backend...
Initializing modules...
- reseau in modules/reseau
Initializing provider plugins...
- Reusing previous version of hashicorp/random from the dependency lock file
- Reusing previous version of hashicorp/local from the dependency lock file
...
Voyons maintenant le plan, sans autre précaution :
$ terraform plan -no-color
...
# local_file.marqueur will be destroyed
# (because local_file.marqueur is not in configuration)
# random_pet.nom will be destroyed
# (because random_pet.nom is not in configuration)
# module.reseau.local_file.marqueur will be created
# module.reseau.random_pet.nom will be created
Plan: 2 to add, 0 to change, 2 to destroy.
Terraform propose bien de détruire les deux ressources de la racine et de créer deux nouvelles dans le module. Avec des random_pet et des fichiers, personne ne s'en plaindrait. Avec un VPC, un réseau privé et leurs dépendants, ce plan est un incident en puissance. (Notez aussi que random_pet générerait un nouveau nom : l'ancien objet est détruit, pas déplacé.)
Dire à Terraform que c'est le même objet : moved
Le bloc moved déclare, dans le code, que l'adresse a changé. On l'écrit à côté de l'appel du module, dans un fichier moved.tf par exemple :
moved {
from = random_pet.nom
to = module.reseau.random_pet.nom
}
moved {
from = local_file.marqueur
to = module.reseau.local_file.marqueur
}Le plan change du tout au tout :
$ terraform plan -no-color
...
Terraform will perform the following actions:
# local_file.marqueur has moved to module.reseau.local_file.marqueur
resource "local_file" "marqueur" {
id = "c30cb1344c233efc759220540755d41cdc6bf7f2"
# (10 unchanged attributes hidden)
}
# random_pet.nom has moved to module.reseau.random_pet.nom
resource "random_pet" "nom" {
id = "notable-slug"
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ nom_reseau = "notable-slug"
0 to add, 0 to change, 0 to destroy : les deux ressources ont conservé leur identifiant (notable-slug), seul leur adresse a été réécrite dans l'état. Le nom aléatoire est le même qu'avant l'extraction, preuve qu'il s'agit du même objet. Après terraform apply, l'état est :
$ terraform state list
module.reseau.local_file.marqueur
module.reseau.random_pet.nom
$ terraform plan -no-color | tail -4
No changes. Your infrastructure matches the configuration.
Le bloc se relit dans le plan, peut rester dans le code après l'apply (il protège les autres environnements qui n'ont pas encore été appliqués) et s'enchaîne (a vers b, puis b vers c). Détails dans la leçon 11 du premier cours.
L'alternative est terraform state mv, qui fait la même chose de façon impérative, sans relecture ni trace dans le dépôt. Pour une équipe, préférez toujours moved : il se relit dans la demande de fusion et se rejoue dans chaque environnement.
Extraire le réseau de Signalements
Revenons au vrai dépôt. Le réseau tient dans reseau.tf. On crée modules/reseau/ avec la structure standard (détaillée dans la leçon suivante) : main.tf, variables.tf, outputs.tf, versions.tf.
Les variables : seulement ce qui varie d'un appel à l'autre.
# modules/reseau/variables.tf
variable "environnement" {
description = "Nom court de l'environnement (preprod, prod)."
type = string
}
variable "sous_reseau" {
description = "Plage CIDR du réseau privé."
type = string
default = "172.16.20.0/22"
}
variable "adresses_instances" {
description = "Adresse privée réservée par instance, indexée par nom d'instance."
type = map(string)
}
variable "adresse_repartiteur" {
description = "Adresse privée réservée au répartiteur de charge."
type = string
default = "172.16.20.5"
}
variable "etiquettes" {
description = "Étiquettes appliquées à toutes les ressources."
type = list(string)
default = []
}Le code reprend les ressources du premier cours, en remplaçant les références à local.* par des variables :
# modules/reseau/main.tf
resource "scaleway_vpc" "principal" {
name = "vpc-signalements"
tags = var.etiquettes
}
resource "scaleway_vpc_private_network" "app" {
name = "pn-signalements"
vpc_id = scaleway_vpc.principal.id
tags = var.etiquettes
ipv4_subnet {
subnet = var.sous_reseau
}
}
resource "scaleway_ipam_ip" "app" {
for_each = var.adresses_instances
address = each.value
tags = var.etiquettes
source {
private_network_id = scaleway_vpc_private_network.app.id
}
}
resource "scaleway_ipam_ip" "lb" {
address = var.adresse_repartiteur
tags = var.etiquettes
source {
private_network_id = scaleway_vpc_private_network.app.id
}
}Les sorties publient ce dont les autres parties de la configuration ont besoin, et rien de plus :
# modules/reseau/outputs.tf
output "private_network_id" {
description = "Identifiant du réseau privé, à attacher aux instances, à la base et au répartiteur."
value = scaleway_vpc_private_network.app.id
}
output "ip_ids_instances" {
description = "Identifiants IPAM des adresses réservées, indexés par nom d'instance."
value = { for nom, ip in scaleway_ipam_ip.app : nom => ip.id }
}
output "ip_id_repartiteur" {
description = "Identifiant IPAM de l'adresse du répartiteur."
value = scaleway_ipam_ip.lb.id
}Et la déclaration des fournisseurs requis, sans configuration (voir plus bas pourquoi) :
# modules/reseau/versions.tf
terraform {
required_version = ">= 1.11"
required_providers {
scaleway = {
source = "scaleway/scaleway"
version = ">= 2.84, < 3.0"
}
}
}Dans le module racine, l'appel remplace les anciens blocs :
module "reseau" {
source = "./modules/reseau"
environnement = var.environnement
adresses_instances = { for nom, i in local.instances : nom => i.adresse }
etiquettes = local.etiquettes
}Les autres fichiers (calcul.tf, base.tf, repartiteur.tf) qui référençaient scaleway_vpc_private_network.app.id référencent désormais module.reseau.private_network_id, et les instances scaleway_ipam_ip.app[each.key].id deviennent module.reseau.ip_ids_instances[each.key]. Cette étape est la plus longue de l'extraction : chaque référence directe à une ressource du module doit passer par une sortie, ce qui fait apparaître sans ménagement tout couplage caché entre le réseau et le reste. On le repère à l'erreur Unsupported attribute, jamais à une destruction.
Reste à écrire les moved. Chaque ressource du réseau change d'adresse. Pour une ressource avec for_each, un seul bloc suffit : Terraform déplace toutes les instances de l'ensemble (scaleway_ipam_ip.app["sig-app-1"], scaleway_ipam_ip.app["sig-app-2"]...) en conservant leurs clés.
# moved.tf
moved {
from = scaleway_vpc.principal
to = module.reseau.scaleway_vpc.principal
}
moved {
from = scaleway_vpc_private_network.app
to = module.reseau.scaleway_vpc_private_network.app
}
moved {
from = scaleway_ipam_ip.app
to = module.reseau.scaleway_ipam_ip.app
}
moved {
from = scaleway_ipam_ip.lb
to = module.reseau.scaleway_ipam_ip.lb
}Ce code a été vérifié avec terraform init et terraform validate (fournisseur scaleway/scaleway 2.84.0), pas avec un plan : aucun plan n'a été exécuté sur une infrastructure réelle pour cette leçon. Quand vous le faites sur la vôtre, le critère de réussite est unique : le plan annonce has moved pour chaque ressource et 0 to destroy. Si une ligne will be destroyed apparaît, arrêtez-vous et cherchez le moved manquant, ne lancez pas l'apply.
Warning
La passerelle publique et son bastion, la clé SSH et les instances restent pour l'instant dans le module racine. Ne déplacez pas tout d'un coup : une extraction par module, avec un plan vérifié et un apply à chaque fois, rend chaque étape réversible. La leçon 2 ajoute la passerelle au module sous forme d'option.
Le bénéfice se mesure au second appel : module "reseau_client_b" avec sous_reseau = "172.16.24.0/22" crée un second réseau sans inventer de nom de ressource (module.reseau_client_b.scaleway_vpc.principal). Les noms Scaleway écrits en dur (vpc-signalements) seraient en double : tout nom qui doit être unique devient une variable (leçon 2).
Sous le capot
Terraform ne voit pas les modules. Au chargement, Terraform lit le module racine, installe les enfants, puis construit un seul graphe de ressources où chaque ressource porte son chemin de module. Les frontières de module sont une notion d'organisation et de portée des noms, pas d'exécution : les ressources de deux modules différents sont créées en parallèle si rien ne les relie, et une dépendance entre une ressource et une variable d'entrée de module se traduit par une dépendance entre la ressource de l'appelant et toutes les ressources qui utilisent cette variable dans le module.
C'est la raison pour laquelle depends_on sur un bloc module est à manier avec précaution. Écrire depends_on = [scaleway_iam_ssh_key.equipe] sur le module signifie que toutes ses ressources attendent la clé SSH, et que toutes les valeurs lues dans le module sont considérées comme inconnues à la planification tant que la clé a un changement en attente. Ce n'est pas faux, mais c'est large : préférez passer une valeur réellement utilisée en entrée (l'identifiant de la clé), ce qui crée une dépendance précise.
terraform init installe les modules. Il résout chaque source, télécharge le code si nécessaire dans .terraform/modules/ et écrit .terraform/modules/modules.json, qui est la table de correspondance entre l'appel (reseau) et son répertoire. Pour un module local, il n'y a rien à copier. Pour une source distante (leçon 3), init clone ou télécharge, et les plans suivants lisent la copie locale.
Le bloc moved est une instruction de plan. Terraform le lit au début de la planification : avant de comparer la configuration à l'état, il réécrit les adresses de l'état selon les moved, en mémoire. Ensuite la comparaison se fait sur les adresses déjà corrigées, d'où le « 0 to destroy ». C'est aussi pourquoi un moved périmé (ressource déjà déplacée) est sans effet et n'est pas une erreur : si l'adresse from n'existe plus dans l'état, le bloc est ignoré. Terraform vérifie en revanche que les blocs ne forment pas de cycle et que to existe dans la configuration.
Les fournisseurs descendent dans l'arbre. Un module enfant n'a pas besoin de configurer scaleway : il hérite implicitement de la configuration par défaut du module racine pour chaque fournisseur qu'il déclare dans required_providers. La leçon 2 y revient, car c'est une règle de conception à part entière.
Pièges courants
Extraire sans moved. C'est le piège principal. Le plan montre des destructions et des créations, pas d'erreur : rien n'empêche de l'appliquer. Prenez l'habitude de chercher la ligne Plan: en premier et de refuser tout plan d'extraction qui annonce des destructions.
Un chemin relatif qui change de sens. path.module désigne le répertoire du module qui contient l'expression, path.root celui du module racine, et path.cwd le répertoire de travail. Un file("${path.module}/cloud-init.yaml") écrit dans un module enfant cherche dans le répertoire du module, pas dans celui de la racine. Dans la démonstration plus haut, un path.module oublié aurait écrit les fichiers dans modules/reseau/sortie/.
Des sorties qui exposent trop ou trop peu. Une sortie qui renvoie la ressource entière (value = scaleway_vpc_private_network.app) lie les appelants à tous ses attributs, et le moindre changement du fournisseur casse leurs expressions ; une sortie par identifiant utile (private_network_id) est un contrat plus étroit et plus durable.
Le module qui devine ce qu'il ne sait pas. Si un module code en dur vpc-signalements et qu'on l'appelle deux fois dans le même projet Scaleway, le second appel échoue ou, pire, réussit avec un nom ambigu. Tout nom qui doit être unique dans le projet vient d'une variable.
Sécurité
- Un module est du code exécuté avec vos droits. Ses ressources sont créées avec les identifiants du fournisseur de la racine. Un module local est revu comme le reste du dépôt ; un module externe (leçon 3) peut créer une clé d'API, ouvrir un groupe de sécurité ou envoyer des données ailleurs. Le périmètre du risque est celui de la clé d'API qui applique.
- Les valeurs sensibles traversent les frontières. Une variable marquée
sensitive = truereste masquée quand elle passe d'un module à l'autre, mais une valeur sensible calculée dans un module et exposée par une sortie doit aussi être marquéesensitive = truesur l'output, sinon Terraform refuse de la publier. Et l'état en garde la valeur en clair, quoi que vous marquiez (voir le premier cours et la leçon Les secrets hors de l'état de ce cours). - Les sorties sont un contrat de sécurité. Un module qui ne publie pas le mot de passe de la base ne risque pas de le voir dans le plan d'un autre module. Publiez le moins possible.
- L'extraction est une modification d'état. Faites-la sur la préproduction d'abord, et gardez la sauvegarde de l'état (le versionnement du bucket de la leçon 7) : un
movedmal écrit est détecté par le plan, mais une extraction mal relue peut se terminer par un apply destructeur.
En production
Quand ne pas faire de module
Un module est une abstraction, et l'abstraction a un coût : un niveau de plus à lire, une interface à maintenir, un risque de compatibilité. Brikman et Morris s'accordent sur ce point : on extrait quand on a vu la duplication, pas quand on l'imagine. Quelques critères pratiques.
- Un seul appelant, aucun projet de second. Un module appelé une fois n'apporte que de l'indirection. Le découpage par fichiers du premier cours suffit.
- Le module est une enveloppe fine autour d'une seule ressource. Un module
bucketqui expose les mêmes arguments quescaleway_object_bucketoblige à relire la documentation du fournisseur et celle du module. Il n'apporte de valeur que s'il impose une politique (chiffrement, étiquettes, versionnement activé) que la ressource seule ne garantit pas. - Le module a autant de variables que la ressource a d'arguments. C'est le signe d'une enveloppe, pas d'une abstraction. Un bon module a des entrées de plus haut niveau que ses ressources : « une application, ses instances et leur pare-feu » plutôt que « une instance et ses 25 arguments ».
- Les cas particuliers s'accumulent. Quand le module commence à porter des
count = var.avec_x ? 1 : 0pour dix options, c'est que l'on a fusionné deux modules différents. - Le cycle de vie diffère. Une base de données change deux fois par an, une instance applicative à chaque déploiement : mettre les deux dans le même module les rend solidaires. C'est le sujet de la leçon 4.
La règle de trois est un bon repère : une première occurrence s'écrit directement, la seconde se copie en le sachant, la troisième se transforme en module avec l'expérience des deux premières. Pour le réseau de Signalements, la deuxième application de Lyneko a servi de déclencheur.
Ce que coûte un module dans le temps
- Un module est un contrat et un point de revue : changer une variable ou une sortie casse les appelants (leçon 3), et une modification de
modules/reseau/touche tous les environnements, d'où un plan par environnement dans la demande de fusion. - Le nom du module entre dans l'état. Renommer l'appel
module "reseau"enmodule "network"change toutes les adresses : il faut unmovedde module à module (from = module.reseau,to = module.network).
OpenTofu
Les blocs module et moved fonctionnent de la même façon dans OpenTofu. Une différence notable pour la suite : depuis OpenTofu 1.8, la source et la version d'un module peuvent contenir des variables et des valeurs locales, ce que Terraform n'a autorisé que bien plus tard et avec une contrainte (variables const, leçon 3).
Exercices
Exercice 1 : lire un plan d'extraction
Un collègue a extrait scaleway_object_bucket.pieces_jointes dans un module stockage, sans bloc moved. Le plan annonce Plan: 1 to add, 0 to change, 1 to destroy. Que va-t-il se passer si on l'applique, et que faut-il ajouter pour obtenir 0 to destroy ?
Solution
Terraform va détruire le bucket existant (adresse scaleway_object_bucket.pieces_jointes, absente de la configuration) et en créer un nouveau (adresse module.stockage.scaleway_object_bucket.pieces_jointes). Si le bucket contient des pièces jointes, la destruction échoue tant qu'il n'est pas vide, ou pire, il est vidé avant (selon force_destroy). Dans tous les cas, le nom étant globalement unique, la recréation peut aussi échouer.
Il faut déclarer le déplacement :
moved {
from = scaleway_object_bucket.pieces_jointes
to = module.stockage.scaleway_object_bucket.pieces_jointes
}Si le bucket a des ressources associées (versionnement, règles de cycle de vie), chacune a besoin de son propre bloc moved. On vérifie en relançant terraform plan : chaque ressource annonce has moved, et le bilan est 0 to add, 0 to change, 0 to destroy.
Exercice 2 : décider
Pour chacun de ces cas, dites s'il faut un module : (a) la définition d'un groupe de sécurité utilisé par une seule application, (b) le trio VPC, réseau privé et adresses IPAM, nécessaire à chaque nouvelle application cliente, (c) un module qui expose exactement les arguments de scaleway_object_bucket.
Solution
(a) Non : un seul appelant, pas de duplication constatée ; un fichier securite.tf suffit. (b) Oui : plusieurs appelants prévisibles, un rôle clair (« donner un réseau privé adressé à une application »), des ressources qui vivent et changent ensemble. (c) Non : c'est une enveloppe sans valeur ajoutée. Elle ne devient utile que si elle impose une politique, par exemple le versionnement activé, le chiffrement et des étiquettes obligatoires, et que ses variables sont plus restreintes que celles de la ressource.
Récapitulatif
- Un module est un répertoire de fichiers
.tf. Celui où l'on lance Terraform est le module racine ; il appelle des modules enfants avec un blocmoduleet unsource. - L'interface d'un module se réduit à ses variables, ses sorties et les fournisseurs qu'il déclare ; tout le reste est privé.
- Dans l'état, le chemin du module fait partie de l'adresse :
module.reseau.scaleway_vpc.principal. Changer le chemin sans le dire à Terraform revient à détruire puis recréer. terraform initinstalle les modules : il est nécessaire après chaque ajout ou changement desource.- Les blocs
movedréécrivent les adresses avant la comparaison : une extraction réussie affichehas movedet0 to destroy; on les relit dans la demande de fusion et on peut les conserver. - Un module cache une décision de conception et se justifie par la duplication constatée et un rôle clair, pas par l'anticipation.
- Un module est un contrat : l'interface se maintient, les noms uniques viennent de variables, les sorties publient le strict nécessaire.
Pour aller plus loin
- Dans ce cours : la leçon 2 pour concevoir l'interface du module
reseau(types, valeurs par défaut, validation), la leçon 3 pour le publier avec un numéro de version. - La documentation HashiCorp Refactoring (blocs
moved,removed, déplacement entre modules et entre états), et la page Structure d'un module standard. - Yevgeniy Brikman, Terraform: Up & Running, chapitre 4 : modules et leurs limites (chemins de fichiers, blocs en ligne, versionnement).
- Kief Morris, Infrastructure as Code, chapitres sur la conception de composants : cohésion, couplage et taille des unités.
Sources
- HashiCorp, Terraform : modules
- HashiCorp, Terraform : structure standard d'un module
- HashiCorp, Terraform : refactoriser avec les blocs moved
- OpenTofu, documentation des modules
- Yevgeniy Brikman, Terraform: Up & Running (3e éd., O'Reilly, 2022), chapitre 4 : How to Create Reusable Infrastructure with Terraform Modules
- Kief Morris, Infrastructure as Code (3e éd., O'Reilly, 2025), partie sur la conception de composants d'infrastructure