06 — Terraform : modules et organisation avancée#
Une fois les fondations maîtrisées, la productivité de Terraform repose sur la modularisation : encapsuler des patterns d’infrastructure réutilisables, organiser les dépendances entre stacks, et industrialiser les ressources multiples. Ce chapitre couvre les modules, l’organisation de dépôt, les métaarguments count/for_each, les blocs dynamiques, les fonctions avancées et les outils de débogage.
Modules : structure et utilisation#
Un module Terraform est simplement un répertoire contenant des fichiers .tf. Le répertoire racine où vous lancez Terraform est le module racine ; il peut appeler des modules enfants.
Structure d’un module#
modules/
└── eks-cluster/
├── main.tf # ressources principales
├── variables.tf # inputs du module
├── outputs.tf # outputs exposés
├── versions.tf # required_providers
└── README.md # documentation (inputs, outputs, exemple)
Appel d’un module#
# Depuis le module racine
module "eks" {
source = "./modules/eks-cluster" # local
# source = "terraform-aws-modules/eks/aws" # Terraform Registry
# source = "git::https://github.com/org/repo.git//modules/eks?ref=v2.1.0" # Git
version = "~> 19.0" # uniquement pour les sources registry
# Inputs du module
cluster_name = "prod-eks"
cluster_version = "1.29"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_groups = {
general = {
desired_size = 3
min_size = 2
max_size = 10
instance_types = ["t3.large"]
}
}
}
# Consommer les outputs du module
output "cluster_endpoint" {
value = module.eks.cluster_endpoint
}
Terraform Registry#
Le Terraform Registry héberge des modules officiels (préfixe hashicorp/) et communautaires. Les modules officiels pour les trois grands clouds suivent la convention de nommage terraform-<provider>-<nom>.
Modules particulièrement utiles :
terraform-aws-modules/vpc/aws— VPC complet avec subnets, NAT, IGWterraform-aws-modules/eks/aws— EKS avec node groups et addonsterraform-google-modules/network/google— VPC GCPAzure/aks/azurerm— AKS
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1"
name = "${local.name_prefix}-vpc"
cidr = "10.0.0.0/16"
azs = data.aws_availability_zones.available.names
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = !local.is_production
enable_dns_hostnames = true
tags = local.common_tags
}
Organisation d’un dépôt Terraform#
Structure recommandée (mono-repo)#
infrastructure/
├── modules/ # modules réutilisables
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── iam-role/
├── envs/ # configurations par environnement
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
├── global/ # ressources partagées (DNS, IAM global)
│ ├── route53/
│ └── iam/
└── .github/
└── workflows/
└── terraform.yml
Mono-repo vs poly-repo#
Mono-repo : toute l’infrastructure dans un seul dépôt.
Avantages : refactoring atomique, visibilité globale, CI unifiée.
Inconvénients : blast radius plus large, permissions moins granulaires.
Poly-repo : un dépôt par équipe ou domaine.
Avantages : isolation forte, ownership clair, pipelines indépendants.
Inconvénients : dépendances inter-dépôts difficiles à synchroniser.
Remote state et dépendances inter-stacks#
Pour partager des outputs entre projets Terraform indépendants, on utilise terraform_remote_state.
# Stack "network" — output exposé
output "private_subnet_ids" {
value = module.vpc.private_subnet_ids
}
# Stack "application" — consommation du state réseau
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-terraform-states"
key = "network/prod/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
# ...
}
Alternative : SSM Parameter Store ou outputs explicites
Plutôt que de coupler directement deux stacks via terraform_remote_state, certaines équipes préfèrent écrire les valeurs importantes dans AWS SSM Parameter Store ou dans des data sources dédiées. Cela réduit le couplage et permet à des équipes non-Terraform de consommer les valeurs.
count et for_each#
Ces métaarguments permettent de créer plusieurs instances d’une même ressource.
# count — basé sur un entier
resource "aws_subnet" "private" {
count = length(var.private_cidr_blocks)
vpc_id = aws_vpc.main.id
cidr_block = var.private_cidr_blocks[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "private-subnet-${count.index + 1}" }
}
# for_each — basé sur une map ou un set (plus robuste)
resource "aws_security_group_rule" "ingress" {
for_each = {
http = { port = 80, protocol = "tcp" }
https = { port = 443, protocol = "tcp" }
ssh = { port = 22, protocol = "tcp", cidr = ["10.0.0.0/8"] }
}
type = "ingress"
security_group_id = aws_security_group.app.id
from_port = each.value.port
to_port = each.value.port
protocol = each.value.protocol
cidr_blocks = lookup(each.value, "cidr", ["0.0.0.0/0"])
description = "Règle ${each.key}"
}
La clé de for_each devient l’identifiant de la ressource dans le state : aws_security_group_rule.ingress["https"]. Avec count, ce serait aws_security_group_rule.ingress[1] — si on insère un élément en début de liste, tous les index bougent et Terraform va recréer toutes les ressources suivantes.
Dynamic blocks#
Les blocs dynamiques génèrent des blocs imbriqués répétitifs à partir d’une collection.
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
}
resource "aws_security_group" "app" {
name = "${local.name_prefix}-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Fonctions avancées#
# templatefile() : rend un template avec des variables
resource "aws_instance" "app" {
user_data = templatefile("${path.module}/templates/cloud-init.yaml.tpl", {
hostname = "app-${var.environment}"
packages = ["nginx", "certbot", "fail2ban"]
ssh_pub_key = var.ssh_public_key
})
}
# jsonencode() / yamlencode() : sérialisation inline
resource "aws_iam_policy" "s3_read" {
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = ["arn:aws:s3:::${var.bucket_name}", "arn:aws:s3:::${var.bucket_name}/*"]
}]
})
}
# Autres fonctions utiles
locals {
# Fusionner des maps
merged_tags = merge(var.default_tags, var.extra_tags)
# Extraire des clés d'une map
subnet_names = keys(var.subnets_config)
# Aplatir une liste de listes
all_cidrs = flatten([for vpc in var.vpcs : vpc.subnet_cidrs])
# Encoder en base64
encoded_script = base64encode(file("${path.module}/scripts/init.sh"))
}
Lifecycle rules avancées#
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-rds"
# ...
lifecycle {
# Éviter la suppression accidentelle de la DB de production
prevent_destroy = true
# Ignorer les changements de mot de passe (géré par rotation externe)
ignore_changes = [password, snapshot_identifier]
# Créer le remplacement avant de supprimer (zéro downtime)
create_before_destroy = true
# Hook avant destruction (ex: snapshot final)
replace_triggered_by = [aws_db_parameter_group.main]
}
}
Import de ressources existantes#
Terraform 1.5+ introduit le bloc import déclaratif, plus pratique que la commande terraform import.
# Import déclaratif (TF 1.5+)
import {
to = aws_s3_bucket.legacy
id = "my-existing-bucket-name"
}
resource "aws_s3_bucket" "legacy" {
bucket = "my-existing-bucket-name"
# Terraform génèrera la configuration avec `terraform plan -generate-config-out=generated.tf`
}
# Ancienne méthode (toujours valide)
terraform import aws_s3_bucket.legacy my-existing-bucket-name
# Générer la configuration depuis les ressources importées
terraform plan -generate-config-out=imported_resources.tf
Refactoring avec moved#
Le bloc moved permet de renommer des ressources dans le state sans les recréer.
# Renommer une ressource
moved {
from = aws_instance.app
to = aws_instance.web_server
}
# Déplacer une ressource vers un module
moved {
from = aws_security_group.main
to = module.network.aws_security_group.main
}
# Convertir count → for_each
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["eu-west-1a"]
}
Débogage#
# Niveaux de log : TRACE, DEBUG, INFO, WARN, ERROR
export TF_LOG=DEBUG
export TF_LOG_PATH=/tmp/terraform-debug.log
terraform apply
# Console interactive — évaluer des expressions HCL
terraform console
> cidrsubnets("10.0.0.0/16", 8, 8, 8)
> jsondecode(file("config.json"))
> length(var.subnet_ids)
# Afficher le graphe de dépendances
terraform graph | dot -Tsvg > graph.svg
Visualisations#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import numpy as np
import networkx as nx
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Visualisation 1 : Graphe de dépendances entre modules Terraform
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
G = nx.DiGraph()
# Nœuds : modules
modules = {
"root": {"layer": 0, "color": "#4dabf7"},
"vpc": {"layer": 1, "color": "#74c0fc"},
"iam": {"layer": 1, "color": "#74c0fc"},
"eks": {"layer": 2, "color": "#51cf66"},
"rds": {"layer": 2, "color": "#51cf66"},
"alb": {"layer": 2, "color": "#51cf66"},
"monitoring": {"layer": 3, "color": "#ffd43b"},
"dns": {"layer": 3, "color": "#ffd43b"},
}
for node, attrs in modules.items():
G.add_node(node, **attrs)
# Arêtes : dépendances (A → B signifie A dépend de B)
edges = [
("root", "vpc"), ("root", "iam"),
("eks", "vpc"), ("eks", "iam"),
("rds", "vpc"),
("alb", "vpc"), ("alb", "eks"),
("monitoring", "eks"), ("monitoring", "rds"),
("dns", "alb"),
("root", "eks"), ("root", "rds"), ("root", "alb"),
("root", "monitoring"), ("root", "dns"),
]
G.add_edges_from(edges)
# Layout hiérarchique manuel
pos = {
"root": (4.0, 4.0),
"vpc": (2.0, 3.0),
"iam": (6.0, 3.0),
"eks": (2.0, 2.0),
"rds": (4.0, 2.0),
"alb": (6.0, 2.0),
"monitoring": (3.0, 1.0),
"dns": (5.5, 1.0),
}
fig, ax = plt.subplots(figsize=(11, 7))
ax.set_title("Graphe de dépendances entre modules Terraform", fontsize=13, fontweight="bold", pad=15)
node_colors = [modules[n]["color"] for n in G.nodes()]
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1800, alpha=0.92, ax=ax)
nx.draw_networkx_labels(G, pos, font_size=9, font_weight="bold", ax=ax)
nx.draw_networkx_edges(G, pos, arrows=True, arrowsize=18, width=1.8,
edge_color="#495057", alpha=0.7,
connectionstyle="arc3,rad=0.07", ax=ax)
layer_labels = {0: "Module racine", 1: "Infrastructure de base", 2: "Services", 3: "Transversal"}
legend_colors = ["#4dabf7", "#74c0fc", "#51cf66", "#ffd43b"]
patches = [mpatches.Patch(color=c, label=l) for c, l in zip(legend_colors, layer_labels.values())]
ax.legend(handles=patches, loc="lower left", fontsize=9)
ax.axis("off")
plt.savefig("terraform_modules_dag.png", dpi=120, bbox_inches="tight")
plt.show()
# Visualisation 2 : Mono-repo vs poly-repo — complexité vs isolation
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(7)
strategies = {
"Mono-repo\n(petite équipe)": {"x": 2.5, "y": 3.5, "size": 180, "color": "#51cf66"},
"Mono-repo\n(grande équipe)": {"x": 7.5, "y": 2.5, "size": 280, "color": "#ffd43b"},
"Poly-repo\n(par domaine)": {"x": 5.0, "y": 7.5, "size": 240, "color": "#74c0fc"},
"Poly-repo\n(par service)": {"x": 8.5, "y": 8.0, "size": 200, "color": "#ff922b"},
"Terragrunt\nmono-repo": {"x": 4.0, "y": 6.5, "size": 260, "color": "#cc5de8"},
}
fig, ax = plt.subplots(figsize=(10, 8))
for label, props in strategies.items():
ax.scatter(props["x"], props["y"], s=props["size"] * 3,
color=props["color"], alpha=0.78, edgecolors="white", linewidths=2, zorder=3)
ax.annotate(label, (props["x"], props["y"]),
textcoords="offset points", xytext=(12, 5),
fontsize=9.5, fontweight="bold", color="#333")
ax.set_xlabel("Complexité de gestion →", fontsize=11)
ax.set_ylabel("Isolation entre équipes →", fontsize=11)
ax.set_title("Stratégies d'organisation de dépôt Terraform\nComplexité vs isolation", fontsize=13, fontweight="bold")
ax.set_xlim(0, 11)
ax.set_ylim(0, 11)
# Quadrants
ax.axvline(x=5.5, color="#ced4da", linestyle="--", linewidth=1.2, alpha=0.7)
ax.axhline(y=5.5, color="#ced4da", linestyle="--", linewidth=1.2, alpha=0.7)
ax.text(1.5, 9.5, "Simple + Isolé\n(idéal)", fontsize=8.5, color="#2f9e44", alpha=0.6, style="italic")
ax.text(7.0, 9.5, "Complexe + Isolé\n(acceptable à grande échelle)", fontsize=8, color="#e67700", alpha=0.6, style="italic")
ax.text(1.5, 1.0, "Simple + Couplé\n(petites équipes)", fontsize=8.5, color="#1971c2", alpha=0.6, style="italic")
plt.savefig("terraform_repo_strategies.png", dpi=120, bbox_inches="tight")
plt.show()
# Visualisation 3 : Tableau comparatif count vs for_each
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(12, 5))
ax.axis("off")
ax.set_title("Comparaison : count vs for_each en Terraform", fontsize=13, fontweight="bold", pad=20)
columns = ["Critère", "count", "for_each"]
rows = [
["Type de collection", "Entier (0..n-1)", "Map ou set de strings"],
["Référence dans state", "resource[0], resource[1]", 'resource["clé"]'],
["Ajout en milieu", "Décale tous les index\n→ recreate en cascade", "Seule la nouvelle clé est ajoutée"],
["Suppression", "Décale les index restants", "Supprime uniquement la clé retirée"],
["Use case typique", "N instances identiques", "Ressources différenciées par une clé"],
["Lisibilité du plan", "Index numériques peu lisibles", "Clés sémantiques explicites"],
["Recommandation", "Simples répliques homogènes", "Tout ce qui a une identité propre"],
]
colors_header = ["#4dabf7"] * 3
colors_rows = []
for i, _ in enumerate(rows):
if i % 2 == 0:
colors_rows.append(["#f8f9fa", "#e8f4fd", "#e8f7ee"])
else:
colors_rows.append(["#ffffff", "#ffffff", "#ffffff"])
table = ax.table(
cellText=rows,
colLabels=columns,
cellLoc="center",
loc="center",
cellColours=colors_rows,
colColours=colors_header,
)
table.auto_set_font_size(False)
table.set_fontsize(9.5)
table.scale(1.0, 2.2)
for (row, col), cell in table.get_celld().items():
cell.set_edgecolor("#dee2e6")
if row == 0:
cell.set_text_props(fontweight="bold", color="white")
if col == 0 and row > 0:
cell.set_text_props(fontweight="bold")
plt.savefig("count_vs_foreach.png", dpi=120, bbox_inches="tight")
plt.show()
Résumé#
Un module Terraform est tout répertoire contenant des fichiers
.tf; il encapsule des ressources avec des inputs et outputs clairement définis, favorisant la réutilisation et l’abstraction.Le Terraform Registry fournit des modules maintenus et éprouvés pour les providers majeurs ; les préférer à des modules maison réduit la maintenance.
Le remote state via
terraform_remote_stateou des data sources dédiées permet aux stacks indépendantes de partager des valeurs sans fusionner leurs configurations.for_eachest presque toujours préférable àcountpour les ressources qui ont une identité propre : les identifiants sémantiques dans le state évitent les recréations en cascade lors des modifications de liste.Les blocs dynamiques éliminent la duplication de blocs imbriqués répétitifs tout en conservant la lisibilité grâce au nommage explicite de l’itérateur.
templatefile(),jsonencode()etyamlencode()sont les fonctions de sérialisation incontournables pour générer des configurations complexes (cloud-init, policies IAM, fichiers de configuration).Le bloc
movedpermet de refactoriser le state sans recréer les ressources, rendant les migrations de code Terraform non destructives.L’import déclaratif (TF 1.5+) avec génération de configuration (
-generate-config-out) facilite l’adoption de Terraform sur une infrastructure existante.TF_LOG=DEBUGetterraform consolesont les outils de premier recours pour diagnostiquer les comportements inattendus des expressions et des providers.La stratégie mono-repo avec Terragrunt est souvent le meilleur compromis à l’échelle d’une organisation : visibilité globale, DRY, et isolation suffisante par environnement.