Terraform: Up & Running
Chapitre 5 / 8 · 19 min de lecture

Boucles, conditions, déploiement et pièges

Les techniques avancées de HCL — count, for_each, expressions for, conditionnelles — le déploiement sans interruption, et les pièges à connaître.

Terraform est un langage déclaratif. Comme on l'a vu au premier chapitre, l'infrastructure as code (IaC) écrite dans un langage déclaratif offre une vue plus fidèle de ce qui est réellement déployé qu'un langage procédural ; on raisonne plus facilement dessus et le code reste plus compact. Mais certaines tâches deviennent, elles, plus difficiles. Comment répéter un bout de logique — créer plusieurs ressources similaires — sans copier-coller, quand le langage n'a pas de boucle for ? Comment configurer une ressource sous condition, quand il n'y a pas d'instruction if ? Et comment exprimer une idée fondamentalement procédurale, comme un déploiement sans interruption (zero-downtime deployment), dans un langage qui ne décrit que des états ?

Heureusement, Terraform fournit quelques primitives — le méta-paramètre count, les expressions for_each et for, le bloc de cycle de vie create_before_destroy, l'opérateur ternaire et une vaste bibliothèque de fonctions — qui permettent de réaliser certains types de boucles, de conditions et de déploiements progressifs. Ce chapitre les déroule une à une, puis recense les pièges (gotchas) qui prennent régulièrement les débutants au dépourvu.

Les boucles avec le paramètre count

Le méta-paramètre count est le plus ancien, le plus simple et le plus limité des outils d'itération de Terraform : tout ce qu'il fait, c'est définir combien de copies d'une ressource créer. Imaginons que vous gériez désormais vos utilisateurs IAM (AWS Identity and Access Management) avec Terraform. Pour en créer trois d'un coup, on ne dispose pas de boucle for ; on pose un count sur la ressource :

resource "aws_iam_user" "example" {
  count = 3
  name  = "neo"
}

Problème : les trois utilisateurs porteraient le même nom, ce qui provoque une erreur, car les noms d'utilisateur doivent être uniques. La parade tient dans count.index, qui donne l'indice de chaque « itération » de la « boucle ». Combiné à une variable de type liste et à la fonction intégrée length, on obtient un nom distinct par utilisateur :

variable "user_names" {
  description = "Create IAM users with these names"
  type        = list(string)
  default     = ["neo", "trinity", "morpheus"]
}

resource "aws_iam_user" "example" {
  count = length(var.user_names)
  name  = var.user_names[count.index]
}

Une fois count posé sur une ressource, celle-ci devient un tableau de ressources plutôt qu'une ressource unique. On ne lit donc plus un attribut avec la syntaxe habituelle, mais en précisant l'indice voulu — par exemple aws_iam_user.example[0].arn. Pour récupérer les attributs de toutes les copies, on emploie l'expression d'éclatement (splat expression), l'étoile * :

output "all_arns" {
  value       = aws_iam_user.example[*].arn
  description = "The ARNs for all users"
}

count souffre toutefois de deux limites qui réduisent nettement son utilité. Première limite : on peut itérer sur une ressource entière, mais pas sur les blocs imbriqués (inline blocks) à l'intérieur d'une ressource — par exemple les blocs tag d'un aws_autoscaling_group, où chaque étiquette exige son propre bloc avec key, value et propagate_at_launch. Utiliser count à l'intérieur d'un bloc imbriqué n'est tout simplement pas supporté.

