Aller au contenu
Infrastructure as Code medium

Le bloc lifecycle Terraform : les 7 règles expliquées

50 min de lecture

logo terraform

Quand Terraform doit remplacer une ressource, il applique un ordre par défaut : il détruit l'ancienne, puis crée la nouvelle. Pour un fichier de configuration, cela n'a aucune importance. Pour une base de données, un équilibreur de charge ou un volume persistant, cela signifie une coupure de service, voire une perte de données.

Le bloc lifecycle existe pour reprendre la main sur ces comportements. Il se place à l'intérieur d'une ressource et modifie la façon dont Terraform construit et parcourt son graphe de dépendances. C'est cette phrase qui explique tout le reste du chapitre, y compris les contraintes qui surprennent.

Ce guide passe en revue les sept règles du bloc, une par une : à quoi chacune sert, comment elle s'écrit, quand y recourir, et le piège qu'elle réserve. Tous les comportements décrits ont été vérifiés sur Terraform v1.15.4, plan JSON à l'appui, avec les seuls providers local, random et la ressource intégrée terraform_data. Aucun compte cloud n'est nécessaire pour rejouer les exemples.

  • Ce qu'est le bloc lifecycle et il se place
  • Les sept règles qu'il accepte, et pourquoi on n'en cite souvent que quatre
  • create_before_destroy : supprimer la fenêtre de coupure lors d'un remplacement
  • prevent_destroy : refuser un plan destructeur, et ce que cette protection ne couvre pas
  • ignore_changes : cesser de proposer des mises à jour sur certains attributs
  • replace_triggered_by : lier le remplacement d'une ressource à une autre
  • precondition et postcondition : valider avec les valeurs résolues au plan
  • action_trigger : la règle apparue en 1.15.x
  • Comment prouver un comportement avec le plan JSON

C'est un bloc imbriqué, écrit à l'intérieur d'une ressource, qui modifie la manière dont Terraform planifie les opérations sur cette ressource. Il ne crée rien et ne configure aucun attribut du fournisseur : il agit sur le comportement de Terraform lui-même.

resource "local_file" "exemple" {
filename = "${path.module}/exemple.txt"
content = "bonjour"
lifecycle {
create_before_destroy = true
}
}

Un seul bloc lifecycle par ressource suffit : il peut contenir plusieurs règles à la fois. Il ne peut pas vivre en dehors d'un bloc resource, data ou ephemeral : il n'existe pas de fichier lifecycle.tf autonome.

On cite couramment quatre règles. Le bloc en accepte sept, et les trois oubliées sont précisément celles qui intéressent un profil confirmé.

RègleTypeRôle
create_before_destroybooléenInverse l'ordre du remplacement : créer, puis détruire
prevent_destroybooléenRejette tout plan qui détruirait la ressource
ignore_changesliste d'attributs, ou allCesse de proposer des mises à jour sur ces attributs
replace_triggered_byliste de références de ressourcesRemplace la ressource quand la cible bouge
preconditionblocVérifie une hypothèse avant d'évaluer la ressource
postconditionblocVérifie une garantie après l'application
action_triggerblocDéclenche une action sur un événement du cycle de vie (1.15.x)

Une huitième règle, destroy, existe mais n'est valide que dans un bloc removed.

precondition et postcondition ne sont d'ailleurs pas réservées aux ressources gérées : elles s'écrivent aussi dans un bloc data et dans un bloc ephemeral.

Une contrainte commune : seulement des valeurs littérales

Section intitulée « Une contrainte commune : seulement des valeurs littérales »

Aucune règle du bloc n'accepte d'expression. La documentation en donne la raison : « All lifecycle settings affect how Terraform constructs and traverses the dependency graph. As a result, only literal values can be used because the processing happens too early for arbitrary expression evaluation. »

Le graphe se construit avant que les expressions ne soient évaluées. Rendre une protection configurable est donc impossible :

