
Un anti-pattern de module se reconnaît rarement à sa tête : le code compile,
le plan passe, l'infrastructure sort. Il se reconnaît à son symptôme, et
celui-ci arrive toujours tard, le jour du deuxième appel, du destroy, ou de
la reprise par quelqu'un d'autre. Ce guide part donc du symptôme, mesuré, et
remonte à la correction.
Tout ce qui suit a été exécuté sur Terraform v1.15.4 : messages d'erreur,
avertissements, résumés de plan et contenu du state compris. Deux affirmations
répandues n'y survivent pas, celle du provider embarqué que Terraform
accepterait sans rien dire, et celle du bloc moved qui corrigerait presque
tout.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Le symptôme de chaque anti-pattern, et à quel moment il tombe
- Pourquoi un provider embarqué n'est pas qu'une gêne de style
- Comment refactorer une configuration déjà appliquée sans rien détruire
- Ce que
movedne corrigera jamais, malgré sa réputation - L'arbre profond, l'anti-pattern le mieux documenté et le moins nommé
Prérequis
Section intitulée « Prérequis »- Terraform 1.11 ou plus récent (installer Terraform).
- Le pendant positif de ce guide, les bonnes pratiques des modules.
- Les guides qui donnent les formes correctes : le socle de fichiers standard, tester un module et versionner ses modules.
1. Le module God
Section intitulée « 1. Le module God »Un module qui fait tout ne se réutilise nulle part : réseau, machines, stockage et DNS dans un seul bloc, avec quarante variables pour en piloter les variantes. Le symptôme n'est pas une erreur, c'est une fourche : chaque nouveau cas ajoute une variable, chaque variable ajoute une condition.
La documentation ne fixe aucun seuil chiffré, et méfiez-vous des articles qui en donnent un. Le critère officiel est qualitatif : « A good module should raise the level of abstraction by describing a new concept in your architecture ».
2. Le provider embarqué dans le module
Section intitulée « 2. Le provider embarqué dans le module »Voici l'anti-pattern dont on lit le plus de choses fausses. Non, Terraform ne l'accepte pas en silence, et non, la correction ne se réduit pas à « supprimer le bloc ».
Ce que la mesure donne, sur trois cas distincts :
| Situation | Ce que fait Terraform |
|---|---|
bloc provider vide dans le module | Warning: Redundant empty provider block |
bloc configuré, appel sans count ni for_each | rien, aucun diagnostic |
bloc configuré, appel avec count, for_each ou depends_on | erreur immédiate |
Error: Module is incompatible with count, for_each, and depends_on
The module at module.cle 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 motif de l'interdiction est le cycle de vie, pas le confort, et la documentation le dit en une phrase : « A provider configuration must always stay present in the overall Terraform configuration for longer than all of the resources it manages. »
La correction complète tient en deux déclarations, l'appelant fournissant la configuration que le module déclare attendre :
# dans le moduleterraform { required_providers { local = { source = "hashicorp/local" version = ">= 2.5" configuration_aliases = [local.plaques] } }}# chez l'appelantmodule "plaque" { source = "../bibliotheque/plaque"
providers = { local.plaques = local.atelier }}3. La contrainte de version trop serrée dans un module partagé
Section intitulée « 3. La contrainte de version trop serrée dans un module partagé »Un module partagé qui écrit version = "~> 0.8" impose sa borne haute à tous
ses consommateurs. La règle officielle est l'inverse : « If you are writing a
shared Terraform module, constrain only the minimum required provider version
using a >= constraint. »
Le symptôme se mesure, et il est brutal :
- Finding hashicorp/local versions matching "~> 2.4.0, >= 2.9.0"...
Error: Failed to query available provider packages
Could not retrieve the list of available versions for providerhashicorp/local: no available releases match the given constraints ~> 2.4.0,>= 2.9.0Les contraintes du module et de la racine s'intersectent. Le consommateur est bloqué, et seul un nouveau tag du module le débloque.
4. Le copier-coller, et le refactoring qui détruit
Section intitulée « 4. Le copier-coller, et le refactoring qui détruit »Deux blocs presque identiques, puis trois : c'est l'anti-pattern le plus
banal. Sa correction, elle, est piégeuse, parce que Terraform ne suit pas
des ressources mais des adresses. Extraire local_file.plaque_nord vers
module.plaque["nord"].local_file.plaque change l'adresse, donc :
Plan: 4 to add, 0 to change, 4 to destroy.Le bloc moved déclare qu'une adresse en remplace une autre, et le plan change
de nature :
moved { from = local_file.plaque_nord to = module.plaque["nord"].local_file.plaque} # local_file.plaque_nord has moved to module.plaque["nord"].local_file.plaque
Plan: 0 to add, 0 to change, 0 to destroy.Un détail décide de la fiabilité de votre contrôle : le témoin que vous
regardez. L'id d'un local_file est un hachage de son contenu, donc
identique après une destruction suivie d'une recréation. Un random_pet, lui, est
tiré au hasard : s'il change, la ressource a bien été recréée. Choisissez
toujours comme témoin une valeur que l'outil ne sait pas recalculer.
5. Croire que moved corrige tout
Section intitulée « 5. Croire que moved corrige tout »L'erreur symétrique de la précédente. Un moved traite des adresses, et
rien d'autre :
| Défaut | moved le corrige ? |
|---|---|
| ressource déplacée, renommée, passée dans un module | oui |
| ressource gérée à transformer en source de données | non, la doc l'interdit explicitement |
| variable non typée, valeur figée dans le module | non, aucun rapport avec l'adressage |
bloc provider embarqué | non, et son retrait a son propre ordre |
La documentation est nette sur le deuxième cas, et c'est la limite la plus
mal connue du bloc : « You cannot use the moved block to change a managed
resource (a resource block) into a data resource. »
6. L'interface fourre-tout
Section intitulée « 6. L'interface fourre-tout »Une variable sans type accepte any. L'erreur d'appel ne se voit alors qu'au
moment où la valeur est utilisée, parfois jamais. Un objet explicite en
fait un contrat :
variable "plaque" { type = object({ etiquette = string intitule = string }) description = "Etiquette de la plaque et intitule a y inscrire."}Le bénéfice défensif est le plus visible, mais ce n'est pas le principal. Un
type d'objet décrit un concept, donc rend le module substituable : la doc
montre qu'un module de dns_records typé peut être remplacé par une autre
implémentation sans que ses appelants changent.
La description fait partie du même contrat. La Standard Module Structure
demande que « all variables and outputs should have one or two sentence
descriptions », et c'est la seule chose que l'outillage sache extraire.
7. L'arbre profond
Section intitulée « 7. L'arbre profond »C'est l'anti-pattern le mieux documenté, et le moins nommé : un module qui appelle un module qui appelle un module. La recommandation officielle est franche, « in most cases we strongly recommend keeping the module tree flat, with only one level of child modules ».
module "reseau" { source = "./modules/reseau"}
module "machine" { source = "./modules/machine"
reseau_id = module.reseau.id}Le coût d'un arbre profond est double : il faut descendre pour comprendre ce qui est réellement créé, et toute entrée nouvelle doit être relayée à chaque étage, qui n'en fait rien d'autre que la transmettre.
8. Le module qui fabrique ses dépendances
Section intitulée « 8. Le module qui fabrique ses dépendances »Cause commune de plusieurs points ci-dessus, et jamais nommée dans la plupart des listes : un module qui crée ou interroge lui-même ce dont il dépend impose ses choix. La doc appelle sa correction l'inversion de dépendance : « Instead of a module embedding its dependencies [...] the module receives its dependencies from the root module. »
Le cas particulier le plus fréquent est la création conditionnelle : « Rather than trying to write a module that itself tries to detect whether something exists and create it if not, we recommend applying the dependency inversion approach ». L'appelant décide, le module reçoit l'objet.
9. Le retrait brutal d'une sortie
Section intitulée « 9. Le retrait brutal d'une sortie »Supprimer un output casse tous les appelants qui le lisent, sans préavis.
Depuis la 1.15, il existe un chemin officiel de retrait progressif :
l'argument deprecated d'un bloc output, « only valid in child modules ».
output "ancien_chemin" { value = local_file.plaque.filename deprecated = "Utilisez la sortie chemin, celle-ci disparaitra en 2.0.0."}Le consommateur reçoit un avertissement au plan, et dispose du temps d'une version majeure pour migrer.
Récapitulatif
Section intitulée « Récapitulatif »| Anti-pattern | Symptôme | Correction |
|---|---|---|
| module God | chaque cas ajoute une variable | découper par concept, tester le nom |
| provider embarqué | Module is incompatible with count, for_each... | configuration_aliases plus providers |
| contrainte trop serrée | no available releases match the given constraints | ne contraindre que le minimum |
| copier-coller | trois blocs jumeaux | un module, un appel, for_each |
| refactoring destructeur | Plan: 4 to add, 4 to destroy | des blocs moved |
interface any | l'erreur tombe à l'usage, ou jamais | un objet typé et décrit |
| arbre profond | une entrée à relayer sur trois étages | aplatir, relier par expressions |
| dépendances fabriquées | impossible de réutiliser un existant | inversion de dépendance |
| sortie supprimée | les appelants cassent sans préavis | deprecated puis retrait |
Mettre en pratique
Section intitulée « Mettre en pratique »Le lab anti-patterns des modules remet un projet déjà appliqué, fait de
deux ressources copiées-collées. Il faut les extraire dans un module typé appelé
une seule fois, sans qu'aucune ne soit détruite. Le témoin du contrôle est un
jeton random_pet, précisément parce qu'il ne se recalcule pas : le même
refactoring sans blocs moved détruit et recrée les quatre ressources, et le lab
le voit. Il se joue hors ligne.
À retenir
Section intitulée « À retenir »- Un anti-pattern se reconnaît à son symptôme, qui arrive au pire moment.
- Le provider embarqué n'est pas silencieux : avertissement s'il est vide,
erreur dès qu'on veut
countoufor_each. - Sa correction complète, c'est
configuration_aliasesetproviders, dans cet ordre sur une infrastructure appliquée. - Un module partagé ne contraint que son minimum : une borne haute bloque ses consommateurs.
- Terraform suit des adresses : sans
moved, un refactoring détruit. - Un
movedne corrige que des adresses, jamais un type ni un provider. - Choisissez comme témoin de non-destruction une valeur non recalculable.
- L'arbre des modules reste plat, et un module reçoit ses dépendances.
- Une sortie se retire avec
deprecated, jamais d'un coup.
FAQ : questions fréquentes
Section intitulée « FAQ : questions fréquentes »Les questions ci-dessous portent sur ce qui casse en pratique : le module
qu'on ne peut pas appeler deux fois, le refactoring qui détruit, et le
moved auquel on prête trop de pouvoirs.
Trois cas, trois comportements
Mesuré sur Terraform 1.15.4 :| Situation | Ce que fait Terraform |
|---|---|
bloc provider vide |
Warning: Redundant empty provider block |
| bloc configuré, appel simple | rien, aucun diagnostic |
bloc configuré, appel avec count, for_each ou depends_on |
erreur |
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.
C'est le deuxième cas qui rend l'anti-pattern durable : rien ne vous prévient tant que vous n'essayez pas de réutiliser le module.Déclarer le déplacement
Sansmoved, mesuré sur 1.15.4 :Plan: 4 to add, 0 to change, 4 to destroy.
Avec :moved {
from = local_file.plaque_nord
to = module.plaque["nord"].local_file.plaque
}
# local_file.plaque_nord has moved to module.plaque["nord"].local_file.plaque
Plan: 0 to add, 0 to change, 0 to destroy.
Un bloc par ressource déplacée, clé d'instance comprise. Le moved peut rester dans la configuration : il documente l'historique des adresses pour les états qui n'ont pas encore été migrés.Ce que moved ne fait pas
| Défaut | moved le corrige ? |
|---|---|
| ressource déplacée, renommée, passée dans un module | oui |
| ressource gérée à transformer en source de données | non |
| variable non typée, valeur figée | non |
bloc provider embarqué |
non |
moved block to change a managed resource (a resource block) into a data resource. »Et pour le provider, elle annonce une erreur de planification si la configuration disparaît avant les ressources qu'elle gère.Le témoin décide de la valeur du contrôle
terraform output jetons
{
"nord" = "becoming-gull"
"sud" = "stirring-porpoise"
}
Ces jetons viennent d'un random_pet. S'ils changent, la ressource a été recréée, quel que soit le résumé affiché.À l'inverse, un identifiant déterministe ne prouve rien : un local_file détruit puis recréé à l'identique retrouve exactement le même id, puisque celui-ci n'est qu'un hachage du contenu. Le principe vaut bien au-delà de Terraform : un contrôle ne vaut que par ce qu'il est capable de distinguer.Le test du nom, plutôt qu'un compteur
« A good module should raise the level of abstraction by describing a new concept in your architecture. »Et le critère opérationnel : « If you have trouble finding a name for your module that isn't the same as the main resource type inside it, that may be a sign that your module is not creating any new abstraction. »Un module nomméaws_instance n'abstrait rien, il enveloppe. Un module nommé plaque, qui produit un fichier et son jeton, décrit un objet du domaine.Méfiez-vous des seuils du type « dix variables » : ils ne figurent dans aucune page officielle, et ils se contredisent d'un article à l'autre.Le module annonce un plancher
« If you are writing a shared Terraform module, constrain only the minimum required provider version using a>= constraint. »Mesuré sur 1.15.4, module en ~> 2.4.0 et racine en >= 2.9 :Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider
hashicorp/local: no available releases match the given constraints ~> 2.4.0,
>= 2.9.0
Le consommateur ne peut rien faire de son côté : la borne haute vient du module, et seul un nouveau tag la desserre.Un seul niveau, des expressions entre appels
« In most cases we strongly recommend keeping the module tree flat, with only one level of child modules. »module "reseau" {
source = "./modules/reseau"
}
module "machine" {
source = "./modules/machine"
reseau_id = module.reseau.id
}
Le coût d'un arbre profond est double : il faut descendre pour savoir ce qui est réellement créé, et toute entrée nouvelle doit être relayée à chaque étage, qui ne fait que la transmettre.Un retrait progressif, pas une suppression
output "ancien_chemin" {
value = local_file.plaque.filename
deprecated = "Utilisez la sortie chemin, celle-ci disparaitra en 2.0.0."
}
La documentation précise que l'argument est « only valid in child modules » : une sortie de module racine ne se déprécie pas, elle n'a pas de consommateur au sens de Terraform.Le cycle recommandé tient en trois temps : publier la nouvelle sortie, déprécier l'ancienne dans une version mineure, puis la retirer dans la majeure suivante.Pour aller plus loin
Section intitulée « Pour aller plus loin »- Le style guide officiel : les conventions dont ces anti-patterns sont l'exact négatif.
- Refactoring : la référence officielle : le bloc
movedet ses limites. - Composition de modules : la référence officielle : inversion de dépendance et arbre plat.