Seconde limite : ce qui se passe quand on modifie la liste. Comme count identifie chaque ressource par sa position (son indice) dans le tableau, retirer un élément du milieu décale tous les suivants d'un cran. Si l'on enlève "trinity" de la liste, Terraform ne supprime pas simplement cet utilisateur : il propose de renommer l'utilisateur à l'indice 1 en "morpheus" puis de détruire l'ancien "morpheus" à l'indice 2.

  État interne avant            État interne après suppression de "trinity"

  example[0]: neo               example[0]: neo
  example[1]: trinity     ──►   example[1]: morpheus   (décalé depuis [2])
  example[2]: morpheus          (l'indice [2] disparaît)

Attention

Chaque fois que vous utilisez count pour créer une liste de ressources, retirer un élément du milieu de la liste pousse Terraform à détruire puis recréer toutes les ressources situées après lui. Le résultat final est correct, mais le chemin pour y parvenir — supprimer et modifier des ressources existantes — est rarement celui que vous souhaitiez.

Pour lever ces deux limites, Terraform 0.12 a introduit les expressions for_each.

Les boucles avec for_each

L'expression for_each permet d'itérer sur des listes, des ensembles (sets) et des maps pour créer soit (a) plusieurs copies d'une ressource entière, soit (b) plusieurs blocs imbriqués au sein d'une ressource. Dans le corps de la ressource, each.key et each.value donnent accès à la clé et à la valeur de l'élément courant. Voici les trois mêmes utilisateurs IAM avec for_each :

resource "aws_iam_user" "example" {
  for_each = toset(var.user_names)
  name     = each.value
}

Notez l'appel à toset qui convertit la liste var.user_names en ensemble : sur une ressource, for_each n'accepte que des ensembles et des maps, pas des listes. Une fois for_each appliqué, la ressource devient une map de ressources — et non un tableau comme avec count. C'est un changement majeur, car cela permet de retirer un élément du milieu d'une collection sans danger. Si l'on enlève à nouveau "trinity", Terraform propose cette fois exactement ce qu'on attend :

  # aws_iam_user.example["trinity"] will be destroyed
  - resource "aws_iam_user" "example" {
      - arn  = "arn:aws:iam::123456789012:user/trinity" -> null
      - name = "trinity" -> null
    }

  Plan: 0 to add, 0 to change, 1 to destroy.

Les autres ressources ne bougent pas. C'est pourquoi il faut presque toujours préférer for_each à count pour créer plusieurs copies d'une ressource. Pour extraire la liste des ARN d'une map de ressources, on combine la fonction values (qui ne renvoie que les valeurs d'une map) et l'expression d'éclatement : values(aws_iam_user.example)[*].arn.

Des blocs imbriqués dynamiques

Le second atout de for_each est sa capacité à générer des blocs imbriqués dynamiques. Reprenons les étiquettes de l'Auto Scaling Group (ASG) du module webserver-cluster. On ajoute d'abord une variable custom_tags de type map(string), puis on génère les blocs tag à la volée avec un bloc dynamic :

resource "aws_autoscaling_group" "example" {
  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnet_ids.default.ids
  target_group_arns    = [aws_lb_target_group.asg.arn]
  health_check_type    = "ELB"

  min_size = var.min_size
  max_size = var.max_size

  tag {
    key                 = "Name"
    value               = var.cluster_name
    propagate_at_launch = true
  }

  dynamic "tag" {
    for_each = var.custom_tags

    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }
}

La syntaxe d'un bloc dynamic comporte un nom (ici "tag", le type de bloc à générer), une expression for_each et un bloc content qui décrit ce qu'on produit à chaque itération ; à l'intérieur, tag.key et tag.value désignent la clé et la valeur de l'élément courant. Si var.custom_tags est vide, aucun bloc n'est généré : vous tenez déjà là une forme de logique conditionnelle.

Astuce

Adopter un standard d'étiquetage et l'imposer comme du code dans vos modules est une excellente pratique. Une étiquette Owner désigne l'équipe propriétaire ; une étiquette DeployedBy = "terraform" signale que l'infrastructure est gérée par Terraform — et donc qu'on ne doit jamais la modifier à la main, sous peine des ennuis décrits plus loin dans « Un plan valide peut échouer ».

Les expressions for

Boucler sur des ressources et des blocs, c'est bien ; mais comment générer une valeur unique par transformation d'une liste ou d'une map ? C'est le rôle de l'expression for (à ne pas confondre avec for_each), proche des compréhensions de liste de Python. Sa syntaxe de base est [for ITEM in LIST : OUTPUT]. Pour passer une liste de noms en majuscules :

