Terraform: типи даних, цикли, індекси, та “resource must be replaced”

Автор |  04/06/2025

У нас є автоматизація для AWS IAM, яка створює EKS Access Entries для підключення AWS IAM Users до кластеру.

Не пам’ятаю, чи я писав її сам, чи нагенерила якась LLM (хоча судячи з коду – писав сам 🙂 ), але згодом виявилась неприємна особливість того, як ця автоматизація працює: при видаленні юзера Terraform починає робити “re-mapping” інших юзерів.

Власне, сьогодні глянемо на те, як я це все діло робив, згадаємо типи даних Terraform, і подивимось як треба було зробити, аби такої проблеми не виникало.

Хоча помилка описана щодо aws_eks_access_entry (тут в прикладах буде local_file замість aws_eks_access_entry), насправді вона стосується загального підходу до використання індексів та циклів у Terraform.

Поточна реалізація

Спрощено вона виглядає так:

variable "eks_clusters" {
  description = "List of EKS clusters to create records"
  type        = set(string)
  default = [
    "cluster-1",
    "cluster-2"
  ]
}

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

locals {
  eks_users_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

resource "local_file" "backend" {
  for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

Тільки в оригіналі замість resource "local_file" використовується resource "aws_eks_access_entry".

Власне, в цьому коді:

  • variable "eks_clusters": містить список наших EKS кластерів, до яких треба підключити юзерів
  • variable "eks_users": містить списки груп (бекенд в цьому прикладі) і юзерів в цій групі – user1, user2, user3
  • locals.eks_users_access_entries_backend: створює список на кожну унікальну комбінацію EKS кластер + IAM юзер
  • resource "local_file": для кожного кластера і кожного юзера створює файл з іменем у вигляді [email protected]

Тепер поглянемо детальніше на змінні та типи даних – бо робив це давно, корисно самому згадати.

Variables та типи даних

variable "eks_clusters"

Тип просто set(string) з двома елементами – “cluster-1” та “cluster-2“:

variable "eks_clusters" {
  description = "List of EKS clusters to create records"
  type        = set(string)
  default = [
    "cluster-1",
    "cluster-2"
  ]
}

Тип set[] не має індексів, і звернення до об’єктів виконується в будь-якому порядку.

variable "eks_users"

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

Тут у нас вже змінна з типом map(list(string)).

map{} – це набір key => value даних, де key – ім’я групи юзерів (devops, backend, qa), а в value маємо вкладений list[] з об’єктами типу string, де кожен об’єкт – це ім’я юзера.

А list – на відміну від set – окрім інших відмінностей має індекси для кожного елемента, а тому порядок звернення до об’єктів у list буде по черзі.

Тобто ми можемо звернутись до них за індексами 0-1-2:

  • eks_users["backend"].0: буде user1
  • eks_users["backend"].1: буде user2
  • eks_users["backend"].2: буде user3

Можемо це вивести це в outputs:

output "eks_users_all" {
  value = var.eks_users
}

output "eks_users_backend" {
  value = var.eks_users["backend"]
}

output "eks_users_backend_user_1" {
  value = var.eks_users["backend"].0
}

output "eks_users_backend_user_2" {
  value = var.eks_users["backend"].1
}
...

І в результаті terraform apply отримаємо:

$ terraform apply
...
eks_users_all = tomap({
  "backend" = tolist([
    "user1",
    "user2",
  ])
})
eks_users_backend = tolist([
  "user1",
  "user2",
])
eks_users_backend_user_1 = "user1"
eks_users_backend_user_2 = "user2"

Або просто глянути в terraform console:

> var.eks_users["backend"].0
"user1"
> var.eks_users["backend"].1
"user2"
> var.eks_users["backend"].2
"user3"

Але проблема виникає не через самі індекси – а через те, як вони змінюються, якщо елемент зі списку видаляється або переміщується, особливо якщо ці індекси використовуються як ключі для for_each. Власне, скоро до цього дійдемо.

local.eks_users_access_entries_backend

locals {
  eks_users_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

Тут ми використовуємо подвійний for, який перебирає кожен кластер із set(string) в var.eks_clusters, а потім кожного юзера із list(string) в var.eks_users.backend.

Давайте поки приберемо flatten():

...
  eks_users_access_entries_backend_unflatten = [
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ]
...

Тепер в eks_users_access_entries_backend_unflatten = [ ... ] ми отримуємо вкладений список – list(list(object)):

  • зовнішній name = [ ... ] – це перший рівень списку, де кожен елемент – це кластер із var.eks_clusters
  • далі з for cluster in var.eks_clusters : [ ... ] ми формуємо окремий вкладений список об’єктів для кожного кластеру
  • і потім з for user_arn in var.eks_users.backend : { ... } створюються objects з полями cluster_name та principal_arn – по одному об’єкту на кожну пару cluster_name та principal_arn

Теж глянемо з terraform console:

> local.eks_users_access_entries_backend_unflatten
tolist([
  [
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user1"
    },
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user2"
    },
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user3"
    },
  ],
  [
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user1"
    },
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user2"
    },
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user3"
    },
  ],
])

А flatten() нам просто прибирає цю вкладеність list(list(object)), і перетворює результат в плаский list(object), де кожен об’єкт – це унікальна пара кластер + юзер:

