TerraformIaCGouvernanceIngénierie plateformeAvancé

Refactoriser un Terraform legacy multi-équipes

Jerry RajaonarivoMis à jour le 28 sept. 20269 min de lecture

Cette démarche part d'un dépôt Terraform legacy : un seul module racine, un seul fichier d'état partagé, les ressources de toutes les équipes emmêlées ensemble, et un terraform plan qui prend plusieurs minutes juste pour se rafraîchir. Ensuite, elle découpe cette configuration en modules partagés versionnés et en fichiers d'état séparés par équipe, afin que chaque équipe puisse posséder son infrastructure sans empiéter sur celle des autres.

Avant et après

text
AVANT                                   APRÈS
─────────────────────────────           ─────────────────────────────
infra/                                  infra/
├── main.tf        (2 400 lignes)       ├── modules/
├── variables.tf                        │   ├── networking/
├── outputs.tf                          │   ├── postgres-service/
└── (un seul fichier d'état             │   └── eks-node-pool/
    partagé par toutes les équipes)     ├── teams/
                                         │   ├── payments/
                                         │   │   ├── main.tf
                                         │   │   └── backend.tf   (état propre)
                                         │   ├── platform/
                                         │   │   ├── main.tf
                                         │   │   └── backend.tf   (état propre)
                                         │   └── data-eng/
                                         │       ├── main.tf
                                         │       └── backend.tf   (état propre)
                                         └── policy/
                                             └── guardrails.rego

Deux mouvements suffisent : extraire les primitives partagées et stables (VPC, node pools, un module Postgres) dans des modules versionnés, puis donner à chaque équipe la propriété de son propre fichier d'état. Un apply de l'équipe payments ne peut pas toucher aux ressources de data-eng, pas par convention de nommage, mais parce qu'elles sont dans des fichiers d'état structurellement séparés.

Tracer les frontières de modules par propriété, pas par type de ressource

L'instinct est d'organiser les modules par service AWS/GCP (modules/vpc, modules/rds, modules/iam), bon pour les primitives partagées, mais la mauvaise frontière pour la couche équipes, qui doit être organisée selon qui est d'astreinte dessus :

text
teams/payments/main.tf
hcl
module "postgres" {
  source = "git::https://github.com/example-org/terraform-modules.git//postgres-service?ref=postgres-service-v2.3.0"
 
  team          = "payments"
  instance_size = "db.r6g.xlarge"
  multi_az      = true
}
 
module "eks_node_pool" {
  source = "git::https://github.com/example-org/terraform-modules.git//eks-node-pool?ref=eks-node-pool-v1.4.0"
 
  team           = "payments"
  min_size       = 3
  max_size       = 12
  instance_types = ["m6i.xlarge"]
}

Les équipes consomment les modules partagés via des tags Git plutôt que via des chemins relatifs. Terraform refuse l'argument version sur une source locale comme ../../modules/postgres-service, et un chemin relatif pointe toujours sur ce qui se trouve dans la branche courante, donc rien n'est épinglé. Avec un tag par release de module (postgres-service-v2.3.0, eks-node-pool-v1.4.0), chaque module évolue à son rythme, et une équipe monte de version en modifiant le ref dans une PR relue. Si vous préférez écrire des contraintes version = "~> 2.3", publiez les modules dans un registre privé (HCP Terraform, ou un registre auto-hébergé) et utilisez une source de registre.

Migrer l'état en toute sécurité : moved, import et removed

Pour réorganiser l'état, on lançait autrefois des terraform state mv à la main contre l'état de production, où une seule faute de frappe pouvait rendre une ressource orpheline. Terraform propose aujourd'hui trois blocs déclaratifs pour cela, relus en PR comme n'importe quel autre changement. Chacun couvre un cas différent.

Renommer au sein d'un même état : moved

moved (Terraform 1.1+) ne fonctionne qu'au sein d'un seul état. Je m'en sers pour la première étape du refactoring, qui consiste à envelopper les ressources en ligne du monolithe dans les nouveaux modules partagés pendant que tout vit encore dans l'état du monolithe.

