Découper et refactoriser les états
Pourquoi
Un état de Terraform grandit avec l'infrastructure. Un jour, trois problèmes se présentent ensemble. Le plan met quatre minutes parce qu'il rafraîchit trois cents ressources, dont deux cent quatre-vingts n'ont rien à voir avec le changement du jour. Un apply sur la configuration d'une application a failli toucher le réseau, parce que le même état contient les deux. Et l'équipe applicative n'a plus le droit d'écrire dans l'état parce qu'il contient aussi les bases de données, que seule l'équipe plateforme doit modifier.
La leçon 4 a montré comment organiser le code en couches. Cette leçon traite de ce qui reste, et qui est moins glamour : déplacer des ressources existantes d'un état à un autre, ou d'un endroit à un autre dans un même état, sans qu'elles soient détruites. Car Terraform identifie une ressource par son adresse dans un état. Si l'adresse change, ou si l'état change, il voit une ressource disparue et une ressource nouvelle, et propose de détruire la première et de créer la seconde. Pour une base de données, c'est un incident. La refactorisation d'état est donc l'art de dire à Terraform « c'est le même objet » avant qu'il ne conclue le contraire.
Le premier cours a présenté les outils de base (état, moved, removed et import). Ici, on les compare, on traite le cas qu'ils ne résolvent pas tous (le déplacement entre états) et on construit la méthode de travail.
Les concepts
Quand découper
Quatre critères, par ordre d'importance.
- Le rayon d'impact. Une erreur d'apply ne devrait pas pouvoir toucher ce qui n'a rien à voir. Un réseau, des bases de données et des déploiements applicatifs n'ont pas la même criticité ; les mettre dans un même état, c'est faire de la plus petite erreur un risque pour la plus fragile des ressources.
- Les droits. Un état est une unité de contrôle d'accès : qui peut le lire, qui peut l'écrire. Si deux équipes ne doivent pas avoir les mêmes droits, elles ne partagent pas le même état.
- Le rythme de changement. Le réseau change tous les six mois, l'application chaque jour. Un état qui mélange les deux fait attendre l'un pour l'autre (verrou) et rafraîchit inutilement ce qui ne bouge pas.
- La vitesse du plan. Le temps d'un plan croît avec le nombre de ressources rafraîchies. À partir de quelques centaines, on le sent ; au-delà d'un millier, on l'organise. On peut aussi accélérer sans découper (
-refresh=falsepour un plan rapide,-targetà titre exceptionnel), mais ce sont des palliatifs : le plan rapide ne détecte pas la dérive.
Le découpage a un coût, qu'il faut regarder avant de se lancer : chaque frontière est une interface à maintenir (les sorties d'une couche consommées par une autre), un ordre à respecter (le réseau avant les données), et une orchestration à fournir (leçon 9). Un état de quarante ressources qui plan en vingt secondes n'a aucune raison d'être découpé. La règle de Brikman, que l'on retrouve chez Morris, est de découper selon les frontières de changement et de responsabilité, pas selon la taille seule.
Trois outils pour trois situations
| Situation | Outil | Nature |
|---|---|---|
Renommer, passer dans un module, passer de count à for_each, dans le même état | bloc moved | déclaratif, relu en demande de fusion |
| Déplacer vers un autre état, avec un accès direct aux deux | terraform state mv -state-out | impératif, immédiat |
| Déplacer vers un autre état, de façon relue et rejouable | bloc removed (destroy = false) côté source, bloc import côté destination | déclaratif, en deux changements |
Les trois partagent une idée : on ne touche jamais à l'infrastructure réelle, seulement à la correspondance entre le code et l'état.
moved (depuis Terraform 1.1) dit que l'objet qui s'appelait from s'appelle maintenant to. Il ne sait travailler que dans un seul état : il ne peut pas traverser deux configurations, ni deux répertoires racines.
state mv modifie l'état tout de suite. Avec -state-out, il écrit l'objet dans un autre fichier d'état, ce qui en fait la commande historique de découpage. Elle crée une sauvegarde de chaque état avant de les modifier ; la documentation le dit : les sauvegardes « sont obligatoires » et ne peuvent pas être désactivées. Elle est impérative : aucun plan ne la précède, et personne ne la relit dans une demande de fusion.
removed et import (Terraform 1.7 et 1.5) reproduisent le déplacement en deux temps déclaratifs : la configuration source déclare que la ressource sort de la gestion de Terraform sans être détruite, la destination l'importe. Chaque étape passe par un plan et une revue. La condition : la ressource doit être importable, et on doit connaître son identifiant d'import, ce qui est normalement le cas pour la plupart des ressources, et que la page de chaque ressource documente.
OpenTofu offre les mêmes blocs (moved, removed depuis la 1.7, import) et la même commande, avec des comportements voisins. L'attribut lifecycle { destroy = false } d'un bloc removed existe dans les deux outils.
En pratique
Les démonstrations utilisent Terraform 1.16.1, des ressources terraform_data et random_id (sans coût), et deux répertoires qui jouent le rôle de deux couches de signalements-iac, chacune avec son état local. Sur l'infrastructure réelle de Signalements, le cas équivalent est de sortir le bucket de médias de la couche réseau pour le placer dans la couche application.
Le problème : déplacer le code seul
Un random_id appelé suffixe_bucket (le suffixe qui rend le nom du bucket unique) a été créé dans le répertoire a. On décide qu'il appartient au répertoire b : on retire son bloc du code de a, on l'ajoute dans b, et l'on planifie :
$ cd a && terraform plan -no-color -detailed-exitcode
# random_id.suffixe_bucket will be destroyed
# (because random_id.suffixe_bucket is not in configuration)
...
Plan: 0 to add, 0 to change, 1 to destroy.
$ echo $?
2
$ cd ../b && terraform plan -no-color -detailed-exitcode
...
Plan: 1 to add, 0 to change, 0 to destroy.
$ echo $?
2
Deux plans non vides, un qui détruit, un qui crée. Pour un suffixe aléatoire, c'est un changement de nom de bucket ; pour une base, la perte des données. L'option -detailed-exitcode donne le code de sortie qui servira de garde-fou : 0 pour un plan vide, 2 pour des changements, 1 pour une erreur.
Déplacer avec state mv
On déplace l'objet de l'état de a à celui de b :
$ cd a
$ terraform state mv -dry-run -state-out=../b/terraform.tfstate \
random_id.suffixe_bucket random_id.suffixe_bucket
Would move "random_id.suffixe_bucket" to "random_id.suffixe_bucket"
$ terraform state mv -state-out=../b/terraform.tfstate \
random_id.suffixe_bucket random_id.suffixe_bucket
Move "random_id.suffixe_bucket" to "random_id.suffixe_bucket"
Successfully moved 1 object(s).
Les trois arguments : -state-out désigne l'état de destination (un fichier), puis l'adresse source, puis l'adresse de destination, qui peut être différente. -dry-run montre ce qui serait déplacé sans rien écrire. Vérification :
$ terraform plan -no-color -detailed-exitcode > /dev/null; echo "a : $?"
a : 0
$ cd ../b && terraform plan -no-color -detailed-exitcode > /dev/null; echo "b : $?"
b : 0
Les deux plans sont vides. Des fichiers de sauvegarde horodatés ont été écrits dans les deux répertoires (terraform.tfstate.<horodatage>.backup). Le déplacement n'a pas touché à la ressource : même identifiant, même valeur.
Warning
Cette démonstration utilise des états locaux, où -state-out désigne directement un fichier. Avec un backend distant (l'état Object Storage de signalements-iac), la méthode sûre est de travailler sur des copies locales : terraform state pull > source.tfstate pour chaque état, terraform state mv -state=source.tfstate -state-out=destination.tfstate ..., vérification, puis terraform state push de chacun. Gardez le verrou : personne ne doit planifier pendant l'opération. Cette suite n'a pas été exécutée sur un backend distant pour cette leçon, et la documentation de state mv ne présente -state et -state-out que comme des options historiques du backend local : répétez-la d'abord sur un état de test, dans un répertoire sans backend distant. Un state push refuse un état qui a un autre lineage que celui qu'il remplace, ce qui est une protection : ne la contournez pas avec -force sans comprendre pourquoi.
Déplacer avec removed et import
La même opération, de façon déclarative, dans l'autre sens (de application vers reseau). Côté source, un bloc removed remplace le bloc resource :
removed {
from = random_id.suffixe_bucket
lifecycle {
destroy = false
}
}Le plan montre ce qui va se passer, sans rien détruire :
# random_id.suffixe_bucket will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
. resource "random_id" "suffixe_bucket" {
id = "k2OI"
# (5 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
Côté destination, on déclare la ressource et on l'importe avec l'identifiant lu dans l'état source (terraform state show random_id.suffixe_bucket donne id = "k2OI") :
resource "random_id" "suffixe_bucket" {
byte_length = 3
}
import {
to = random_id.suffixe_bucket
id = "k2OI"
}$ terraform plan -no-color
# random_id.suffixe_bucket will be imported
resource "random_id" "suffixe_bucket" {
b64_std = "k2OI"
b64_url = "k2OI"
byte_length = 3
dec = "9659272"
hex = "936388"
id = "k2OI"
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
$ terraform apply -no-color -auto-approve
...
$ terraform plan -no-color | grep "No changes"
No changes. Your infrastructure matches the configuration.
Les deux changements peuvent être des demandes de fusion distinctes, planifiées par la CI, relues par des humains. C'est l'avantage sur state mv : la trace est dans Git. L'inconvénient : un removed appliqué avant l'import laisse un instant la ressource sans propriétaire. Pendant ce temps, aucune des deux couches ne la gère, mais rien ne la supprime non plus. On applique la couche destination (import) avant de retirer la source, ou on enchaîne les deux dans la même fenêtre.
Le plan de l'import affiche tous les attributs : la valeur lue de la ressource doit correspondre à ce que la configuration de destination décrit. Si elle diffère, le plan montrera aussi des modifications (~), qu'il faut résoudre avant d'appliquer ; sinon, l'import déplace l'objet, puis l'apply le modifie.
Renommer et regrouper avec moved
Dans un même état, un bloc moved suffit. On déplace terraform_data.reseau_prive dans un module prive. Sans bloc moved, le plan veut détruire et recréer :
# terraform_data.reseau_prive will be destroyed
# (because terraform_data.reseau_prive is not in configuration)
# module.prive.terraform_data.reseau_prive will be created
Plan: 1 to add, 0 to change, 1 to destroy.
On ajoute le bloc :
moved {
from = terraform_data.reseau_prive
to = module.prive.terraform_data.reseau_prive
} # terraform_data.reseau_prive has moved to module.prive.terraform_data.reseau_prive
# (2 unchanged attributes hidden)
Plan: 0 to add, 0 to change, 0 to destroy.
Le plan l'annonce (« has moved to ») et ne propose aucun changement. Le bloc moved fait ainsi partie de l'histoire du code, pas d'une intervention manuelle.
Quelques règles à retenir, d'après la documentation :
- Les adresses
fromettosont relatives au module où le bloc est écrit. Un blocmovedd'un module publié peut donc décrire des renommages internes que ses utilisateurs n'ont pas à connaître. - On peut déplacer une instance (
[0]vers["clé"]), une ressource dans un module, un module vers un autre nom, un module entier. - Les chaînes de déplacements sont suivies :
aversb, puisbversc, laisse les états anciens migrer directement. - On garde les blocs
movedtant qu'il peut exister un état qui n'a pas migré. Pour un module publié, pendant plusieurs versions majeures. - Un
movedne change pas de type de ressource, sauf si le fournisseur l'a prévu.
Une opération sur Signalements
Le dépôt signalements-iac du premier cours est un seul état. On veut sortir la couche réseau (réseau privé, groupes de sécurité, adresses), pour que l'équipe applicative n'ait plus le droit d'y écrire. La séquence :
- Préparer. Créer le répertoire
reseau/avec son backend (clé d'état distincte), son code copié des ressources concernées, et ses sorties (identifiant du réseau, CIDR). Ne pas encore appliquer. - Sécuriser. Activer la conservation des versions du bucket d'état si ce n'est pas fait, faire une copie
state pulldatée, annoncer un gel : personne n'applique pendant l'opération. - Déplacer. Par
state mvsur copies locales puispush, ou parimportdansreseau/suivi deremoveddans l'ancien dépôt. - Vérifier. Plan de
reseau/: vide. Plan de l'ancienne configuration, dont les blocs ont été retirés : vide. Un plan non vide, c'est qu'un attribut diffère ou qu'une ressource n'a pas été déplacée. - Rebrancher. Dans l'application, les références directes (
scaleway_vpc_private_network.main.id) deviennent des lectures de la sortie de la couche réseau (terraform_remote_state, ou une source de données qui cherche par nom, voir leçon 4). Plan vide, là encore. - Lever le gel, et ajouter la politique de la leçon 6 qui refuse un plan contenant
deletedansreseau/sans étiquette de dérogation.
Sous le capot
Ce qu'est un déplacement. Un état est un fichier JSON qui associe des adresses (module.prive.terraform_data.reseau_prive) à des objets (attributs, identifiant, fournisseur). Déplacer, c'est modifier la clé d'adresse (dans un même état, ce que fait moved) ou copier l'entrée dans un autre fichier (ce que fait state mv -state-out) et la supprimer de l'ancien. L'objet réel, côté cloud, n'est pas concerné : il garde son identifiant. Le plan suivant compare le code à l'état, et si les deux concordent, c'est vide.
Pourquoi moved s'exécute au plan. Terraform applique les moved en début de plan, avant de construire le graphe : il réécrit les adresses de l'état (en mémoire) puis calcule les différences. Le plan montre le déplacement, l'apply l'écrit dans l'état. C'est pourquoi c'est sûr et rejouable : si l'apply est interrompu, le plan suivant refait le même déplacement.
Pourquoi import est plus lourd qu'un mv. import ne copie pas l'entrée de l'ancien état : il relit l'objet auprès du fournisseur par son identifiant, et reconstruit l'entrée. C'est plus long, mais l'état de destination reflète la vérité du cloud, pas une copie qui aurait pu être périmée. Pour une ressource dont l'identifiant d'import n'est pas évident (identifiants composés avec région et zone), il faut le chercher dans la documentation.
Les sauvegardes de state mv. Elles sont écrites à côté de l'état local, sous la forme terraform.tfstate.<horodatage>.backup, une par état touché. Avec un backend distant, les versions du bucket sont votre vraie sauvegarde : elles ne se reconstituent pas après coup.
Pièges courants
Un plan qui détruit après un mv. Cause fréquente : le code de destination décrit la ressource avec une adresse ou un for_each différents de l'état déplacé. L'état dit scaleway_instance_server.app["sig-app-1"], le code déclare count. Corrigez l'adresse de destination dans la commande mv, pas dans l'état à la main.
Oublier de retirer le code de la source. L'état de la source ne contient plus l'objet, mais le code le décrit encore : le plan veut le créer. Retirez le bloc (ou remplacez-le par removed) dans le même changement.
Error: Resource instance managed by newer provider version ou des erreurs de schéma après un déplacement entre deux configurations qui n'ont pas la même version de fournisseur. Alignez les versions (fichier de verrou) avant de déplacer.
Un import dont les valeurs divergent. Le plan affiche un ~ : l'import réussit, puis l'apply modifie la ressource. Dans une refactorisation, on veut zéro modification : alignez d'abord la configuration sur la réalité.
terraform state mv sans verrou. Si quelqu'un applique pendant l'opération, vous écrasez ses modifications ou lui les vôtres. Sur un backend à verrou (S3 avec use_lockfile), la commande prend le verrou ; l'option -lock=false est à réserver aux états locaux.
Supprimer trop tôt les blocs moved. Un état d'un autre environnement (prod, quand on a migré preprod) qui n'a pas encore appliqué le renommage veut tout recréer. Gardez-les jusqu'à ce que tous les états aient migré.
Découper trop fin. Quarante états de quatre ressources, avec des sorties qui s'enchaînent, rendent chaque changement transversal pénible. Revenez aux critères du début : un état par frontière réelle.
Sécurité
- Un état est une ressource sensible, y compris quand on le manipule.
state pullécrit l'état en clair sur le poste : supprimez la copie dès l'opération terminée, et ne l'enregistrez pas dans un dossier synchronisé ou sauvegardé. - Une opération sur l'état est une intervention privilégiée. Qui peut faire
state pushpeut réécrire l'inventaire d'une couche : réservez-la à une identité dédiée, journalisée, et préférez les méthodes déclaratives (moved,removed,import) qui passent par une revue. - Le découpage est un outil de sécurité. Séparer les états sépare les droits : l'identité de la CI applicative n'écrit plus dans l'état du réseau ni des données. C'est souvent la vraie raison de découper, plus que la taille.
- Les secrets dans les états déplacés. Les copies locales d'états contiennent ce que contient l'état (voir la leçon 7) : mêmes précautions.
En production
Un gabarit de revue pour une opération d'état. Une demande de fusion doit montrer : l'objectif, la liste des adresses déplacées, la commande ou les blocs, le plan attendu (vide) avant et après, la sauvegarde faite, la fenêtre de gel, la procédure de retour arrière. Sans plan vide attendu, la revue ne peut pas dire si l'opération a réussi.
Le garde-fou automatique. Dans la CI, un plan sur une couche refactorisée est lu en code de sortie : terraform plan -detailed-exitcode doit renvoyer 0. Un plan avec une action delete sur une ressource qui ne devait pas disparaître est bloqué par la politique de la leçon 6, qui lit resource_changes[].change.actions.
Retour arrière. Un state mv se défait en rejouant la commande dans l'autre sens, ou en restaurant les sauvegardes des deux états (restaurer une seule des deux laisse un objet dans les deux états ou dans aucun : toujours les deux). Un removed plus import se défait par la paire inverse. Les versions du bucket d'état restent le dernier recours.
Ordre des couches. Le découpage fait apparaître l'ordre : le réseau avant les données, les données avant l'application. Cet ordre vit dans la tête de quelqu'un tant qu'on ne l'écrit pas ; la leçon 9 traite de son automatisation.
Découper un jour, pas en urgence. Une refactorisation d'état est une opération prévue, hors période de changement, avec un plan de retour : jamais pendant un incident.
Exercices
Exercice 1 : choisir l'outil
Pour chaque cas, dites quel outil vous utilisez : (a) renommer scaleway_lb.main en scaleway_lb.signalements, même état ; (b) passer les instances de count à for_each, même état ; (c) sortir la base sig-db de la couche application vers la couche données, deux états sur Object Storage, avec revue obligatoire ; (d) corriger en urgence une ressource déplacée par erreur dans le mauvais état il y a une heure.
Solution
(a) Bloc moved. (b) Blocs moved, un par instance, qui associent chaque index à sa clé. (c) removed (avec destroy = false) dans la couche application, import dans la couche données, en deux demandes de fusion ou en une seule dans l'ordre : import d'abord, retrait ensuite. Pour une base, relisez l'identifiant d'import dans la documentation de scaleway_rdb_instance (région et identifiant). (d) state mv, en rejouant le déplacement inverse sur copies locales puis state push, avec le verrou et la sauvegarde ; c'est une réparation, la méthode impérative se justifie, et on la documente ensuite.
Exercice 2 : lire un plan
Après un state mv, terraform plan dans la destination affiche Plan: 0 to add, 1 to change, 0 to destroy., avec un changement tags = { "app" = "signalements" } -> null. Que s'est-il passé, et que faites-vous ?
Solution
L'objet a été déplacé correctement (aucune création ni destruction), mais le code de destination ne déclare pas l'argument tags que portait la ressource dans l'état : Terraform veut le retirer. On n'applique pas. On ajoute les étiquettes dans le code de destination (en les recopiant depuis terraform state show sur la source ou depuis l'ancienne configuration) jusqu'à obtenir un plan vide : c'est le critère de réussite d'une refactorisation. Appliquer ce plan aurait modifié la ressource sans que personne l'ait voulu.
Exercice 3 : un plan en sortie de CI
Écrivez les deux lignes de shell d'une étape de CI qui planifie une couche refactorisée et échoue si le plan n'est pas vide, en distinguant erreur et changements.
Solution
terraform plan -detailed-exitcode -input=false -no-color; code=$?
case $code in 0) echo "plan vide" ;; 2) echo "changements inattendus"; exit 1 ;; *) exit "$code" ;; esacLe code 0 signifie un plan vide, 2 des changements, 1 une erreur (conservée telle quelle). En régime normal, 2 est légitime ; l'étape ci-dessus est propre à la fenêtre de refactorisation, où tout changement est une anomalie.
Récapitulatif
- On découpe un état selon le rayon d'impact, les droits, le rythme de changement et, en dernier, la vitesse du plan ; chaque frontière a un coût d'interface et d'orchestration.
- Terraform identifie une ressource par son adresse dans un état : changer l'adresse ou l'état, sans le dire, c'est détruire et recréer.
moved: dans un même état, déclaratif, à garder tant qu'un état n'a pas migré.state mv -state-out: entre états, impératif, avec sauvegardes obligatoires.removed(destroy = false) puisimport: entre états, déclaratif, relu.- Le critère de réussite d'une refactorisation est un plan vide des deux côtés, vérifiable par
plan -detailed-exitcode(code 0). - Avant : sauvegarde, verrou, gel annoncé ; pendant :
-dry-run; après : plans vides, blocsmovedconservés. - Le découpage sépare aussi les droits : c'est souvent sa meilleure justification.
Pour aller plus loin
- La page « Refactoring » de la documentation de Terraform, avec ses cas de modules, et celle du bloc
removed. - La page de la commande
state mvet le guide d'import, y compris la génération de configuration. - Le chapitre 3 de Terraform: Up & Running sur l'isolation des états.
- La leçon 4 sur les couches, la leçon 9 sur l'orchestration des états ainsi obtenus, et la leçon 11 du premier cours sur
moved,removedetimport.
Sources
- HashiCorp, Terraform : refactoriser avec les blocs moved
- HashiCorp, Terraform : le bloc removed
- HashiCorp, Terraform : la commande state mv
- HashiCorp, Terraform : importer des ressources
- OpenTofu, refactorisation et blocs moved, removed, import
- Yevgeniy Brikman, Terraform: Up & Running (3e éd., O'Reilly, 2022), chapitre 3 : How to Manage Terraform State (isolation)
- Kief Morris, Infrastructure as Code (3e éd., O'Reilly, 2025), chapitres sur le découpage de l'infrastructure