lifecycle {
prevent_destroy = var.protege # refusé
}
Error: Variables not allowed
Variables may not be used here.
Error: Unsuitable value type
Unsuitable value: value must be known

Retenez cette contrainte dès maintenant : elle explique la moitié des messages d'erreur du chapitre. Une protection conditionnelle se gère au niveau du module appelant, pas ici.

À supprimer la fenêtre pendant laquelle la ressource n'existe plus. Par défaut, un remplacement détruit avant de créer. Avec cette règle, Terraform crée d'abord le nouvel objet, puis détruit l'ancien.

Une seule ligne, à l'intérieur du bloc lifecycle. Le nom du fichier dépend ici d'une valeur qui change, ce qui force le remplacement à chaque évolution : c'est précisément le cas où l'ordre importe.

resource "local_file" "app" {
filename = "${path.module}/app-${random_pet.version.id}.txt"
content = "contenu applicatif"
lifecycle {
create_before_destroy = true
}
}

Dès que l'indisponibilité de l'objet a un coût : bases de données, équilibreurs de charge, certificats, volumes persistants, groupes d'instances derrière un service en production.

Le plan JSON donne la réponse sans ambiguïté, là où une sortie de terminal se reformate à chaque version :

Fenêtre de terminal
terraform plan -out=tf.plan
terraform show -json tf.plan | jq '.resource_changes[] | {address, actions: .change.actions}'
  • ["delete", "create"] : ordre par défaut, la règle n'est pas active
  • ["create", "delete"] : la règle est active

La règle se propage vers les dépendances de la ressource qui la porte, pas vers ses dépendants. C'est le point que la plupart des ressources francophones inversent.

La documentation officielle dit : « Terraform propagates and applies create_before_destroy behavior to all resource dependencies », et l'illustre ainsi : « create_before_destroy is enabled on resource A but not on resource B. Because resource A is dependent on resource B, Terraform enables create_before_destroy for resource B implicitly by default. »

Si A dépend de B et que vous posez la règle sur A, c'est B qui l'hérite. C'est logique dès qu'on raisonne en graphe : pour créer le nouveau A avant de détruire l'ancien, le nouveau B dont il dépend doit déjà exister.

Vérifié sur 1.15.4 : dans l'exemple ci-dessus, où local_file.app dépend de random_pet.version, le plan montre ["create", "delete"] sur les deux, alors que rien n'est écrit sur random_pet.version.

L'héritage ne se refuse pas. Une dépendance qui a reçu la règle implicitement ne peut pas la désactiver : « you cannot override create_before_destroy to false on resource B because that would imply dependency cycles in the graph ».

Les provisioners de destruction ne s'exécutent plus. « When the resource contains a provisioner that runs during the destroy operation, setting create_before_destroy to true also prevents the provisioner from running. » Si vous comptiez sur un provisioner en when = destroy pour désenregistrer un nœud, il ne partira plus.

À faire échouer le plan avant qu'une destruction ne soit tentée. Rien n'est lancé, rien n'est détruit : c'est un garde-fou au moment de la planification.

resource "local_file" "donnees" {
filename = "${path.module}/donnees.txt"
content = "donnee critique"
lifecycle {
prevent_destroy = true
}
}

Un terraform destroy produit alors :

Error: Instance cannot be destroyed
Resource local_file.donnees has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or reduce the scope
of the plan using the -target option.

Sur les objets dont la perte est irréversible : bases de production, volumes de données, buckets d'archives, zones DNS racines.

Passez la valeur à false, puis lancez directement terraform destroy. Aucun apply intermédiaire n'est nécessaire : la règle n'étant pas enregistrée dans l'état, elle est relue depuis la configuration à chaque plan.

Le piège : supprimer le bloc contourne la protection

Section intitulée « Le piège : supprimer le bloc contourne la protection »

prevent_destroy ne protège pas une ressource dont vous supprimez le bloc resource. C'est documenté, et c'est la chose la plus importante à retenir sur cette règle : « This rule doesn't prevent Terraform from destroying a resource if you remove its configuration. »

