
Un module Terraform est un répertoire de fichiers .tf, appelé depuis un
autre grâce au bloc module. Vous en utilisez déjà un sans le savoir : votre
projet est le module racine. Ce guide s'adresse à qui veut cesser de
copier-coller les mêmes ressources d'un projet à l'autre : vous allez écrire
un module, l'appeler, lire ce qu'il expose, l'instancier plusieurs
fois avec un seul bloc, et lui passer les configurations de provider dont
il a besoin.
Tous les exemples et toutes les sorties de ce guide ont été exécutés sur
Terraform v1.15.4 avec les providers local, random et tls :
aucun compte cloud n'est nécessaire pour les rejouer.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire un module minimal : entrées, ressources, sorties
- L'appeler depuis la racine, et lire les adresses qu'il produit
- Connaître les sept arguments du bloc
module - L'instancier plusieurs fois avec
for_each, plutôt que dupliquer - Fournir les configurations de provider, y compris aliasées
Prérequis
Section intitulée « Prérequis »- Terraform 1.11 ou plus récent (installer Terraform).
- Savoir déclarer une ressource et une variable.
- La structure d'un projet aide à s'y retrouver, sans être obligatoire.
Qu'est-ce qu'un module Terraform ?
Section intitulée « Qu'est-ce qu'un module Terraform ? »Un répertoire contenant des fichiers .tf. Il n'y a rien d'autre à
comprendre : pas de format spécial, pas de déclaration d'appartenance.
Trois termes suffisent ensuite à parler du sujet sans se tromper :
| Terme | Ce que c'est |
|---|---|
| Module racine | le répertoire où vous lancez terraform plan ou apply |
| Module enfant | un module appelé par un autre, via un bloc module |
Bloc module | l'instruction qui appelle un module enfant |
Chaque projet Terraform est donc déjà un module. Écrire un module enfant revient à sortir des ressources de la racine, en décidant ce qu'elles reçoivent et ce qu'elles exposent.
Pourquoi factoriser en module ?
Section intitulée « Pourquoi factoriser en module ? »Pour écrire une fois ce que vous déployez plusieurs fois. Trois environnements qui ne diffèrent que par une taille et un nom n'ont aucune raison d'exister en trois copies : la copie diverge, on corrige un défaut dans deux fichiers sur trois, et le troisième reste cassé.
Écrire un premier module
Section intitulée « Écrire un premier module »Un module minimal tient en trois éléments : des variables en entrée, des ressources, des outputs en sortie.
terraform { # Un module declare de quels providers il a besoin. Il ne les CONFIGURE pas : # cette difference fait l'objet de la derniere partie du guide. required_providers { local = { source = "hashicorp/local", version = ">= 2.5" } random = { source = "hashicorp/random", version = ">= 3.6" } }}
# Sans `default`, une variable est OBLIGATOIRE : l'appelant ne peut pas oublier# de la fournir.variable "libelle" { description = "Libelle du compteur, sert de nom de fichier." type = string}
resource "random_integer" "tirage" { min = 1 max = 99}
resource "local_file" "releve" { filename = "${path.root}/releves/${var.libelle}.txt" content = "${var.libelle} : ${random_integer.tirage.result}\n"}
# Un output est la SEULE chose qu'un module rend visible a son appelant.output "valeur" { description = "Valeur tiree pour ce compteur." value = random_integer.tirage.result}La documentation recommande une structure minimale de quatre fichiers,
README.md, main.tf, variables.tf et outputs.tf, le README étant
explicitement attendu : « The root module and any nested modules should
have README files ». Le détail de ce découpage est traité dans
la structure standard d'un module.
Appeler le module depuis la racine
Section intitulée « Appeler le module depuis la racine »Le bloc module prend un label, un source, et les valeurs des
variables du module :
module "compteur" { source = "./modules/compteur"
libelle = "atelier"}
output "valeur_atelier" { value = module.compteur.valeur}terraform init installe le module comme il installerait un provider, et le
dit :
Initializing modules...- compteur in modules/compteurC'est pour cette raison qu'un init est nécessaire après chaque ajout ou
modification d'un source. L'apply préfixe alors les adresses par
module.<label>. :
module.compteur.random_integer.tirage: Creation complete after 0s [id=56]module.compteur.local_file.releve: Creation complete after 0s [id=61a2097a...]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
valeur_atelier = 56Lire ce que le module expose
Section intitulée « Lire ce que le module expose »Uniquement ses outputs. Les ressources d'un module enfant sont opaques
depuis l'appelant : sans le bloc output "valeur", la racine ne pourrait pas
lire random_integer.tirage.result. La syntaxe d'accès reprend le label du
bloc :
module.<label>.<nom_de_l_output>C'est cette frontière qui fait d'un module une interface : vous pouvez en changer l'intérieur tant que les entrées et les sorties ne bougent pas. Cette interface est détaillée dans variables et outputs d'un module.
Les sept arguments du bloc module
Section intitulée « Les sept arguments du bloc module »Le bloc en accepte sept, et on n'en cite couramment que deux. Les cinq autres sont exactement ceux qui font la différence sur un projet réel :
| Argument | Rôle |
|---|---|
source | obligatoire, où trouver le module. Chaîne littérale |
version | contrainte de version, pour un module de registre |
count | crée N instances du module |
for_each | crée une instance par élément d'une map ou d'un set |
providers | passe explicitement des configurations de provider |
depends_on | dépendance explicite entre le module et autre chose |
ignore_nested_deprecations | tait les avertissements de dépréciation internes (1.15) |
L'argument source mérite une précision : la documentation en liste neuf
formes, du chemin local au bucket S3, en passant par le registre
Terraform, GitHub, Git générique, BitBucket, Mercurial, une URL HTTP et un bucket
GCS. Les deux cas les plus fréquents sont traités par
le module local
et le module de registre.
Instancier un module plusieurs fois
Section intitulée « Instancier un module plusieurs fois »Le réflexe est de dupliquer le bloc. La documentation recommande l'inverse :
« You can configure Terraform to provision multiple instances of the same module
resources in one module block, instead of adding multiple blocks to
your configuration. »
variable "ateliers" { type = set(string) default = ["nord", "sud"]}
module "compteur" { source = "./modules/compteur" for_each = var.ateliers
libelle = each.key}
output "valeurs" { value = { for nom, instance in module.compteur : nom => instance.valeur }}Les adresses portent désormais la clé, et module.compteur devient une
map que l'on parcourt :
module.compteur["nord"].local_file.relevemodule.compteur["nord"].random_integer.tiragemodule.compteur["sud"].local_file.relevemodule.compteur["sud"].random_integer.tiragevaleurs = { "nord" = 17 "sud" = 24}Un seul bloc, deux instances, et l'ajout d'un atelier se résume à une entrée de plus dans la variable.
D'où viennent les providers d'un module ?
Section intitulée « D'où viennent les providers d'un module ? »De son appelant. C'est la règle la plus structurante du sujet, et elle explique tout le reste : « Only provider configurations are inherited by child modules, not provider source or version requirements. »
| Ce qui vit dans le module | Ce qui vit dans la racine |
|---|---|
required_providers : source et version | les blocs provider : la configuration |
Autrement dit, un module déclare ce dont il a besoin et hérite de la
configuration. Il n'en configure aucune lui-même, et la documentation est
catégorique : « A module intended to be called by one or more other modules
must not contain any provider blocks. »
Ce n'est pas une préférence de style. Un module qui configure ses providers
ne peut plus être instancié plusieurs fois, et Terraform le refuse dès
l'init :
Error: Module is incompatible with count, for_each, and depends_on
The module at module.compteur is a legacy module which contains its own localprovider configurations, and so calls to it may not use the count, for_each,or depends_on arguments.Le terme employé, legacy, dit bien qu'il s'agit d'un héritage à corriger.
Passer une configuration de provider aliasée
Section intitulée « Passer une configuration de provider aliasée »L'héritage a une limite : il ne transmet que la configuration par défaut. Pour qu'un module utilise une configuration aliasée, il faut la lui déclarer, puis la lui passer. Deux étapes, deux fichiers :
-
Le module déclare ce qu'il attend
modules/compteur/versions.tf terraform {required_providers {local = {source = "hashicorp/local"version = ">= 2.5"configuration_aliases = [local.secondaire]}}}Sans cette ligne, une ressource du module portant
provider = local.secondaireéchoue surProvider configuration not present. -
L'appelant la lui passe
module "compteur" {source = "./modules/compteur"for_each = var.ateliersproviders = {local.secondaire = local.archivage}libelle = each.key}Sans cet argument, l'erreur devient
Missing required provider configuration: le module réclame ce que personne ne lui donne. À gauche le nom attendu par l'enfant, à droite la configuration de la racine.
Les providers que providers ne nomme pas restent hérités : mesuré sur
1.15.4, un argument partiel n'annule pas l'héritage des autres
configurations.
Épingler la version d'un module
Section intitulée « Épingler la version d'un module »Pour un module de registre, l'argument version accompagne toujours
source, et la documentation le recommande sans réserve : « We recommend
explicitly constraining the acceptable version numbers to avoid unexpected or
unwanted changes. »
module "reseau" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"}Une règle inverse s'applique à l'intérieur d'un module partagé : n'y
posez qu'une borne basse sur vos providers. La documentation est explicite,
« constrain only the minimum required provider version using a >=
constraint », afin de laisser vos utilisateurs choisir une version plus récente.
Les opérateurs sont détaillés dans
le versionnement des modules.
Dépannage
Section intitulée « Dépannage »Les messages ci-dessous se lisent dans l'ordre où ils apparaissent : chacun désigne l'étape suivante du câblage.
| Symptôme | Cause probable | Solution |
|---|---|---|
Module not installed | Un source ajouté ou modifié sans init | Relancer terraform init |
Module is incompatible with count, for_each, and depends_on | Le module enfant configure un provider | Retirer ses blocs provider |
Provider configuration not present | Le module référence un alias non déclaré | Ajouter configuration_aliases |
Missing required provider configuration | L'appelant ne passe pas l'alias attendu | Ajouter l'argument providers |
No value for required variable | Une variable du module sans default | La renseigner dans le bloc module |
Unsupported argument | Un argument absent des variables du module | Vérifier les noms déclarés dans le module |
| L'output du module est introuvable | La valeur n'est pas exposée en output | Ajouter le bloc output dans le module |
Mettre en pratique
Section intitulée « Mettre en pratique »Le lab créer un module remet un projet dont le module enfant contient une
configuration de provider héritée d'un vieux copier-coller, et dont l'init
échoue avant même le premier plan. Trois erreurs s'y enchaînent, chacune
désignant l'étape suivante, jusqu'à un module appelé trois fois par un seul
bloc, avec une configuration aliasée correctement transmise. Il se joue
hors ligne.
À retenir
Section intitulée « À retenir »- Un module est un répertoire de
.tf. Votre projet est déjà le module racine. - Un module reçoit des variables et n'expose que ses outputs : ses ressources sont opaques depuis l'appelant.
- Le bloc
moduleaccepte sept arguments, dontfor_each,providersetdepends_on, pas seulementsource. - Un seul bloc suffit pour plusieurs instances, avec
for_eachoucount: dupliquer le bloc est le cas particulier. - Un module déclare ses
required_providersmais ne configure aucun provider : les configurations sont héritées, les exigences jamais. - Un module qui configure ses providers devient legacy : Terraform refuse
count,for_eachetdepends_onsur son appel. - Une configuration aliasée se déclare par
configuration_aliasescôté module, et se passe parproviderscôté appelant. - Tout ne mérite pas un module : un simple emballage autour d'une seule ressource n'apporte aucune abstraction.
FAQ : questions fréquentes
Section intitulée « FAQ : questions fréquentes »Les questions ci-dessous portent sur ce qui bloque en pratique : l'init qui
refuse un module hérité, les providers qu'on croit devoir configurer
partout, et la façon d'appeler un module plusieurs fois.
Un répertoire, pas un format
| Terme | Ce que c'est |
|---|---|
| Module racine | le répertoire où vous lancez plan ou apply |
| Module enfant | un module appelé par un autre |
Bloc module |
l'instruction qui appelle un module enfant |
Un seul bloc, plusieurs instances
module "compteur" {
source = "./modules/compteur"
for_each = var.ateliers
libelle = each.key
}
La doc est explicite : « You can configure Terraform to provision multiple instances of the same module resources in one module block, instead of adding multiple blocks ».module.compteur["nord"].local_file.releve
module.compteur["sud"].local_file.releve
module.compteur devient une map que l'on parcourt : { for nom, instance in module.compteur : nom => instance.valeur }.Le module devient legacy
La documentation est catégorique : « A module intended to be called by one or more other modules must not contain any provider blocks ».Error: Module is incompatible with count, for_each, and depends_on
The module at module.compteur is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count, for_each,
or depends_on arguments.
Détail mesuré sur 1.15.4 : ce refus ne se déclenche que si le provider accepte une vraie configuration. Avec local ou random, qui n'ont aucun argument, Terraform se contente d'un avertissement Redundant empty provider block.Configurations héritées, exigences déclarées
La doc pose la frontière : « Only provider configurations are inherited by child modules, not provider source or version requirements ».| Dans le module | Dans la racine |
|---|---|
required_providers : source et version |
les blocs provider : la configuration |
configuration_aliases, puis passée par l'appelant avec providers.Déclarer, puis passer
# dans le module
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = ">= 2.5"
configuration_aliases = [local.secondaire]
}
}
}
# chez l'appelant
module "compteur" {
source = "./modules/compteur"
providers = {
local.secondaire = local.archivage
}
}
Deux erreurs jalonnent le chemin : Provider configuration not present si le module n'a rien déclaré, Missing required provider configuration s'il a déclaré mais que l'appelant ne passe rien. À gauche le nom attendu par l'enfant, à droite la configuration de la racine.Sept, pas deux
| Argument | Rôle |
|---|---|
source |
obligatoire, où trouver le module |
version |
contrainte de version, pour un module de registre |
count |
crée N instances du module |
for_each |
une instance par élément d'une map ou d'un set |
providers |
passe explicitement des configurations de provider |
depends_on |
dépendance explicite |
ignore_nested_deprecations |
tait les avertissements internes (1.15) |
source accepte neuf formes : chemin local, registre Terraform, GitHub, Git générique, BitBucket, Mercurial, URL HTTP, bucket S3 et bucket GCS.init installe aussi les modules
Initializing modules...
- compteur in modules/compteur
Un init est nécessaire après chaque ajout de bloc module et après chaque modification d'un source. Sinon :Error: Module not installed
En revanche, modifier le contenu d'un module local déjà installé ne demande rien : le chemin pointe directement sur vos fichiers, et le plan voit le changement.Pour aller plus loin
Section intitulée « Pour aller plus loin »- Variables et outputs d'un module : concevoir l'interface, valider les entrées.
- Bonnes pratiques pour les modules : ce qui distingue un module tenable d'un module jetable.
- Module local partagé : consommer le module depuis plusieurs projets.
- Développer un module : la référence officielle : la réserve sur les emballages et les conventions de structure.
- Le bloc module : les sept arguments et les neuf formes de
source. - Providers dans les modules : l'héritage, les alias et
configuration_aliases.