variable "names" {
  description = "A list of names"
  type        = list(string)
  default     = ["neo", "trinity", "morpheus"]
}

output "upper_names" {
  value = [for name in var.names : upper(name)]
}

Comme en Python, on peut filtrer le résultat avec une condition if, par exemple [for name in var.names : upper(name) if length(name) < 5] ne conserve que "NEO". L'expression for sait aussi parcourir une map avec la forme [for KEY, VALUE in MAP : OUTPUT], et même produire une map plutôt qu'une liste : il suffit d'entourer l'expression d'accolades plutôt que de crochets, et de séparer la clé de sortie de la valeur de sortie par une flèche.

variable "hero_thousand_faces" {
  type = map(string)
  default = {
    neo      = "hero"
    trinity  = "love interest"
    morpheus = "mentor"
  }
}

output "upper_roles" {
  value = { for name, role in var.hero_thousand_faces : upper(name) => upper(role) }
}

Les boucles dans les chaînes : la directive for

Les interpolations de chaîne permettent de référencer du code Terraform dans une chaîne. Les directives de chaîne (string directives) vont plus loin : elles autorisent des instructions de contrôle au sein d'une chaîne, avec une syntaxe analogue à l'interpolation, mais en remplaçant le dollar par un signe pourcent : %{...} au lieu du dollar suivi d'accolades. La directive for s'écrit %{ for ITEM in COLLECTION }BODY%{ endfor }.

variable "names" {
  type    = list(string)
  default = ["neo", "trinity", "morpheus"]
}

output "for_directive" {
  value = <<EOF
%{ for name in var.names }
  ${name}
%{ endfor }
EOF
}

Cette première version produit beaucoup de sauts de ligne superflus. Pour les absorber, on place un marqueur de suppression (strip marker), le tilde ~, au début de la directive (il consomme les espaces et retours à la ligne qui la précèdent) ou à la fin (ceux qui la suivent) : %{~ for name in var.names }.

Les conditions avec count

De même qu'il offre plusieurs façons de boucler, Terraform propose plusieurs façons de poser une condition : count pour des ressources conditionnelles, for_each et for pour des ressources et blocs conditionnels, et la directive if pour les chaînes.

L'astuce avec count repose sur deux propriétés. D'une part, count = 1 crée une copie de la ressource ; count = 0 n'en crée aucune. D'autre part, Terraform supporte l'opérateur ternaire de la forme CONDITION ? TRUE_VAL : FALSE_VAL. En les combinant, on simule une instruction if. Au chapitre précédent, l'action de mise à l'échelle planifiée (scheduled action) n'était définie qu'en production ; on peut désormais l'intégrer au module et la créer conditionnellement grâce à une variable booléenne :

variable "enable_autoscaling" {
  description = "If set to true, enable auto scaling"
  type        = bool
}

resource "aws_autoscaling_schedule" "scale_out_during_business_hours" {
  count = var.enable_autoscaling ? 1 : 0

  scheduled_action_name  = "${var.cluster_name}-scale-out-during-business-hours"
  min_size               = 2
  max_size               = 10
  desired_capacity       = 10
  recurrence             = "0 9 * * *"
  autoscaling_group_name = aws_autoscaling_group.example.name
}

Si var.enable_autoscaling vaut true, count vaut 1 et la ressource est créée ; sinon elle vaut 0 et la ressource n'existe pas. Le staging passe enable_autoscaling = false, la production true : c'est exactement la logique conditionnelle voulue.

Cela fonctionne quand l'appelant fournit un booléen explicite. Mais que faire quand la condition résulte d'une comparaison, par exemple une égalité de chaîne ? Imaginons une alarme CloudWatch sur le solde de crédits CPU (CPUCreditBalance) : cette métrique n'existe que pour les instances de type tXXX (comme t2.micro). Sur une m4.large, l'alarme resterait coincée à l'état INSUFFICIENT_DATA. Plutôt qu'ajouter une variable booléenne redondante, on calcule la condition directement :

resource "aws_cloudwatch_metric_alarm" "low_cpu_credit_balance" {
  count = format("%.1s", var.instance_type) == "t" ? 1 : 0

  alarm_name          = "${var.cluster_name}-low-cpu-credit-balance"
  namespace           = "AWS/EC2"
  metric_name         = "CPUCreditBalance"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 1
  period              = 300
  statistic           = "Minimum"
  threshold           = 10
  unit                = "Count"
}

La fonction format extrait le premier caractère de var.instance_type ; s'il s'agit d'un « t », count vaut 1, sinon 0. L'alarme n'est donc créée que pour les types d'instances qui rapportent réellement cette métrique.

Du if au if-else

On peut aller jusqu'au if-else simple, où seule importe l'exécution de l'une ou de l'autre branche. Pour attacher à l'utilisateur neo soit une politique CloudWatch en lecture seule, soit une politique en accès complet, on pose deux attachements aux conditions opposées : count = var.give_neo_cloudwatch_full_access ? 1 : 0 pour l'un, ? 0 : 1 pour l'autre. L'un est créé, l'autre non.

Le cas devient subtil quand le reste du code doit lire un attribut de la branche effectivement exécutée. Supposons deux scripts User Data au choix, chacun chargé par une source de données template_file munie d'un count opposé. Comme l'une des deux sources est un tableau de longueur 1 et l'autre de longueur 0, on ne peut pas indexer directement [0] (le tableau pourrait être vide). La solution emploie une expression d'éclatement, qui renvoie toujours un tableau, puis teste sa longueur :

resource "aws_launch_configuration" "example" {
  image_id        = "ami-0c55b159cbfafe1f0"
  instance_type   = var.instance_type
  security_groups = [aws_security_group.instance.id]

  user_data = (
    length(data.template_file.user_data[*]) > 0
    ? data.template_file.user_data[0].rendered
    : data.template_file.user_data_new[0].rendered
  )

  lifecycle {
    create_before_destroy = true
  }
}

Terraform évalue les résultats conditionnels en différé (lazy evaluation) : seule la branche correspondant à la valeur de la condition est évaluée. Indexer [0] est donc sûr, car seul le tableau non vide sera réellement lu. Recourir à count et aux fonctions intégrées pour simuler un if-else est un peu un bricolage, mais il fonctionne bien et masque beaucoup de complexité : l'utilisateur du module obtient une API propre et simple.

Les conditions avec for_each et la directive if

On devine la suite : passer une collection vide à for_each ne produit aucune ressource ni aucun bloc, une collection non vide en produit. Il suffit donc de décider conditionnellement de la vacuité de la collection, en combinant for_each avec une expression for. On peut ainsi, sur les étiquettes de l'ASG, convertir les valeurs en majuscules et filtrer au passage la clé Name, que le module gère déjà lui-même :

dynamic "tag" {
  for_each = {
    for key, value in var.custom_tags :
    key => upper(value)
    if key != "Name"
  }

  content {
    key                 = tag.key
    value               = tag.value
    propagate_at_launch = true
  }
}

Note

Même si l'on doit presque toujours préférer for_each à count pour créer plusieurs copies d'une ressource, pour la logique conditionnelle poser count à 0 ou 1 reste plus simple que jongler avec une collection vide ou non. Bonne règle : count pour créer conditionnellement des ressources, for_each pour toutes les autres boucles et conditions.

Enfin, la directive de chaîne if complète la directive for. Sa forme est %{ if CONDITION }TRUEVAL%{ endif }, avec une clause else optionnelle : %{ if CONDITION }TRUEVAL%{ else }FALSEVAL%{ endif }. Elle rend conditionnellement un fragment au sein d'une chaîne — par exemple afficher un nom s'il est fourni, et (unnamed) sinon.

Le déploiement sans interruption

Le module expose maintenant une API propre. Reste la vraie question opérationnelle : comment mettre à jour le cluster — déployer une nouvelle image machine (AMI, Amazon Machine Image) ou un nouveau texte de serveur — sans coupure de service ? Quand on modifie le user_data ou l'AMI, Terraform veut remplacer l'ancienne aws_launch_configuration par une nouvelle, puis modifier l'ASG en place pour référencer cette nouvelle configuration. Or référencer une nouvelle launch configuration ne suffit pas : l'ASG ne lancera de nouvelles instances que plus tard. Détruire puis recréer l'ASG (via terraform destroy puis apply) provoquerait une interruption. La bonne approche est de créer le remplaçant d'abord, puis de détruire l'original — exactement ce que fait le réglage de cycle de vie create_before_destroy.

La technique tient en trois réglages sur l'aws_autoscaling_group :

  1. Faire dépendre le name de l'ASG directement du nom de la launch configuration. À chaque changement de cette dernière (mise à jour de l'AMI ou du User Data), son nom change, donc le nom de l'ASG change, ce qui force le remplacement de l'ASG.
  2. Poser create_before_destroy = true pour que le remplaçant soit créé avant que l'original ne soit détruit.
  3. Poser min_elb_capacity égal au min_size du cluster, afin que Terraform attende qu'au moins ce nombre de serveurs du nouvel ASG passent les contrôles de santé (health checks) du load balancer applicatif (ALB) avant de commencer à détruire l'ancien.
resource "aws_autoscaling_group" "example" {
  # Le nom dépend de la launch configuration : il change à chaque
  # remplacement, ce qui force le remplacement de cet ASG.
  name = "${var.cluster_name}-${aws_launch_configuration.example.name}"

  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnet_ids.default.ids
  target_group_arns    = [aws_lb_target_group.asg.arn]
  health_check_type    = "ELB"

  min_size = var.min_size
  max_size = var.max_size

  # Attendre qu'au moins ce nombre d'instances passent les health checks
  min_elb_capacity = var.min_size

  # Lors d'un remplacement, créer le nouveau d'abord, supprimer l'ancien après.
  lifecycle {
    create_before_destroy = true
  }
}

Le déploiement se déroule alors comme un déploiement progressif (rolling deployment) : on part de l'ASG original servant la v1 ; un changement (nouvelle AMI ou nouveau texte) force Terraform à déployer un nouvel ASG avec la v2 ; après une minute ou deux, les serveurs de la v2 démarrent, se connectent à la base, s'enregistrent dans l'ALB et passent les health checks. Les deux versions coexistent alors brièvement. Une fois min_elb_capacity serveurs v2 enregistrés, Terraform désenregistre puis éteint les serveurs v1. À aucun moment le trafic n'est laissé sans serveur.

  v1 seule          v2 démarre        v1 + v2 servent    v1 s'éteint        v2 seule
  ┌──────┐          ┌──────┐          ┌──────┐           ┌──────┐           ┌──────┐
  │ ASG  │   ──►    │ ASG  │   ──►    │ ASG  │    ──►     │ ASG  │   ──►     │ ASG  │
  │  v1  │          │ v1+  │          │ v1 + │           │  +v2 │           │  v2  │
  └──────┘          │  v2  │          │  v2  │           └──────┘           └──────┘
                    └──────┘     (min_elb_capacity OK)  (désenreg. v1)
       ◄──────────────────── ALB route toujours vers des serveurs sains ────────────►

Pour observer le phénomène, on peut marteler l'ALB une fois par seconde pendant un nouveau déploiement :

while true; do curl http://<load_balancer_url>; sleep 1; done

On verra d'abord l'ancien texte, puis une alternance entre l'ancien et le nouveau (les deux ASG coexistent), enfin uniquement le nouveau texte une fois l'ancien ASG éteint.

Astuce

Bonus appréciable : si quelque chose tourne mal pendant le déploiement, Terraform revient automatiquement en arrière (rollback). Si la v2 contient un bug et ne démarre pas, ses instances ne s'enregistrent jamais dans l'ALB. Terraform patiente jusqu'à wait_for_capacity_timeout (10 minutes par défaut) puis, faute des min_elb_capacity serveurs attendus, considère le déploiement comme un échec, supprime le nouvel ASG et sort en erreur — pendant que la v1 continue de tourner sans encombre.

Les pièges de Terraform

Après tous ces trucs et astuces, prenons du recul pour pointer quelques pièges, propres aux techniques de boucle, de condition et de déploiement, mais aussi à Terraform dans son ensemble.

count et for_each ont des limites

Deux restrictions importantes encadrent ces constructions. D'abord, on ne peut pas référencer la sortie d'une ressource dans count ou for_each. Terraform exige de pouvoir calculer ces valeurs durant la phase plan, avant que la moindre ressource ne soit créée. On peut donc s'appuyer sur des valeurs en dur, des variables, des sources de données et même des listes de ressources (tant que leur longueur est connue au moment du plan) — mais pas sur un attribut de ressource calculé (computed), connu seulement après apply. Tenter d'utiliser la sortie d'une ressource random_integer comme count lève une erreur explicite :

Error: Invalid count argument

The "count" value depends on resource attributes that cannot be determined
until apply, so Terraform cannot predict how many instances will be created.

Ensuite, on ne peut pas utiliser count ni for_each sur un bloc module. Le tenter — pour créer plusieurs copies d'un module ou en inclure un conditionnellement avec count = 0 — échoue : le nom count est réservé à un usage futur de Terraform. (Cette limite a été levée dans des versions ultérieures à la 2e édition du livre ; vérifiez le CHANGELOG.)

Le déploiement sans interruption a des limites

create_before_destroy sur un ASG est une excellente technique, mais elle ne s'accommode pas des politiques de mise à l'échelle automatique (auto scaling policies). Plus exactement, elle ramène la taille de l'ASG à son min_size après chaque déploiement. Si une action planifiée fait passer le cluster de 2 à 10 serveurs à 9 h et que vous déployez à 11 h, le nouvel ASG redémarrera à 2 serveurs et y restera jusqu'au lendemain 9 h. Deux contournements : élargir la recurrence de l'action planifiée (par exemple 0-59 9-17 * * * pour réappliquer la politique chaque minute aux heures ouvrées), ou écrire un script qui lit la capacité courante via l'API AWS et la réinjecte dans desired_capacity au travers d'une source de données externe — au prix d'un code moins portable.

Un plan valide peut échouer

Il arrive qu'un plan paraisse parfaitement valide, mais que l'apply échoue. Créer un aws_iam_user portant un nom déjà existant produit un plan d'apparence raisonnable, puis :

Error: Error creating IAM User yevgeniy.brikman: EntityAlreadyExists:
User with name yevgeniy.brikman already exists.

La clé : terraform plan ne regarde que les ressources présentes dans son fichier d'état (state file). Toute ressource créée hors bande — en cliquant dans la console, en CLI — y est absente, donc ignorée par le plan, d'où un plan trompeur qui échoue. Deux leçons en découlent. Une fois Terraform adopté, n'utilisez plus que Terraform : ne modifiez jamais à la main une partie de l'infrastructure qu'il gère, sous peine d'erreurs étranges et de la perte des bénéfices de l'IaC. Et si vous avez de l'infrastructure préexistante, utilisez terraform import pour l'inscrire dans l'état :

terraform import aws_iam_user.existing_user yevgeniy.brikman

La commande prend l'« adresse » de la ressource dans la configuration et un identifiant propre au fournisseur ; dès lors, le plan sait que la ressource existe déjà et ne tente plus de la recréer.

Le refactoring peut être délicat

Le refactoring — restructurer le code interne sans changer son comportement externe — est une pratique saine. Mais avec l'IaC, il faut soigneusement définir ce qu'est le « comportement externe », sous peine de panne. Deux cas classiques se révèlent piégeux.

Renommer la valeur d'un paramètre name. Si la variable cluster_name alimente le name de l'ALB et de groupes de sécurité, la faire passer de foo à bar pousse Terraform à supprimer l'ancienne ressource et à en créer une nouvelle : pendant la transition, l'ALB ne route plus rien, ou les serveurs rejettent tout le trafic réseau. Renommer un identifiant Terraform. Renommer la ressource aws_security_group.instance en aws_security_group.cluster_instance produit le même désastre : Terraform associe chaque identifiant à un identifiant du fournisseur cloud ; changer l'identifiant équivaut, à ses yeux, à supprimer l'ancienne ressource et à en ajouter une toute neuve.

Piège courant

Ne modifiez jamais un fichier d'état à la main. Pour renommer un identifiant sans détruire-recréer la ressource, employez terraform state mv ORIGINAL NOUVEAU, par exemple terraform state mv aws_security_group.instance aws_security_group.cluster_instance. Vous saurez que c'est réussi si le plan suivant n'annonce aucun changement. Les versions récentes offrent aussi le bloc moved pour consigner ces déplacements directement dans le code.

Quatre leçons : toujours lancer plan et scruter la sortie pour repérer une suppression non désirée ; envisager create before destroy quand un remplacement est inévitable ; modifier l'état (state mv) pour changer un identifiant ; et se rappeler que certains paramètres sont immuables — les changer entraîne un remplacement, ce que la documentation de chaque ressource précise.

La cohérence à terme est cohérente… à terme

Les API de certains fournisseurs, dont AWS, sont asynchrones et cohérentes à terme (eventually consistent). Asynchrone : l'API répond immédiatement, sans attendre la fin de l'action demandée. Cohérente à terme : un changement met un certain temps à se propager dans tout le système, si bien que des réponses incohérentes peuvent survenir selon la réplique interrogée. Demandez la création d'une instance EC2 : l'API renvoie un 201 Created quasi instantané, mais une requête immédiate sur cette instance peut retourner un 404 Not Found le temps que l'information se propage. La règle est d'attendre et de réessayer. Terraform a longtemps souffert de bogues de ce type :

$ terraform apply
aws_subnet.private-persistence.2: InvalidSubnetID.NotFound:
The subnet ID 'subnet-xxxxxxx' does not exist

La plupart ont été corrigés, mais ils resurgissent parfois, surtout lors de l'ajout d'un nouveau type de ressource. Ils sont agaçants mais le plus souvent inoffensifs : relancez terraform apply et, l'information ayant entre-temps fini de se propager, tout rentre dans l'ordre.

À retenir

  • count itère par position : la ressource devient un tableau, indexé par count.index ; retirer un élément du milieu déclenche un destroy-recreate en cascade, et count ne sait pas générer de blocs imbriqués.
  • Préférez for_each à count pour créer des copies : la ressource devient une map indexée par clé, donc sûre aux suppressions ; le bloc dynamic génère des blocs imbriqués à la volée, et l'expression for transforme et filtre listes et maps.
  • Les conditions se simulent avec count = CONDITION ? 1 : 0 (le plus simple pour des ressources optionnelles), avec une collection vide/non vide passée à for_each, ou avec la directive de chaîne %{ if }.
  • Zéro interruption = create_before_destroy + nom d'ASG dérivé de la launch configuration + min_elb_capacity : déploiement progressif, le nouvel ASG sert le trafic avant que l'ancien ne s'éteigne, avec rollback automatique en cas d'échec.
  • count/for_each se calculent au plan : ils acceptent variables, sources de données et listes de longueur connue, mais jamais une sortie de ressource calculée, ni un bloc module (à l'époque du livre).
  • Un plan valide peut échouer si l'infrastructure existe hors de l'état : n'utilisez plus que Terraform après l'avoir adopté, et terraform import pour rapatrier l'existant.
  • Refactorez avec prudence : renommer un name ou un identifiant détruit-recrée la ressource ; passez par terraform state mv (ou un bloc moved), lancez toujours plan, et acceptez la cohérence à terme en relançant apply au besoin.