La cause tient en une phrase : « Except for create_before_destroy, Terraform does not explicitly record a resource's lifecycle rule to state. As a result, Terraform destroys the actual infrastructure during an apply operation if you remove the resource's configuration, even if prevent_destroy is enabled. »

La règle n'existe que dans la configuration. Pas de bloc, pas de règle, pas de protection. Un collègue qui supprime dix lignes de HCL emporte la donnée avec elles, et le plan ne proteste pas.

Avec un bloc removed portant lifecycle { destroy = false }. C'est la manœuvre qui répond au trou décrit ci-dessus, et elle est préférée à terraform state rm parce qu'elle passe par un plan, donc par une revue.

removed {
from = local_file.donnees
lifecycle {
destroy = false
}
}

Le défaut d'un bloc removed est de détruire. Vérifié sur v1.15.4, sur les trois écritures possibles :

Écritureactions du planObjet réel après apply
removed sans bloc lifecycle["delete"]détruit
removed avec un lifecycle {} vide["delete"]détruit
removed avec lifecycle { destroy = false }["forget"]conservé

Ni le bloc lifecycle ni l'argument destroy ne sont obligatoires : les omettre est parfaitement valide, et c'est ce qui rend l'erreur silencieuse. L'action forget est la seule qui prouve, dans le plan JSON, que l'objet sera oublié plutôt que supprimé.

Le bloc removed a ses propres règles au-delà de cet argument : il refuse une clé d'instance, couvre un module entier, et accepte des provisioners de destruction. Elles sont traitées dans le guide dédié, le bloc removed.

À cesser de proposer des mises à jour sur certains attributs, typiquement ceux qu'un outil externe pilote : un autoscaler qui ajuste une taille, une autre équipe qui pose des tags, un opérateur qui modifie une image.

resource "local_file" "journal" {
filename = "${path.module}/journal.txt"
content = var.message
file_permission = var.permissions
lifecycle {
ignore_changes = [content]
}
}

Notez la syntaxe : [content], sans guillemets. Ce sont des références d'attributs, pas des chaînes de caractères.

Avec cette configuration, terraform plan -var 'message=v2' ne propose plus rien sur cette ressource. En revanche terraform plan -var 'permissions=0600' propose bien un changement : la portée est limitée à l'attribut nommé.

Quand la source de vérité d'un attribut est ailleurs que dans votre code, et que vous acceptez cette délégation. C'est un aveu explicite de partage de responsabilité, pas un moyen de faire taire un plan gênant.

ignore_changes compare la configuration à l'état, jamais l'état au monde réel. Une modification faite à la main sur l'objet n'est donc pas absorbée par cette règle : elle relève de la dérive détectée au rafraîchissement, que ignore_changes ne masque pas.

C'est la confusion la plus fréquente sur le sujet, et elle mène à des configurations qui semblent inertes puis se réveillent brutalement.

Le raccourci all est plus étroit que sa réputation. « Terraform can create and destroy the remote object but will never propose updates to it. » La création et la destruction restent planifiées ; seules les mises à jour disparaissent. Dire que « Terraform ne planifie plus rien » est faux.

On ne peut pas viser un meta-argument. « Terraform only ignores attributes defined by the resource type. You can't apply ignore_changes to itself or to any other meta-arguments. » Écrire ignore_changes = [lifecycle] donne Error: Unsupported attribute.

À forcer le remplacement d'une ressource quand une autre bouge, alors même qu'aucun de ses propres attributs ne change. Le cas classique est le redémarrage d'une machine quand sa configuration évolue.

resource "terraform_data" "declencheur" {
input = var.revision
}
resource "local_file" "marqueur" {
filename = "${path.module}/marqueur.txt"
content = "marqueur"
lifecycle {
replace_triggered_by = [terraform_data.declencheur]
}
}