hcl
# Dans la configuration du monolithe, après avoir remplacé
# l'aws_db_instance en ligne par un appel au module postgres-service :
moved {
  from = aws_db_instance.payments_primary
  to   = module.payments_postgres.aws_db_instance.primary
}

Lancez terraform plan après avoir ajouté un bloc moved. Terraform doit montrer la ressource déplacée vers sa nouvelle adresse, sans destruction ni recréation. Si le plan affiche une paire destruction/création, les adresses ne correspondent pas, et il faut corriger cela avant d'aller plus loin.

Scinder l'état entre équipes : import + removed

Un bloc moved ne peut pas envoyer une ressource dans un autre fichier d'état, et terraform state mv -state-out ne fonctionne qu'avec des fichiers d'état locaux, ce qui exclut un backend S3. Pour confier la base de données payments à teams/payments, je procède en deux PR coordonnées et je gèle les applies du monolithe jusqu'à la fin de l'opération.

1. La nouvelle configuration d'équipe adopte la ressource avec un bloc import (Terraform 1.5+) :

hcl
# teams/payments/imports.tf
import {
  to = module.postgres.aws_db_instance.primary
  id = "payments-primary" # l'identifiant de la base RDS
}

terraform plan dans teams/payments doit afficher 1 to import, 0 to add, 0 to change, 0 to destroy. S'il veut modifier ou remplacer quoi que ce soit, les entrées du module ne correspondent pas à ce qui est déployé. Ajustez-les jusqu'à ce que le plan ne fasse plus qu'importer, puis lancez l'apply. Répétez l'opération pour chaque ressource liée (subnet group, parameter group, security group). Quand elles sont nombreuses, les blocs import acceptent for_each (Terraform 1.7+).

2. Le monolithe lâche la ressource sans la détruire avec un bloc removed (Terraform 1.7+), à la place du bloc de ressource ou de module supprimé :

hcl
# Dans la configuration du monolithe, à la place du bloc module "payments_postgres" :
removed {
  from = module.payments_postgres
 
  lifecycle {
    destroy = false
  }
}

terraform plan dans le monolithe doit indiquer que les ressources ne seront plus gérées par Terraform et afficher 0 to destroy. Appliquez, puis relancez terraform plan dans les deux configurations. Aucune des deux ne doit montrer de changement. La base n'est jamais détruite ni recréée, donc la scission se fait sans interruption de service. Entre les étapes 1 et 2, la ressource est suivie par deux états à la fois, et c'est pour cela que les applies du monolithe restent gelés jusqu'à la fusion de l'étape 2.

État distant et verrouillage, par équipe

hcl
# teams/payments/backend.tf
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "teams/payments/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true # verrouillage natif S3, sans table DynamoDB
    encrypt      = true
  }
 
  required_version = ">= 1.11"
}

Chaque équipe a son propre état dans le même bucket, ce qui les isole sans infrastructure de backend supplémentaire à maintenir. use_lockfile écrit un objet .tflock à côté de chaque fichier d'état grâce aux écritures conditionnelles S3. L'ancien argument dynamodb_table est déprécié depuis Terraform 1.11, vous n'avez donc plus de table de verrous à provisionner. Si vous migrez un backend existant, vous pouvez activer les deux pendant la transition, puis retirer dynamodb_table.

Garde-fous policy-as-code

Le policy-as-code bloque les changements dangereux que n'importe qui, vous compris, pourrait livrer par accident quand une échéance approche. Il tourne comme contrôle CI (conftest test sur le JSON du plan) avant que apply ne soit accessible :

text
# policy/guardrails.rego
package main
 
deny contains msg if {
  some resource in input.resource_changes
  resource.type == "aws_s3_bucket_acl"
  resource.change.after.acl == "public-read"
  msg := sprintf("L'ACL du bucket S3 '%s' ne doit pas être public-read", [resource.address])
}
 
