Aller au contenu
Infrastructure as Code medium

Bonnes pratiques pour les modules Terraform

35 min de lecture

logo terraform

Un module réutilisable se reconnaît à ce qu'il ne décide pas à la place de son appelant. Il ne configure pas son provider, il reçoit ses dépendances au lieu de les fabriquer, et il documente son contrat. Ces trois règles ne relèvent pas du goût : elles ont des conséquences mesurables, et un module qui les ignore finit par être impossible à détruire proprement.

Tout ce qui suit a été exécuté sur Terraform v1.15.4 : messages d'erreur, avertissements et contenu du JSON du plan compris. Deux formulations répandues n'y survivent pas, celle du bloc provider interdit pour des raisons de confort, et celle de l'héritage automatique d'une configuration de provider.

  • Pourquoi un module ne contient pas de bloc provider, et ce qui casse sinon
  • Comment lui passer une configuration aliasée, des deux côtés
  • L'inversion de dépendance, cœur de la page officielle sur la composition
  • Pourquoi l'arbre des modules reste plat
  • Où lire la preuve de chaque pratique, sans relire le code

C'est la règle la plus citée, et la plus mal justifiée. Le motif n'est pas la souplesse, c'est le cycle de vie : « A provider configuration must always stay present in the overall Terraform configuration for longer than all of the resources it manages. » Un module qui embarque à la fois ses ressources et la configuration qui les gère disparaît d'un seul bloc, et il devient impossible de détruire proprement ce qu'il avait créé.

Terraform sanctionne d'ailleurs l'appel, dès que ce bloc provider configure réellement quelque chose :

Error: Module is incompatible with count, for_each, and depends_on
The module at module.cle 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.
If you also control the module "./mod", consider updating this module to
instead expect provider configurations to be passed by its caller.

Une nuance mesurée vaut d'être connue, car elle explique pourquoi le problème passe parfois inaperçu : un bloc provider vide ne produit qu'un avertissement, Warning: Redundant empty provider block. C'est le bloc qui porte des arguments qui rend le module incompatible avec count, for_each et depends_on.

L'héritage implicite existe, mais il ne couvre que la configuration par défaut : « Aliased providers are never inherited automatically and must be passed explicitly using the providers argument. » Or c'est précisément l'aliasing que l'on invoque pour justifier l'interdiction du bloc provider, multi-région ou multi-compte. Il faut donc deux déclarations.

Côté module, l'attente se déclare dans required_providers :

terraform {
required_providers {
local = {
source = "hashicorp/local"
version = ">= 2.5"
configuration_aliases = [local.plaques]
}
}
}
resource "local_file" "plaque" {
provider = local.plaques
filename = "${path.root}/${var.repertoire}/${var.nom}.txt"
content = "plaque ${var.nom}\n"
}

Côté appelant, la configuration se passe :

provider "local" {
alias = "atelier"
}
module "plaque" {
source = "../bibliotheque/plaque"
providers = {
local.plaques = local.atelier
}
nom = "nord"
repertoire = "sorties"
}

Oubliez l'argument providers, et l'init s'arrête en nommant ce qui manque :

Error: Missing required provider configuration
The child module requires an additional configuration for provider
hashicorp/local, with the local name "local.secondaire".

C'est le mode d'emploi qui manque à la plupart des énoncés de la règle : interdire le bloc provider sans donner configuration_aliases et providers laisse le lecteur sans solution pour son cas multi-région.

C'est le cœur de la page officielle sur la composition, et le point qui distingue un module réutilisable d'un module captif : un module reçoit ce dont il dépend, il ne le fabrique pas.

# avant : le module decide
locals {
repertoire = "plaques"
}
# apres : l'appelant decide
variable "repertoire" {
type = string
description = "Repertoire ou ecrire la plaque."
}

Le bénéfice est concret, et il va plus loin qu'un paramètre de plus. L'appelant peut passer une ressource qu'il crée, ou une data source qui lit un existant, sans que le module change d'une ligne. Le module consul_cluster de l'exemple officiel accepte ainsi des identifiants de VPC et de sous-réseaux, il ne les crée pas.

Le module...Conséquence pour l'appelant
crée sa dépendanceil subit ce choix, et ne peut pas réutiliser l'existant
interroge sa dépendance par une data sourceil subit la requête, et les droits qu'elle exige
reçoit sa dépendance en entréeil décide, ressource neuve ou existante

La création conditionnelle relève du même principe : plutôt qu'un count caché dans le module, déclarez une variable d'objet et laissez l'appelant décider s'il crée l'objet ou en référence un existant.

La recommandation officielle est explicite : « we strongly recommend keeping the module tree flat, with only one level of child modules ». Les relations entre modules passent par des expressions entre appels, comme entre ressources, pas par une hiérarchie profonde.

module "reseau" {
source = "./modules/reseau"
}
module "machine" {
source = "./modules/machine"
reseau_id = module.reseau.id
}

Un arbre profond coûte deux fois : la lecture, puisqu'il faut descendre pour comprendre ce qui est créé, et la modification, puisqu'une entrée nouvelle doit être relayée à chaque étage.

La Standard Module Structure range le README.md dans le minimum, aux côtés de main.tf, variables.tf et outputs.tf. Ce n'est pas une politesse : c'est le fichier que le registre et les générateurs de documentation exploitent, et la doc précise qu'il n'a pas besoin de lister les entrées et sorties, l'outillage s'en chargeant.

Deux règles complètent le contrat. Un module réutilisable ne contraint que son plancher de version, la borne haute appartenant au module racine. Et ce qu'un module garantit se déclare : les validation couvrent les entrées, les precondition et postcondition couvrent les invariants de ce qu'il produit.

Une liste de bonnes pratiques se coche à la main, ce qui la rend invérifiable en revue. Le JSON du plan, lui, expose la configuration telle que Terraform l'a comprise.

Fenêtre de terminal
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | jq '.configuration.provider_config'
{
"local": { "name": "local", "full_name": "registry.terraform.io/hashicorp/local" },
"module.plaque:local": {
"name": "local",
"full_name": "registry.terraform.io/hashicorp/local",
"module_address": "module.plaque"
}
}

Le champ module_address n'apparaît que pour une configuration déclarée dans un module : c'est le détecteur exact de la violation, et il tient en une ligne de jq. À l'inverse, une configuration aliasée passée par l'appelant apparaît au niveau racine, avec son alias.

Ce que vous voulez vérifierOù le lire
aucun provider configuré dans un moduleprovider_config[].module_address absent
quelle configuration sert quel modulemodule_calls[].module.resources[].provider_config_key
l'interface réelle du modulemodule_calls[].module.variables
ce que l'appelant passe vraimentmodule_calls[].expressions
ce que le module gère ou litmode valant managed ou data
  1. Une seule responsabilité par module, et un nom qui la dit.

  2. Des entrées typées et validées, avec un default pour tout ce qui a une valeur raisonnable, et nullable = false là où le défaut doit toujours s'appliquer.

  3. Des sorties utiles, avec description et type, car elles sont la seule chose que l'appelant voit.

  4. Un module « data only » quand le besoin est de lire une infrastructure : il encapsule la requête, et permet de basculer vers un remote state sans toucher aux consommateurs.

  5. Des exemples sous examples/, dont le source pointe l'adresse externe du module, celle qu'un appelant emploierait.

  6. Une version publiée par tag, et un CHANGELOG qui dit ce qui change.

SymptômeCause probableCorrection
Module is incompatible with count, for_each, and depends_onle module configure un providerretirer le bloc, déclarer configuration_aliases
Warning: Redundant empty provider blockbloc provider vide dans un modulele supprimer, il ne sert à rien
Missing required provider configurationl'appel ne passe pas l'argument providersajouter providers = { ... }
le module ne se laisse pas détruire proprementconfiguration de provider partie avec le moduleremettre la configuration à la racine
une entrée nouvelle doit être relayée sur trois étagesarbre de modules profondaplatir, relier par des expressions
l'appelant ne peut pas réutiliser un existantle module crée sa dépendancela transformer en entrée

Le lab bonnes pratiques des modules remet un module « legacy » : il configure son provider et décide seul de son répertoire de sortie. Il faut le rendre composable, puis faire produire au projet deux plaques depuis un seul appel. Les tests ne lisent aucun .tf : ils cherchent module_address dans le JSON du plan, vérifient l'alias passé par l'appelant, comparent l'interface du module à ce que l'appel lui transmet, et lisent les deux instances dans l'état. Il se joue hors ligne.

  • Un module ne configure pas son provider : la configuration doit survivre aux ressources qu'elle gère.
  • Un bloc provider vide ne fait qu'avertir ; un bloc configuré interdit count, for_each et depends_on.
  • Une configuration aliasée ne s'hérite jamais : configuration_aliases côté module, providers côté appel.
  • Un module reçoit ses dépendances, il ne les fabrique pas : l'appelant garde le choix entre créer et réutiliser.
  • L'arbre des modules reste plat, les relations passant par des expressions.
  • Le README.md fait partie du minimum officiel, et l'outillage s'appuie dessus.
  • Un module réutilisable ne contraint que son plancher de version.
  • Chaque pratique se prouve dans le JSON du plan : module_address, provider_config_key, variables, expressions, mode.

Les questions ci-dessous portent sur ce qui casse en pratique : le module qu'on ne peut pas appeler deux fois, la configuration de provider qui ne s'hérite pas, et la revue qu'on voudrait automatiser.

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