Terragrunt : structure du monorepo, dépendances et orchestration en CI
Passé une dizaine d'environnements, un repo Terraform devient un exercice de copier-coller : le même bloc backend, le même provider google, les mêmes required_providers dupliqués partout et le jour où on change de bucket de state il faut ouvrir 40 fichiers. Terragrunt règle ce problème en générant ces fichiers et il en amène de nouveaux dès qu'on branche les units entre elles.
Les exemples ci-dessous sont sur Terragrunt 1.x, où la CLI a été refondue : run-all cède la place à terragrunt run --all et render-json à terragrunt render --json -w. Les anciennes formes sont dépréciées, pas supprimées : elles marchent encore avec un warning, et la politique annoncée est de les garder au moins jusqu'à la 2.0, un strict control permettant d'opter pour le comportement cassant plus tôt. Un tutoriel qui parle de run-all date d'avant, mais son code tourne toujours.
Une arborescence par blast radius
Le découpage des dossiers, c'est le découpage des states, donc c'est le découpage du rayon de casse. Un dossier = une unit = un state = un lock. On sépare ce qui a des cycles de vie différents, pas ce qui a des noms qui se ressemblent.
.
├── root.hcl # backend, provider, tout ce qui est commun
├── units/ # les units réutilisées par les stacks
├── modules/ # les modules Terraform, versionnés
└── live/
├── prod/
│ ├── env.hcl # project_id, region, labels de l'env
│ ├── europe-west9/
│ │ ├── region.hcl
│ │ ├── vpc/
│ │ │ └── terragrunt.hcl
│ │ ├── gke-central/
│ │ │ └── terragrunt.hcl
│ │ └── dns/
│ │ └── terragrunt.hcl
│ └── europe-west1/
└── dev/
Le VPC dans sa propre unit, c'est ce qui évite qu'un apply sur un cluster puisse toucher au réseau. C'est aussi ce qui force à ordonner les MR : le réseau d'abord, ce qui s'y branche ensuite.
Les fichiers env.hcl et region.hcl ne sont pas des units, juste des porteurs de variables qu'on va lire plus bas. Ils n'ont pas de terragrunt.hcl à côté, donc Terragrunt ne les prend pas pour des dossiers à appliquer.
Sortir le backend et le provider du code
C'est le seul vrai argument de vente de Terragrunt : le root.hcl décrit une fois le backend et le provider et chaque unit les récupère par héritage.
locals {
env = read_terragrunt_config(find_in_parent_folders("env.hcl")).locals
region = read_terragrunt_config(find_in_parent_folders("region.hcl")).locals
}
remote_state {
backend = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "tfstate-${local.env.env_name}"
prefix = path_relative_to_include()
location = local.region.region
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "google" {
project = "${local.env.project_id}"
region = "${local.region.region}"
}
EOF
}
inputs = merge(local.env, local.region)
Le prefix = path_relative_to_include() est la ligne qui fait tout le travail : le chemin de l'unit dans le repo devient le chemin de son state dans le bucket. Deux units ne peuvent pas se marcher dessus et on retrouve un state à partir de son dossier sans avoir à chercher.
Chaque unit se contente ensuite de pointer son module et de passer ses inputs :
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::ssh://git@github.com/org/modules.git//gke-cluster?ref=v1.4.0"
}
inputs = {
cluster_name = "central-prod-ew9"
node_count = 6
}
Le double slash dans la source n'est pas décoratif
//gke-cluster marque la racine du module dans le repo. Sans lui, Terragrunt copie tout le repo et les chemins relatifs internes au module cassent, avec un message qui ne pointe jamais vers la vraie cause.
Le ref=v1.4.0 est ce qui rend un apply reproductible. Une source sur main veut dire qu'un apply de rattrapage sur une unit qu'on n'a pas touchée depuis 3 mois va embarquer 3 mois de changements de module. C'est le genre de surprise qu'on découvre à 2h du matin.
Chaîner les units avec dependency
Un dependency fait 2 choses en même temps : il lit les outputs d'une autre unit et il pose une arête dans le DAG qui garantit l'ordre d'exécution.
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
network_name = "mock-network"
subnet_name = "mock-subnet"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
inputs = {
network = dependency.vpc.outputs.network_name
subnet = dependency.vpc.outputs.subnet_name
}
Les mock_outputs existent parce qu'un run --all plan sur un environnement neuf lit les outputs d'units qui n'ont pas encore de state et échouerait sinon. On les cantonne à validate, plan et init : c'est ce que fait mock_outputs_allowed_terraform_commands et c'est ce qui empêche un apply de partir avec mock-network en dur dans la conf.
Les mocks qui masquent une vraie erreur
Le scénario qu'on redoute : une unit est bien appliquée, un output a été renommé côté module, Terragrunt ne trouve plus network_name et retombe sur le mock. Le plan sort propre avec une valeur bidon et on ne le voit qu'à l'apply, ou pire jamais, si la ressource accepte la valeur.
Le défaut protège déjà de ça. mock_outputs_merge_strategy_with_state vaut no_merge, et la doc est explicite : any existing state will be used as is. If the dependency does not have an existing state (it hasn't been applied yet), then the mocks will be used. Une unit déjà appliquée n'utilise donc aucun mock, et un output disparu fait échouer le run au lieu de passer en douce.
Le vrai piège, c'est d'aller toucher à ce réglage en croyant durcir les choses :
dependency "vpc" {
config_path = "../vpc"
mock_outputs = { network_name = "mock-network" }
mock_outputs_merge_strategy_with_state = "shallow" # fabrique le problème
}
En shallow, mocks will only be used where the output does not already exist in the dependency's state. L'output renommé est donc exactement celui qui se fait remplacer par le mock : c'est ce réglage, et lui seul, qui produit le plan vert avec une valeur bidon. shallow a un usage légitime, ajouter un nouvel output mocké sur une unit déjà appliquée sans casser le plan, mais ce n'est pas un garde-fou.
Ordonner sans lire d'output
Quand on a juste besoin qu'une unit passe avant une autre, sans en récupérer quoi que ce soit, dependency est le mauvais outil : il va faire tourner un terraform output pour rien à chaque parse. 2 options plus légères :
# Ou un dependency dont on coupe la lecture d'outputs
dependency "apis" {
config_path = "../project-services"
skip_outputs = true
}
Le cas typique, c'est l'activation des API GCP : rien à en sortir, mais tout ce qui suit en dépend.
Ne rejouer que ce que la MR touche
Sur un monorepo de plusieurs centaines d'units, un run --all plan complet en CI coûte des dizaines de minutes et tape les API du provider pour rien. Terragrunt 1.x sait calculer les units affectées par le diff Git :
# Plan des seules units touchées entre main et HEAD
terragrunt run --all --filter-affected -- plan
# Équivalent explicite, utile quand la branche cible n'est pas main
terragrunt run --all --filter '[origin/master...HEAD]' -- plan
# Restreindre à un sous-arbre
terragrunt run --all --filter './live/prod/**' -- plan
# Les units changées ET tout ce qui en dépend
terragrunt run --all --filter '...[main...HEAD]' -- plan
Attention aux 2 syntaxes qui se ressemblent. Le ** est la récursion de chemin : globs used in path-based expressions will not recursively match nested directories unless you use the ** wildcard. Le ... est tout autre chose, c'est l'opérateur de traversée de graphe, et sa position décide du sens : après une cible il ajoute ses dépendances, avant une cible il ajoute ce qui dépend d'elle.
Ça change la lecture de --filter-affected, qui n'est qu'un raccourci pour [<branche par défaut>...HEAD] et ne matche que les units dont le terragrunt.hcl a été modifié, ajouté ou supprimé. Les dépendantes ne sont pas incluses : toucher au VPC ne replanifie pas ce qui s'y branche tant qu'on n'a pas préfixé l'expression par ....
Les autres flags qui servent réellement en CI :
--parallelism 8plafonne le nombre d'units en vol. Il n'y a aucun plafond par défaut, chaque unit dont les dépendances sont satisfaites démarre immédiatement, d'où les429sur les API GCP--fail-fastarrête à la première unit en échec au lieu de dérouler la queue entière. À réserver auplanet auvalidate: sur unapplyou undestroy, couper la queue en cours de route laisse justement l'état bancal qu'on cherchait à éviter--non-interactiveet--no-color, sinon les logs de pipeline sont illisibles--provider-cachemonte un registry local et arrête de retélécharger le même provider pour chaque unit
run --all apply ajoute -auto-approve tout seul
C'est le comportement par défaut, sans confirmation par unit. Sur un monorepo de prod, on ne le lance qu'après avoir relu le plan et on garde --no-auto-approve sous la main pour les runs manuels.
Le flag à ne jamais mettre en CI, c'est --queue-ignore-dag-order : il applique tout en parallèle sans respecter les dépendances. Ça sert à débloquer une situation à la main, jamais dans un pipeline.
Lire le DAG avant de le subir
Quand un run --all fait des choses inattendues, l'ordre est presque toujours l'explication. Terragrunt sait le sortir sans rien appliquer :
# Les units découvertes, groupées par étage du DAG
terragrunt find --dag
# Le graphe en DOT, pour le regarder en vrai
terragrunt dag graph | dot -Tsvg > dag.svg
# La conf finale d'une unit, includes et fonctions résolus
terragrunt render --format json | jq .
terragrunt render est l'outil à sortir dès qu'un input n'a pas la valeur attendue : il montre le résultat après héritage du root.hcl et évaluation des locals, là où lire les fichiers un par un ne dit pas qui gagne.
Pour un destroy, Terragrunt inverse l'ordre du DAG tout seul. Ce qu'il ne fait pas par défaut, c'est vérifier que personne ne dépend de ce qu'on détruit, d'où --destroy-dependencies-check sur les units qui portent du réseau ou de l'IAM.
Générer les units répétitives avec un stack
Quand la même grappe d'units se répète à l'identique, par exemple un cluster avec son DNS et ses buckets pour chaque nouveau client, les recopier à la main finit par produire des divergences silencieuses. Les stacks génèrent ces dossiers depuis une déclaration unique.
unit "acme_cluster" {
source = "../../../units/gke-cluster"
path = "acme/cluster"
values = {
cluster_name = "acme-prod"
node_count = 4
}
}
unit "acme_dns" {
source = "../../../units/dns-zone"
path = "acme/dns"
values = {
zone = "acme.example.com"
}
}
terragrunt stack generate # matérialise .terragrunt-stack/
terragrunt stack run -- plan
terragrunt stack output
terragrunt stack clean
Les units générées atterrissent dans .terragrunt-stack/, qui est un artefact : on le gitignore et on ne l'édite jamais à la main. Côté unit, les valeurs se lisent avec values.cluster_name au lieu de passer par des locals recopiés.
Ça règle le problème et ça ajoute une couche d'indirection en plus. Sur 3 environnements qui se ressemblent, le gain ne paye pas la complexité. Sur 30 clients au même schéma, il la paye largement.
Les pièges qui coûtent une heure
- Le
root.hclne s'appelait pas comme ça avant. L'ancienne convention mettait la conf racine dans unterragrunt.hclà la racine etfind_in_parent_folders()sans argument le trouvait. C'est déprécié : on nomme le fichierroot.hclet on passe le nom explicitement. Sinon Terragrunt peut remonter jusqu'à unterragrunt.hclqui n'était pas prévu pour ça. - Le
.terragrunt-cachegrossit sans fin. Chaque unit garde sa copie du module et de ses providers. Sur un monorepo, ça se compte en dizaines de Go.--provider-cacherègle la partie providers et unfind . -type d -name ".terragrunt-cache" -prune -exec rm -rf {} +règle le reste quand la CI commence à se plaindre du disque. - Un state par unit veut dire un lock par unit. C'est l'avantage recherché, mais 2 pipelines qui tournent sur la même branche vont se bloquer proprement sur une unit et pas sur les autres, donc un
run --allpeut échouer à moitié. C'est là que--fail-fastévite un état bancal. inputsn'est pasvariables. Terragrunt passe lesinputsen variables d'environnementTF_VAR_*. Une variable non déclarée dans le module est ignorée en silence, sans erreur : une faute de frappe dans un nom d'input ne se voit que par la valeur par défaut qui s'applique.
Voir aussi
- Cheatsheet Terraform - les commandes Terraform sous-jacentes
- Accélerer Terraform - cache des providers et parallélisme, valable sous Terragrunt
- Tools Terraform indispensables - tflint, tfsec et Infracost à brancher sur les units
- GKE Workload Identity - un exemple de ce qu'on déploie avec