Un apply interrompu par un timeout réseau, un collègue qui lance un terraform apply depuis son poste pendant que la CI tourne, un state rm mal ciblé un vendredi soir : le résultat est toujours le même. Le state ne reflète plus l'infrastructure réelle, le plan propose de détruire une base de données de production, et toute l'équipe est bloquée.
La tentation est alors forte de « repartir propre » : vider le state, tout réimporter, ou pire, laisser Terraform recréer. C'est la pire décision possible sur un périmètre de production. Cet article détaille les manœuvres de récupération réelles, dans l'ordre où il faut les tenter, en partant du cas le plus fréquent : backend S3 + verrou DynamoDB.
Pourquoi un state casse, et pourquoi ça paralyse tout
Le state Terraform est une base de données, pas un fichier de cache. Il contient la correspondance entre les adresses de ressources du code (module.rds.aws_db_instance.main) et les identifiants réels du provider (prod-pg-01), plus les attributs lus au dernier refresh. Trois propriétés le rendent fragile :
- Il est monolithique par workspace. Une corruption partielle bloque toutes les ressources du state, pas seulement la ressource fautive.
- Il est versionné par un
serial. Terraform refuse d'écrire un state dont leserialn'est pas supérieur à celui stocké, et refuse toutpushdont lelineage(UUID du state) diffère. C'est une protection, mais aussi un mur quand on restaure une sauvegarde. - Il est protégé par un verrou externe. Si le process qui détenait le verrou meurt brutalement (runner CI tué, pod évincé, Ctrl-C mal géré), le verrou reste. Tout le monde est bloqué avec
Error acquiring the state lock.
Les causes typiques de désynchronisation : apply partiel (certaines ressources créées, state non écrit), modification manuelle en console, refactoring de modules sans moved, suppression de ressources hors Terraform, ou double écriture concurrente.
Étape 0 : geler, sauvegarder, diagnostiquer
Avant toute commande qui écrit, on fige la situation.
Geler : désactiver les pipelines Terraform (workflow GitHub Actions, Tekton PipelineRun, ArgoCD auto-sync sur les ressources concernées) et annoncer un gel des apply manuels. Deux personnes qui réparent le même state en parallèle, c'est une corruption garantie.
Sauvegarder : le terraform state pull ne pose pas de verrou en écriture et donne une copie fidèle.
mkdir -p ~/tf-rescue && cd ~/tf-rescue terraform state pull > state-$(date -u +%Y%m%dT%H%M%SZ).json jq '{version, serial, lineage, resources: (.resources | length)}' state-*.json Notez le serial et le lineage : ils seront indispensables pour toute restauration.
Diagnostiquer sans écrire : plan -refresh-only compare l'état réel au state sans proposer de changement de configuration. C'est l'outil de diagnostic le plus sous-utilisé.
terraform plan -refresh-only -lock=false -out=drift.tfplan terraform show -json drift.tfplan | jq -r ' .resource_drift[]? | "\(.address)\t\(.change.actions | join(","))"' En CI, -detailed-exitcode renvoie 0 (aucun écart), 1 (erreur), 2 (écart détecté) :
terraform plan -refresh-only -detailed-exitcode -lock=false Croisez ensuite avec la liste des ressources présentes dans le state :
terraform state list | wc -l terraform state show aws_db_instance.main | head -30 Déverrouiller proprement
Inspecter le verrou DynamoDB
Avec le backend S3 historique, la table DynamoDB a pour clé primaire LockID (type String). Elle contient deux items par state : le verrou lui-même et un digest.
aws dynamodb get-item \ --table-name terraform-locks \ --key '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate"}}' \ --query 'Item.Info.S' --output text | jq . La sortie donne le ID du verrou, l'opération, le Who et le Created. Vérifiez que le process est réellement mort (pod terminé, job CI en échec) avant de déverrouiller. Forcer un verrou actif, c'est provoquer une double écriture.
terraform force-unlock -force 9f3c1a2b-4f0e-4b7d-8c21-7f1a0d2e5c33 Si le backend n'est plus initialisable, supprimez l'item directement :
aws dynamodb delete-item \ --table-name terraform-locks \ --key '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate"}}' Le piège du digest -md5
Second item, souvent oublié : LockID = "<bucket>/<key>-md5" avec un attribut Digest. Terraform compare ce digest au contenu réel du fichier S3. Si vous restaurez une ancienne version du state via la console S3 sans passer par terraform state push, vous obtiendrez :
Error: state data in S3 does not have the expected content. Dans ce cas, supprimez l'item digest : Terraform le recalculera au prochain apply.
aws dynamodb delete-item \ --table-name terraform-locks \ --key '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate-md5"}}' Le verrou natif S3 (Terraform ≥ 1.11)
DynamoDB n'est plus obligatoire. Le backend S3 sait poser un verrou via un objet .tflock et If-None-Match.
terraform { backend "s3" { bucket = "tf-state-prod" key = "env/prod/terraform.tfstate" region = "eu-west-3" encrypt = true kms_key_id = "arn:aws:kms:eu-west-3:111122223333:key/abcd-1234" use_lockfile = true } } Le déverrouillage manuel devient un simple aws s3api delete-object sur env/prod/terraform.tfstate.tflock.
Restaurer une version depuis le versioning S3
Si le state est tronqué, vide ou invalide en JSON, la restauration est la voie la plus rapide. Elle suppose que le versioning est actif sur le bucket — s'il ne l'est pas, arrêtez tout et activez-le avant de continuer à travailler.
aws s3api list-object-versions \ --bucket tf-state-prod \ --prefix env/prod/terraform.tfstate \ --query 'reverse(sort_by(Versions,&LastModified))[:10].[VersionId,LastModified,Size]' \ --output table aws s3api get-object \ --bucket tf-state-prod \ --key env/prod/terraform.tfstate \ --version-id 3HL4kqtJlcpXroDTDmjVBH40Nrjfkd \ restored.tfstate jq '{serial, lineage, resources: (.resources | length)}' restored.tfstate Le bon réflexe est de pousser ce state plutôt que de l'écraser dans S3 : state push recalcule le digest et respecte le protocole du backend.
# Le serial doit être strictement supérieur à celui actuellement stocké CURRENT=$(terraform state pull | jq -r '.serial') jq --argjson s "$((CURRENT + 1))" '.serial = $s' restored.tfstate > push.tfstate terraform state push push.tfstate Si le lineage diffère (state recréé entre-temps), terraform state push -force push.tfstate passe outre. À n'utiliser qu'après avoir vérifié que le lineage cible correspond bien à la même infrastructure, sinon vous écrasez le state d'un autre environnement.
Validez immédiatement par un plan -refresh-only. Les ressources créées après la version restaurée apparaîtront comme « à créer » : ce sont vos orphelines, à importer.
Chirurgie du state
state rm et state mv
state rm retire une ressource du state sans la détruire. C'est l'outil pour sortir une ressource dont les attributs sont corrompus, avant de la réimporter proprement.
terraform state rm 'module.network.aws_nat_gateway.this[0]' terraform state rm 'kubernetes_manifest.app_route' # retire du state, la Route reste state mv renomme une adresse — utile après un refactoring, mais préférez le bloc moved (déclaratif, versionné, rejouable en CI).
terraform state mv 'aws_instance.web' 'module.frontend.aws_instance.web' terraform state mv -state-out=../network/terraform.tfstate \ 'module.vpc' 'module.vpc' # découpage de state Blocs moved et removed
moved { from = aws_instance.web to = module.frontend.aws_instance.web } # Terraform >= 1.7 : retire du state sans détruire, en déclaratif removed { from = aws_s3_bucket.legacy_logs lifecycle { destroy = false } } Ces blocs sont exécutés au plan, tracés dans Git et idempotents : contrairement aux commandes CLI, ils survivent au fait que l'ingénieur de garde ne soit pas celui qui a réparé.
Importer les ressources orphelines
Depuis Terraform 1.5, l'import est déclaratif et vérifiable au plan.
import { to = aws_db_instance.main id = "prod-pg-01" } import { to = kubernetes_deployment_v1.api id = "prod/api" } import { to = helm_release.ingress id = "openshift-ingress/nginx" } terraform plan -generate-config-out=generated.tf terraform apply # effectue l'import, aucune création L'option -generate-config-out écrit une configuration HCL dérivée de l'objet réel : un gain de temps énorme sur une ressource à quarante attributs. Relisez-la, elle contient souvent des valeurs calculées à nettoyer.
Cas Kubernetes et OpenShift
Les ressources kubernetes_manifest utilisent un identifiant structuré. Soit la Route OpenShift suivante, créée à la main pendant l'incident :
apiVersion: route.openshift.io/v1 kind: Route metadata: name: api namespace: prod labels: app.kubernetes.io/name: api app.kubernetes.io/managed-by: terraform spec: host: api.apps.cluster.example.com to: kind: Service name: api weight: 100 port: targetPort: http tls: termination: edge insecureEdgeTerminationPolicy: Redirect Import correspondant :
import { to = kubernetes_manifest.api_route id = "apiVersion=route.openshift.io/v1,kind=Route,namespace=prod,name=api" } Attention : kubernetes_manifest exige un accès à l'API du cluster dès le plan. Si le point d'accès est injoignable (bastion, VPN coupé), le plan échoue avant même d'atteindre le state. Sur un cluster OpenShift, vérifiez d'abord :
oc whoami --show-server oc auth can-i get routes -n prod Le cas particulier de l'apply partiel
Quand un apply casse au milieu, Terraform écrit normalement un errored.tfstate local. Ne le perdez pas : il contient les ressources créées avant l'échec.
ls -l errored.tfstate jq -r '.resources[].instances[].attributes.id' errored.tfstate | head terraform state push errored.tfstate Si ce fichier n'existe pas (runner CI détruit), reconstruisez par import. Ciblez les dépendances dans l'ordre, avec -target uniquement en mode récupération :
terraform apply -target=module.network -lock-timeout=10m terraform apply # convergence complète, sans -target Terminez toujours par un apply non ciblé : -target laisse volontairement des parties du graphe non évaluées.
Bonnes pratiques de sécurité
Un state contient des valeurs sensibles en clair : mots de passe RDS, clés privées, tokens de ServiceAccount, contenu des kubernetes_secret. Traitez-le comme un secret, pas comme un artefact de build.
- Chiffrement obligatoire :
encrypt = true+kms_key_iddédié, et une bucket policy qui refuse toute écriture non chiffrée par cette clé (Conditionsurs3:x-amz-server-side-encryption-aws-kms-key-id). - Versioning + rétention des versions non courantes (≥ 90 jours) et blocage des suppressions via une politique de cycle de vie ; le versioning est votre seule sauvegarde réelle.
- IAM séparé lecture/écriture : les rôles de CI qui n'exécutent que
plann'ont besoin que des3:GetObjectetdynamodb:GetItem. Seul le rôle d'applyobtients3:PutObjectetdynamodb:PutItem/DeleteItem. - Pas de clés statiques : OIDC (GitHub Actions, GitLab) ou IRSA / Pod Identity côté Kubernetes.
- Journalisation : activez les CloudTrail data events sur le bucket de state. Après un incident, savoir qui a écrit la version N-1 fait gagner des heures.
- Réduire la surface : utilisez les arguments write-only / ressources
ephemeral(Terraform ≥ 1.10/1.11) pour les mots de passe, et gérez les secrets Kubernetes via External Secrets Operator plutôt que viakubernetes_secret. - Jamais de
terraform state pulldans un artefact CI ni dans un log. LeTF_LOG=DEBUGaffiche des attributs sensibles.
Côté détection, un CronJob de drift sur le cluster vaut tous les post-mortems :
apiVersion: batch/v1 kind: CronJob metadata: name: terraform-drift-detect namespace: platform-ops spec: schedule: "0 6 * * 1-5" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 5 jobTemplate: spec: backoffLimit: 1 template: spec: serviceAccountName: terraform-readonly restartPolicy: Never containers: - name: terraform image: hashicorp/terraform:1.11 command: ["/bin/sh", "-c"] args: - | set -e cd /workspace terraform init -input=false -lock=false terraform plan -refresh-only -lock=false -detailed-exitcode env: - name: AWS_REGION value: eu-west-3 - name: AWS_ROLE_ARN value: arn:aws:iam::111122223333:role/tf-plan-readonly - name: AWS_WEB_IDENTITY_TOKEN_FILE value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token volumeMounts: - name: workspace mountPath: /workspace - name: aws-token mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount readOnly: true securityContext: allowPrivilegeEscalation: false runAsNonRoot: true capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault volumes: - name: workspace emptyDir: {} - name: aws-token projected: sources: - serviceAccountToken: path: token audience: sts.amazonaws.com expirationSeconds: 3600 Un exit code 2 déclenche une alerte : vous découvrez la dérive le lundi matin, pas pendant l'astreinte.
Conclusion : les points clés
- Sauvegardez avant de toucher à quoi que ce soit :
terraform state pull > backup.json, puis notezserialetlineage. - Diagnostiquez en lecture seule avec
plan -refresh-only -lock=falseet-detailed-exitcode; n'écrivez jamais sur la base d'une supposition. - Vérifiez qu'un verrou est mort avant de le forcer. Pensez à l'item
-md5dans DynamoDB, source d'erreurs incompréhensibles après restauration manuelle. - Restaurez via
terraform state push, pas via la console S3 : le digest et leserialsont gérés pour vous. - Préférez le déclaratif au CLI :
moved,removedetimportsont versionnés, revus en pull request et rejouables ;state mv/rmrestent des outils d'urgence. -
-targetest un mode récupération, jamais un mode de fonctionnement : terminez toujours par unapplycomplet. - Le state est un secret : KMS, versioning, IAM séparé lecture/écriture, CloudTrail, et surveillance de la dérive en continu.
La vraie leçon d'un state corrompu n'est pas la séquence de commandes, mais le fait qu'un seul state monolithique pour toute une production transforme n'importe quel incident mineur en blocage d'équipe. Découper les states par domaine de blast radius — réseau, données, workloads — reste la meilleure assurance.