Le plan produit alors, pour local_file.marqueur, actions: ["delete", "create"] et surtout action_reason: "replace_by_triggers". Cette valeur est la seule qui prouve un replace_triggered_by : une autre trahirait un remplacement venu d'un changement d'attribut.

Quand une dépendance existe dans les faits mais pas dans les données : une VM qui doit redémarrer après un changement de configuration, un cache à vider après un déploiement, un service à recycler quand un secret tourne.

Le piège : seules les ressources gérées sont acceptées

Section intitulée « Le piège : seules les ressources gérées sont acceptées »

Une variable ou un local sont refusés. « You can only reference managed resources in replace_triggered_by expressions », et « Plain values, such as local values and input variables, aren't valid ».

lifecycle {
replace_triggered_by = [var.revision] # refusé
}
Error: Invalid reference in replace_triggered_by expression
Only resources, count.index, and each.key may be used in replace_triggered_by.

D'où le motif de production illustré plus haut : terraform_data, une ressource intégrée qui ne nécessite aucun fournisseur, porte la valeur dans son argument input et devient une adresse référençable.

Le déclenchement se produit sur une mise à jour comme sur un remplacement : « If the reference is to a single resource instance, a plan to update or replace that instance triggers a replacement. » Une simple modification en place de la cible suffit donc, ce qui peut provoquer des remplacements en cascade.

Enfin, count.index et each.key sont admis : ils permettent de cibler l'instance homologue d'une autre ressource indexée plutôt que la ressource entière.

À valider des hypothèses avec les valeurs résolues au moment du plan. C'est ce qui les distingue d'un bloc validation, qui ne voit qu'une variable d'entrée isolée.

  • precondition vérifie une hypothèse avant que la ressource ne soit évaluée : « cette combinaison de valeurs a-t-elle du sens ? »
  • postcondition vérifie une garantie après l'application : « le résultat est-il conforme à ce que j'attendais ? »

Les deux blocs prennent une condition et un message d'erreur. La precondition vérifie une hypothèse avant l'action, la postcondition contrôle le résultat après. Seule la seconde peut lire self.

resource "local_file" "app" {
filename = "${path.module}/app.txt"
content = replace(data.local_file.modele.content, "{{env}}", var.env)
lifecycle {
precondition {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env doit valoir dev, staging ou prod."
}
postcondition {
condition = !strcontains(self.content, "{{env}}")
error_message = "Le jeton {{env}} n'a pas été substitué."
}
}
}

Quand le contrôle porte sur plusieurs valeurs à la fois, sur un calcul intermédiaire, ou sur un attribut que seul le fournisseur connaît. Un bloc validation ne sait faire aucun des trois.

self n'existe que dans une postcondition. Il désigne l'objet tel qu'il vient d'être appliqué, ce qui permet de vérifier un attribut calculé, par exemple self.public_dns != "".

L'ordre d'évaluation est précis. « Terraform evaluates precondition blocks before evaluating the resource's configuration arguments », et la précondition « can take precedence over argument evaluation errors ». Elle est en revanche évaluée après count et for_each, ce qui rend each.key et count.index disponibles dans la condition.

Terraform enregistre leur résultat, pas leur contenu. C'est ce qui alimente le tableau checks de terraform show -json, et permet de savoir où un contrôle a réellement été posé : kind: resource pour une précondition ou une postcondition, kind: var pour un bloc validation.

Ne dupliquez pas la même condition sur un bloc resource et sur le bloc data qui représente le même objet : Terraform risque alors d'ignorer les changements du bloc data.

Le sujet est traité en détail dans valider les entrées d'une configuration Terraform.

action_trigger est la règle la plus récente du bloc, documentée en 1.15.x. Elle associe des actions à des événements du cycle de vie, choisis parmi before_create, after_create, before_update et after_update, avec une condition facultative.

Elle répond aux besoins qui poussaient historiquement vers les provisioner, en restant dans le plan plutôt qu'en s'exécutant en marge.