deny contains msg if {
  some resource in input.resource_changes
  resource.type == "aws_db_instance"
  resource.change.after != null # ignorer les suppressions
  not resource.change.after.tags.team
  msg := sprintf("L'instance RDS '%s' n'a pas le tag 'team' requis", [resource.address])
}
yaml
# Étape CI, fait échouer le pipeline avant même que apply soit atteignable
- name: Terraform plan → JSON → policy check
  run: |
    terraform plan -out=tfplan.binary
    terraform show -json tfplan.binary > tfplan.json
    conftest test --policy policy/ tfplan.json

Deux détails peuvent faire passer cette policy sans qu'elle vérifie quoi que ce soit. D'abord, Conftest n'évalue par défaut que le package main, donc une policy déclarée en package terraform.guardrails ne s'exécute jamais si vous n'ajoutez pas --namespace terraform.guardrails (ou --all-namespaces). Ensuite, les versions actuelles de conftest embarquent OPA 1.x, qui exige la syntaxe contains/if montrée ici et rejette l'ancienne forme deny[msg] {. Sur un conftest antérieur à la 1.0 d'OPA, ajoutez import rego.v1 sous la ligne package. Enfin, le contrôle des tags ne lit que les tags propres à la ressource. Si vous posez team via les default_tags du provider, vérifiez plutôt tags_all.

Détection de dérive

Une fois que les équipes possèdent leur propre état, la dérive devient un problème que chaque équipe doit détecter tôt, pas un mystère à l'échelle de la plateforme. Refermer cette boucle prend un plan planifié, en lecture seule, contre l'état de chaque équipe, câblé pour alerter sur tout diff non vide :

yaml
name: drift-detection
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch: {}
 
permissions:
  id-token: write # requis pour l'OIDC AWS
  contents: read
 
jobs:
  detect:
    strategy:
      fail-fast: false
      matrix:
        team: [payments, platform, data-eng]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
 
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "~1.16.0"
 
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-drift-${{ matrix.team }}
          aws-region: us-east-1
 
      - run: terraform -chdir=teams/${{ matrix.team }} init -input=false
 
      - run: terraform -chdir=teams/${{ matrix.team }} plan -detailed-exitcode -lock=false -input=false
        # le code de sortie 2 signifie qu'une dérive a été détectée : le job échoue, c'est l'alerte

Le job s'authentifie auprès d'AWS via le fournisseur OIDC de GitHub, donc aucune clé d'accès longue durée ne traîne dans les secrets du dépôt. Chaque équipe a son propre rôle en lecture seule (ReadOnlyAccess plus la lecture de sa propre clé d'état), et la politique de confiance de ce rôle n'accepte que les jetons émis pour la branche par défaut du dépôt. Avec -lock=false, le plan planifié ne peut jamais bloquer l'apply réel d'une équipe. C'est sans risque, puisque ce plan n'écrit jamais l'état.

Ce qui change pour les équipes

Un nouvel ingénieur dans l'équipe payments peut lancer terraform apply en toute sécurité dès sa première semaine, parce que le rayon d'impact de son fichier d'état se limite uniquement à ses propres ressources.

Voici ce que je constate en général, avant et après (vos temps dépendront du nombre de providers et de la latence des API) :

text
Avant : 1 fichier d'état partagé, module racine d'environ 2 400 lignes
        terraform plan : plusieurs minutes  |  très peu de personnes prêtes à lancer apply
 
Après : 3 fichiers d'état détenus par les équipes + 3 modules partagés versionnés
        terraform plan : bien moins d'une minute par équipe  |  chaque ingénieur lance ses propres applies

Si l'une de ces étapes (les frontières de modules, une migration d'état sûre, ou câbler le policy-as-code dans la CI) ressemble au mur contre lequel vous butez, c'est précisément à cela que sert une mission de Revue d'architecture.

ALLER PLUS LOIN

Envie de faire tourner ça en production ?

Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.

Ce tutoriel

  • Concepts clés et architecture principale
  • Extraits de code illustratifs
  • Le raisonnement derrière chaque décision

Mentorat individuel

  • Des sessions de travail sur votre propre environnement
  • Des réponses directes aux cas particuliers que vous rencontrez
  • Un retour sur votre implémentation réelle
  • Un accompagnement continu pendant que vous la construisez
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires