Aller au contenu
Infrastructure as Code medium

Créer un module Terraform : votre premier code réutilisable

30 min de lecture

logo terraform

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.

  • É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

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 :

TermeCe que c'est
Module racinele répertoire où vous lancez terraform plan ou apply
Module enfantun module appelé par un autre, via un bloc module
Bloc modulel'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.

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é.

Un module minimal tient en trois éléments : des variables en entrée, des ressources, des outputs en sortie.

modules/compteur/main.tf
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.

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/compteur

C'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 = 56

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.

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 :

ArgumentRôle
sourceobligatoire, où trouver le module. Chaîne littérale
versioncontrainte de version, pour un module de registre
countcrée N instances du module
for_eachcrée une instance par élément d'une map ou d'un set
providerspasse explicitement des configurations de provider
depends_ondépendance explicite entre le module et autre chose
ignore_nested_deprecationstait 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.

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.releve
module.compteur["nord"].random_integer.tirage
module.compteur["sud"].local_file.releve
module.compteur["sud"].random_integer.tirage
valeurs = {
"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.

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 moduleCe qui vit dans la racine
required_providers : source et versionles 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 local
provider 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.

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 :

  1. 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 sur Provider configuration not present.

  2. L'appelant la lui passe

    module "compteur" {
    source = "./modules/compteur"
    for_each = var.ateliers
    providers = {
    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.

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.

Les messages ci-dessous se lisent dans l'ordre où ils apparaissent : chacun désigne l'étape suivante du câblage.

SymptômeCause probableSolution
Module not installedUn source ajouté ou modifié sans initRelancer terraform init
Module is incompatible with count, for_each, and depends_onLe module enfant configure un providerRetirer ses blocs provider
Provider configuration not presentLe module référence un alias non déclaréAjouter configuration_aliases
Missing required provider configurationL'appelant ne passe pas l'alias attenduAjouter l'argument providers
No value for required variableUne variable du module sans defaultLa renseigner dans le bloc module
Unsupported argumentUn argument absent des variables du moduleVérifier les noms déclarés dans le module
L'output du module est introuvableLa valeur n'est pas exposée en outputAjouter le bloc output dans le module

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.

  • 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 module accepte sept arguments, dont for_each, providers et depends_on, pas seulement source.
  • Un seul bloc suffit pour plusieurs instances, avec for_each ou count : dupliquer le bloc est le cas particulier.
  • Un module déclare ses required_providers mais 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_each et depends_on sur son appel.
  • Une configuration aliasée se déclare par configuration_aliases côté module, et se passe par providers côté appelant.
  • Tout ne mérite pas un module : un simple emballage autour d'une seule ressource n'apporte aucune abstraction.

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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn