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, IGW

  • terraform-aws-modules/eks/aws — EKS avec node groups et addons

  • terraform-google-modules/network/google — VPC GCP

  • Azure/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()
_images/790172e4071245e85a9a2da3bb4f1843ea54fc004c00067d2816d5b71310fa49.png
# 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()
_images/7bcd18dc4e814c634183e1eb5053835544ea42be6ea373d9240844590c9cef6f.png
# 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()
_images/854e3e29226b7a167422e7c19bfe3a381c412ea557fbda8af14504d27c640a48.png

Résumé#

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

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

  3. Le remote state via terraform_remote_state ou des data sources dédiées permet aux stacks indépendantes de partager des valeurs sans fusionner leurs configurations.

  4. for_each est presque toujours préférable à count pour 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.

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

  6. templatefile(), jsonencode() et yamlencode() sont les fonctions de sérialisation incontournables pour générer des configurations complexes (cloud-init, policies IAM, fichiers de configuration).

  7. Le bloc moved permet de refactoriser le state sans recréer les ressources, rendant les migrations de code Terraform non destructives.

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

  9. TF_LOG=DEBUG et terraform console sont les outils de premier recours pour diagnostiquer les comportements inattendus des expressions et des providers.

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