...
eks_users_access_entries_backend = [
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  },
]
...

Окей – з цим розібрались, йдемо далі – подивимось, як цей список буде використовувати в for_each, і які помилки я там допустив.

resource "local_file" "backend"

Ну і основне – використовуючи це список ми створюємо юзерів для кожного кластеру:

...
resource "local_file" "backend" {
  for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

В оригіналі це виглядає так:

resource "aws_eks_access_entry" "backend" {
  for_each = { for cluser, user in local.eks_users_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn

  kubernetes_groups = [
    "backend-team"
  ]
}

І знову цикли і списки 🙂

Що ми тут маємо: з { for cluster, user in local.eks_users_access_entries_backend : cluster => user } формується map{}, де ключем (cluster) стає індекс кожного елементу зі списку local.eks_users_access_entries_backend, а значенням (user) – сам object з цього списку за цим індексом, і цей об’єкт містить поля cluster_name та principal_arn.

Тобто, в cluster ми будемо мати значення 0, 1, 2, а в user – значення { cluster_name = "cluster-1", principal_arn = "user1" }, { cluster_name = "cluster-1", principal_arn = "user2" }, { cluster_name = "cluster-1", principal_arn = "user3" } відповідно.

Отже, перша моя помилка – це взагалі імена cluster та user в самому циклі for, бо правильніше було б їх назвати просто for index, entry in ..., ну або ж index (чи idx) та user – бо все ж в кожному user маємо комбінацію, яка ідентифікую саме юзера – кластер:юзер.

Ще один нюанс: оскільки в цьому for_each ключем виступає індекс з типом number, а значення – це об’єкт з типом object, то ми отримуємо не map{} – а object{}, бо в map ключ та значення мають бути одного типу, і Terraform не може створити map(number => object):

> type({ for cluster, user in local.eks_users_access_entries_backend : cluster => user })
object({
    0: object({
        cluster_name: string,
        principal_arn: string,
    }),
    1: object({
        cluster_name: string,
        principal_arn: string,
    }),
...

Хоча зараз це не принципово.

The Issue

Тепер йдемо далі, до головної проблеми: якщо ми в списку юзерів variable "eks_users" видаляємо юзера, тобто замість:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

Зробимо:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user3",
    ]
  }
}

То це призведе до того, що, по-перше, зміниться local.eks_users_access_entries_backend, бо замість 6 об’єктів:

> local.eks_users_access_entries_backend
[
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  },
]

Ми отримаємо новий список з 4 об’єктами:

> local.eks_users_access_entries_backend
[
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  },
]

А оскільки for_each формується на основі індексів списку local.eks_users_access_entries_backend:

for_each = {
  for cluster, user in local.eks_users_access_entries_backend :
  cluster => user
}

То коли зміниться кількість елементів в local.eks_users_access_entries_backend – то map{} (котрий все ж object) в умові для for_each зміниться теж, бо він створюється на основі індексів списку local.eks_users_access_entries_backend.

Тобто замість 0, 1, … 5:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  }
  "2" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "4" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  }
  "5" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

Ми вже тримаємо 0, 1, … 3:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "2" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

І якщо раніше for_each створював map з ключем “3” і значенням {“cluster_name” = “cluster-2” “principal_arn” = “user1“}, то тепер ключ 3 буде мати значення {“cluster_name” = “cluster-2” “principal_arn” = “user3“}.

І для Terraform це виглядає так, що в об’єкті за тим самим ключем змінилось значення, а тому він має видалити старий ресурс local_file.backend["3"] – і створити новий за тим самим індексом, але вже з новим змістом:

Terraform will perform the following actions:

  # local_file.backend["1"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
            cluster_name=cluster-1
          -     principal_arn=user2
          +     principal_arn=user3
        EOT
...

  # local_file.backend["2"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
          - cluster_name=cluster-1
          + cluster_name=cluster-2
          -     principal_arn=user3
          +     principal_arn=user1
        EOT
...

  # local_file.backend["3"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
            cluster_name=cluster-2
          -     principal_arn=user1
          +     principal_arn=user3
        EOT
...

І все це тому, що for_each будується на основі нестабільного індексу, який може змінюватись.

The Fix

Отже, як ми можемо цьому запобігти?

Просто змінити те, як для for_each створюються ключі.

Замість того, аби створювати ключ з індексу та значення у вигляді object, як це робиться зараз:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  ...
  }
  "5" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

Ми можемо створити унікальний ключ на кожну пару cluster+user, і виконувати ітерацію за цим ключем:

> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }
{
  "cluster-1-user1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "cluster-1-user2" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  }
  "cluster-1-user3" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "cluster-2-user1" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "cluster-2-user2" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  }
  "cluster-2-user3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

І тоді при видаленні “user2” всі інші ключі в умові для for_each не зміняться, і він не буде міняти файли:

> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }
{
  "cluster-1-user1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "cluster-1-user3" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "cluster-2-user1" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "cluster-2-user3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

В коді це буде виглядати так:

resource "local_file" "backend" {
  for_each = { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

На перший раз Terraform все одно перестворить всі ресурси, бо змінились ключі – але надалі можна буде спокійно додавати/видаляти юзерів.