En lisant le plan converti en JSON, jamais la sortie humaine. Trois champs suffisent à tout démontrer :

ChampOù le lireCe qu'il prouve
resource_changes[].change.actionsterraform show -json tf.planL'ordre : ["delete","create"] par défaut, ["create","delete"] avec create_before_destroy
resource_changes[].action_reasonidemLa cause : replace_by_triggers prouve un replace_triggered_by
checks[].address.kindterraform show -jsonLe niveau : resource pour une précondition, var pour une validation
Fenêtre de terminal
terraform plan -out=tf.plan
terraform show -json tf.plan | jq '.resource_changes[] | select(.change.actions != ["no-op"])'

Le tableau ci-dessous sert de mémo de relecture : la colonne de droite rappelle, pour chaque règle, le comportement qui ne se devine pas. Si une seule colonne doit être relue avant d'écrire un bloc lifecycle, c'est celle-là.

RègleEffetPiège principal
create_before_destroyCréer avant détruireSe propage vers les dépendances, pas les dépendants
prevent_destroyRejette le plan de destructionContourné si on supprime le bloc resource
ignore_changesCesse de proposer des mises à jourCompare la configuration à l'état, pas le monde réel
replace_triggered_byRemplace sur mouvement d'une cibleRefuse variables et locals : passer par terraform_data
preconditionVérifie avant évaluationÉvaluée après count et for_each
postconditionVérifie après applicationSeul endroit où self existe
action_triggerDéclenche une action sur événementNouveauté 1.15.x
  1. Le bloc lifecycle n'accepte que des valeurs littérales : il sert à construire le graphe de dépendances, avant toute évaluation d'expression.
  2. create_before_destroy se propage vers les dépendances, pas vers les dépendants, et cet héritage ne peut pas être refusé.
  3. prevent_destroy ne protège pas contre la suppression du bloc resource : la règle n'est pas enregistrée dans l'état.
  4. Retirer une ressource sans détruire l'objet demande un bloc removed avec destroy = false, faute de quoi il détruit en silence.
  5. ignore_changes compare la configuration à l'état, jamais l'état au monde réel : une modification externe reste une dérive.
  6. replace_triggered_by n'accepte que des ressources gérées, count.index et each.key : passer par terraform_data pour une variable.
  7. Seule la postcondition dispose de self, puisqu'elle s'évalue après l'action.
  8. Le plan au format JSON tranche tous ces comportements : resource_changes[].change.actions dit ce que Terraform va réellement faire.

Messages relevés sur Terraform v1.15.4.

MessageCauseSolution
Instance cannot be destroyedprevent_destroy = true actifPasser à false, puis terraform destroy directement : aucun apply intermédiaire n'est requis
Variables not allowed + Unsuitable value: value must be knownUne expression dans le bloc lifecycleLe bloc n'accepte que des littéraux : déplacer la logique dans le module appelant
Only resources, count.index, and each.key may be used in replace_triggered_byUn var.x ou un local.x dans replace_triggered_byPorter la valeur avec terraform_data et référencer la ressource
Unsupported attribute sur ignore_changesUn meta-argument dans la listeignore_changes ne vise que les attributs du type de ressource
Plan vide alors qu'une variable a changéL'attribut est dans ignore_changesVérifier la liste, et se méfier de all qui masque toutes les mises à jour
Remplacement en cascade inattendureplace_triggered_by se déclenche aussi sur une mise à jour de la cibleCibler une instance précise, ou un terraform_data dédié
Conflit de nom lors d'un remplacementcreate_before_destroy fait coexister les deux objetsRendre le nom variable, par exemple avec un suffixe de révision

Les questions ci-dessous portent sur les points où le comportement observé surprend : le sens de la propagation, ce qu'une protection ne protège pas, et ce qu'une règle compare réellement. Chaque réponse indique comment le vérifier sur votre propre configuration.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn