Aller au contenu
Infrastructure as Code medium

count Terraform : N instances, et le piège de l'index

30 min de lecture

logo terraform

Le meta-argument count crée plusieurs instances d'une même ressource à partir d'un nombre. Il est facile à écrire, et coûteux à défaire : ses instances sont identifiées par leur position, pas par leur identité. Retirer un élément du milieu d'une liste décale tous les suivants, et Terraform détruit alors des objets que personne n'avait demandé de toucher.

Ce guide part de la base, count et count.index, puis traite le piège du décalage d'index, le choix entre count et for_each, la migration non destructrice par bloc moved, et le count conditionnel exposé par one(). Tous les comportements ont été vérifiés sur Terraform v1.15.4, plan JSON à l'appui.

  • Créer N instances avec count, et les personnaliser via count.index
  • Les adresser (ressource.nom[i]), et les exposer (splat [*], one())
  • Le piège du décalage d'index, et pourquoi c'est un remplacement
  • Choisir entre count et for_each, et l'interdiction de les cumuler
  • Migrer de count vers for_each sans rien détruire, avec moved
  • Prouver chaque changement dans le plan JSON

count = N crée N instances identiques d'une ressource, adressées ressource.nom[0], ressource.nom[1], etc. Dans le bloc, count.index donne l'index courant, un entier qui démarre à 0 :

resource "libvirt_volume" "disk" {
count = 3
name = "worker-${count.index}.qcow2"
pool = "default"
}

Trois volumes sont créés : disk[0], disk[1], disk[2]. La valeur de count doit être connue avant l'apply : elle ne peut pas dépendre d'un attribut qui n'existe qu'une fois une ressource créée (known after apply). Une variable, une data source lue au plan, ou une constante conviennent.

count accepte aussi bien un bloc resource qu'un bloc module, data ou ephemeral : la documentation les liste tous dans « Supported constructs ». Créer trois instances d'un module se fait de la même façon, count = length(local.noms) puis name = local.noms[count.index].

Avec count, une ressource devient une liste d'instances, adressables par index. On accède à l'une par ressource.nom[i], et à l'instance courante dans le même bloc par ressource.nom[count.index] :

resource "libvirt_domain" "vm" {
count = 3
name = "worker-${count.index}"
memory = 512
memory_unit = "MiB"
devices = {
disks = [
{
source = { file = { file = libvirt_volume.disk[count.index].path } }
target = { dev = "vda", bus = "virtio" }
}
]
}
}

Ce bloc référence déjà libvirt_volume.disk[count.index].path : la dépendance est implicite, et ajouter un depends_on = [libvirt_volume.disk] serait redondant, en plus de rendre le plan plus conservateur. Pour exposer tous les attributs d'une ressource count, l'opérateur splat [*] extrait un attribut de chaque instance et rend une liste :

output "vm_names" {
value = libvirt_domain.vm[*].name
# ["worker-0", "worker-1", "worker-2"]
}

Le splat fonctionne avec count parce que la ressource est une liste. Il ne s'applique pas à une ressource for_each, qui est une map : là, il faut une expression for.

for_each prend une map ou un set et crée une instance par élément, adressée par sa clé : ressource.nom["web"]. C'est l'autre façon de créer plusieurs instances, et un même bloc ne peut pas porter les deux :

resource "local_file" "bad" {
count = 2
for_each = toset(["a", "b"])
}
Error: Invalid combination of "count" and "for_each"

La différence tient en un mot : count indexe par entier (la position), for_each par clé (l'identité). Ce mot décide de tout ce qui suit.

Voici la raison d'être de ce guide. Avec count, l'identité d'une instance est sa position. Prenons trois VMs nommées d'après une liste :

variable "services" {
type = list(string)
default = ["web", "api", "cache"]
}
resource "local_file" "service" {
count = length(var.services)
filename = "service-${var.services[count.index]}.txt"
content = "service=${var.services[count.index]}\n"
}

Retirez api, celle du milieu. On s'attend à ne perdre que api. Le plan JSON dit autre chose :

Fenêtre de terminal
terraform plan -out=tfplan -var 'services=["web","cache"]'
terraform show -json tfplan | jq -c '.resource_changes[] | {address, actions: .change.actions}'
{"address":"local_file.service[0]","actions":["no-op"]}
{"address":"local_file.service[1]","actions":["delete","create"]}
{"address":"local_file.service[2]","actions":["delete"]}

service[1], qui portait api, est détruite puis recréée avec cache, et service[2] est détruite. Ce n'est pas « un simple changement » sur service[1] : c'est un remplacement (["delete","create"]). Retirer un service en a recréé un autre. Sur un fichier, c'est indolore ; sur une base de données, c'est un incident.

Les mêmes services, mais keyés par nom, ne décalent rien. Retirer api ne touche que api :

resource "local_file" "service" {
for_each = toset(var.services)
filename = "service-${each.key}.txt"
content = "service=${each.key}\n"
}
{"address":"local_file.service[\"web\"]","actions":["no-op"]}
{"address":"local_file.service[\"api\"]","actions":["delete"]}
{"address":"local_file.service[\"cache\"]","actions":["no-op"]}

D'où la règle, reprise mot pour mot de la documentation : utilisez count quand les instances sont presque identiques et interchangeables (des workers, des replicas) ; utilisez for_each dès qu'elles ont une identité propre, ou que leurs arguments viennent de valeurs distinctes qu'un ajout ou un retrait ne doit pas déplacer.

for_each a ses propres contraintes : ses clés doivent être connues au plan (pas dérivées d'un attribut known after apply), il n'accepte pas de valeur sensible comme clé, ni de fonction impure (uuid, timestamp, bcrypt), et une liste doit être convertie en set (toset(...)) pour éviter que ses index ne redeviennent positionnels.

C'est le point qui sépare l'usage débutant de l'usage professionnel. Vous avez appliqué une ressource en count, et vous voulez passer à for_each pour tuer le piège. Changer le bloc sans précaution détruit et recrée tout : les adresses passent de service[0] à service["web"], et Terraform ne devine pas la correspondance. Un plan de migration nu annonce alors 3 to add, 3 to destroy.

Le bloc moved donne la correspondance à Terraform :

resource "local_file" "service" {
for_each = toset(var.services)
filename = "service-${each.key}.txt"
content = "service=${each.key}\n"
}
moved {
from = local_file.service[0]
to = local_file.service["web"]
}
moved {
from = local_file.service[1]
to = local_file.service["api"]
}
moved {
from = local_file.service[2]
to = local_file.service["cache"]
}

Le plan devient entièrement no-op, et chaque changement d'adresse est tracé par previous_address dans le JSON :

Fenêtre de terminal
terraform show -json tfplan | jq -c '.resource_changes[] | {address, prev: .previous_address, actions: .change.actions}'
{"address":"local_file.service[\"web\"]","prev":"local_file.service[0]","actions":["no-op"]}

Un cas plus simple se gère tout seul depuis les versions récentes de Terraform : ajouter count = 1 à une ressource qui n'en avait pas migre service vers service[0] sans destruction (has moved to, 0 to destroy). Mais dès qu'il faut deviner des clés, comme pour for_each, le bloc moved est obligatoire.

Le seul usage vraiment courant de count reste le on/off : une ressource présente ou absente selon un booléen, avec le pattern count = condition ? 1 : 0.

resource "libvirt_domain" "monitoring" {
count = var.enable_monitoring ? 1 : 0
name = "monitoring"
}

À false, aucune instance n'existe. Passer de 0 à 1 crée monitoring[0] et ne détruit rien d'autre : le « count 0 vers 1 détruit les voisins » est un mythe. Chaque instance a son propre objet, créé, mis à jour ou détruit indépendamment.

Pour exposer l'attribut d'une ressource qui a 0 ou 1 instance, la liste splat monitoring[*].name a une longueur 0 ou 1. La fonction one() la réduit à une valeur unique, ou null si elle est vide, et lève une erreur au-delà d'un élément :

output "monitoring_name" {
value = one(libvirt_domain.monitoring[*].name)
}

Une sortie dont la valeur est null est simplement omise de terraform output, ce qui est le comportement attendu quand l'option est désactivée.

terraform state list et la sortie humaine du plan se lisent trop vite. La seule preuve non ambiguë d'un changement de count est le plan au format JSON :

Fenêtre de terminal
terraform plan -out=tfplan
terraform show -json tfplan | jq -c '.resource_changes[] | {address, actions: .change.actions, prev: .previous_address}'
  • resource_changes[].actions distingue ["no-op"], ["create"], ["update"], ["delete"] et le remplacement ["delete","create"]. C'est lui qui révèle les destructions parasites d'un décalage d'index.
  • previous_address n'apparaît que si l'adresse a changé, typiquement via un bloc moved. C'est la preuve qu'une migration est bien un déplacement, et non une destruction suivie d'une création.

Ces symptômes se lisent tous dans le plan, avant l'apply.

SymptômeCauseSolution
Des instances détruites/recréées au retrait d'un élément du milieuDécalage d'index : l'identité count est positionnellePasser à for_each keyé par une valeur stable
count.index refuséUtilisé hors d'un bloc portant countDéclarer count sur la ressource, ou retirer count.index
Invalid combination of "count" and "for_each"Les deux meta-arguments sur le même blocN'en garder qu'un
The "count" value depends on resource attributes that cannot be determined until applycount dérive d'un attribut known after applyLe fonder sur une variable ou une data source lue au plan
Migration count vers for_each qui détruit toutAucun bloc moved pour relier index et clésAjouter un moved { from = ...[i] to = ...["clé"] } par instance
  1. count = N crée N instances indexées par entier ; count.index démarre à 0.
  2. La valeur de count doit être connue au plan, jamais known after apply.
  3. Le décalage d'index au retrait d'un élément du milieu est un remplacement (["delete","create"]), pas une simple mise à jour.
  4. count pour des instances interchangeables ; for_each dès qu'elles ont une identité propre. Jamais les deux dans un même bloc.
  5. Migrer count vers for_each sans moved détruit tout ; avec moved, le plan est no-op et previous_address trace le déplacement.
  6. count = cond ? 1 : 0 rend une ressource optionnelle, exposée par one().
  7. Le splat [*] marche sur count (liste), pas sur for_each (map).
  8. La preuve d'un changement est dans resource_changes[].actions, pas dans la sortie humaine.

Les questions ci-dessous reprennent les confusions les plus fréquentes autour de count : le choix avec for_each, le piège du décalage d'index, et la migration sans destruction.

  • Les boucles for : Transforme une collection avant de la passer à count ou à for_each.
  • Blocs dynamiques : Répète un bloc imbriqué, ce que count ne sait pas faire.
  • Le bloc lifecycle : Encadre les remplacements de ressources, fréquents quand l'index se décale.

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