Модуль 07 — count, for_each, источники данных

Убрали из списка первую очередь — и count пересоздал все обработчики, кроме удалённого

Прогресс курса Модуль 7 из 9

Что вы освоите в этом модуле

01

Учебный проект

Веб-сервисы описаны map в переменной, обработчики очередей — списком:

variable "env" {
  type    = string
  default = "dev"
}

variable "services" {
  type = map(object({
    port  = number
    title = string
  }))
  default = {
    price = { port = 8080, title = "Сервис цен" }
    cart  = { port = 8081, title = "Корзина" }
  }
}

variable "queues" {
  type        = list(string)
  description = "Очереди, для каждой — свой обработчик"
  default     = ["mail", "pdf", "sms"]
}
locals {
  labels = {
    env        = var.env
    managed_by = "terraform"
  }
}

# Источник данных: прочитать то, что создано не этим кодом
data "docker_network" "bridge" {
  name = "bridge"
}

resource "docker_image" "nginx" {
  name         = "nginx:1.28-alpine"
  keep_locally = true
}

resource "docker_image" "alpine" {
  name         = "alpine:3.22"
  keep_locally = true
}

# Один ресурс на каждый элемент map
resource "docker_container" "web" {
  for_each = var.services

  name  = "${each.key}-web"
  image = docker_image.nginx.image_id

  ports {
    internal = 80
    external = each.value.port
    ip       = "127.0.0.1"
  }

  upload {
    file    = "/usr/share/nginx/html/index.html"
    content = "<h1>${each.value.title}</h1>\n"
  }

  dynamic "labels" {
    for_each = merge(local.labels, { service = each.key })
    content {
      label = labels.key
      value = labels.value
    }
  }
}

# Обработчики очередей: count по списку — см. раздел о ловушке
resource "docker_container" "worker" {
  count = length(var.queues)

  name    = "worker-${var.queues[count.index]}"
  image   = docker_image.alpine.image_id
  command = ["sleep", "infinity"]
}

# Резервное копирование — только в prod
resource "docker_container" "backup" {
  count = var.env == "prod" ? 1 : 0

  name    = "backup"
  image   = docker_image.alpine.image_id
  command = ["sleep", "infinity"]
}

Здесь все приёмы модуля сразу; разберём их по очереди. В плане у ресурсов появились индексы:

$ terraform plan -no-color | grep -E "^  # |^ <= |Plan:"
  # docker_container.web["cart"] will be created
  # docker_container.web["price"] will be created
  # docker_container.worker[0] will be created
  # docker_container.worker[1] will be created
  # docker_container.worker[2] will be created
  # docker_image.alpine will be created
  # docker_image.nginx will be created
Plan: 7 to add, 0 to change, 0 to destroy.
02

for_each

for_each принимает map или множество строк (set(string)) и создаёт по экземпляру ресурса на каждый элемент. Внутри блока доступны each.key — ключ ("price") и each.value — значение (объект с port и title). Адрес экземпляра — docker_container.web["price"]:

$ terraform apply -auto-approve -no-color | sed -n "/Apply complete/,\$p"
Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

bridge_subnet = tolist([
  "172.17.0.0/16",
])
urls = {
  "cart" = "http://127.0.0.1:8081/"
  "price" = "http://127.0.0.1:8080/"
}
workers = [
  "worker-mail",
  "worker-pdf",
  "worker-sms",
]
$ terraform state list
data.docker_network.bridge
docker_container.web["cart"]
docker_container.web["price"]
docker_container.worker[0]
docker_container.worker[1]
docker_container.worker[2]
docker_image.alpine
docker_image.nginx

Список для for_each переводят в множество функцией toset(): у списка могут быть повторы и важен порядок, а ключи должны быть уникальны. Ключи for_each должны быть известны на этапе плана: for_each по идентификаторам ресурсов, которые ещё не созданы, закончится ошибкой Invalid for_each argument.

03

count и его ловушка

count = N создаёт N экземпляров с адресами [0], [1]…; номер внутри блока — count.index. У обработчиков count = length(var.queues), и имя берётся по номеру. Уберём из списка первую очередь, mail:

$ terraform plan -var 'queues=["pdf", "sms"]' -no-color | grep -E "^  # |^      [~-] name |Plan:"
  # docker_container.worker[0] must be replaced
      ~ name                                        = "worker-mail" -> "worker-pdf" # forces replacement
  # docker_container.worker[1] must be replaced
      ~ name                                        = "worker-pdf" -> "worker-sms" # forces replacement
  # docker_container.worker[2] will be destroyed
  # (because index [2] is out of range for count)
      - name                                        = "worker-sms" -> null
Plan: 2 to add, 0 to change, 3 to destroy.

Удалить нужно было один контейнер, а план пересоздаёт два и удаляет третий. Экземпляры count привязаны к номерам: после удаления первого элемента pdf переехал с индекса 1 на 0, sms — с 2 на 1, и каждому «номеру» досталось другое имя. Для контейнеров без данных это неприятно, для дисков и баз — авария.

countfor_each
Адрес экземпляраНомер: [0]Ключ: ["mail"]
Удаление из серединыСдвигает все следующиеЗатрагивает только удалённый
Когда братьN одинаковых экземпляров; «0 или 1»Экземпляры с собственными именами и параметрами
04

Переход с count на for_each без пересоздания

Меняем count на for_each = toset(var.queues). Сами по себе новые адреса означали бы «удалить [0], создать ["mail"]», поэтому рядом — блоки moved из модуля 05, по одному на экземпляр:

resource "docker_container" "worker" {
  for_each = toset(var.queues)

  name    = "worker-${each.key}"
  image   = docker_image.alpine.image_id
  command = ["sleep", "infinity"]
}

moved {
  from = docker_container.worker[0]
  to   = docker_container.worker["mail"]
}
# … и так же [1] → ["pdf"], [2] → ["sms"]
$ terraform plan -no-color | grep -E "^  # |Plan:"
  # docker_container.worker[0] has moved to docker_container.worker["mail"]
  # docker_container.worker[1] has moved to docker_container.worker["pdf"]
  # docker_container.worker[2] has moved to docker_container.worker["sms"]
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve -no-color | grep "Apply complete"
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list | grep worker
docker_container.worker["mail"]
docker_container.worker["pdf"]
docker_container.worker["sms"]

Ни одного пересоздания — адреса переписаны в состоянии. Теперь то же удаление очереди mail:

$ terraform plan -var 'queues=["pdf", "sms"]' -no-color | grep -E "^  # |Plan:"
  # docker_container.worker["mail"] will be destroyed
  # (because key ["mail"] is not in for_each map)
Plan: 0 to add, 0 to change, 1 to destroy.

Удаляется ровно один контейнер.

05

Ресурс только в одном окружении

count = var.env == "prod" ? 1 : 0 — ресурс backup есть в prod и отсутствует в dev. К такому ресурсу обращаются по индексу docker_container.backup[0], а если он может отсутствовать — через one(docker_container.backup[*].name), которая вернёт null для пустого списка. План для prod:

$ terraform plan -var env=prod -no-color | grep -E "^  # |Plan:"
  # docker_container.backup[0] will be created
  # docker_container.web["cart"] must be replaced
  # docker_container.web["price"] must be replaced
Plan: 3 to add, 0 to change, 2 to destroy.
$ echo "[for k, s in var.services : \"\${k}:\${s.port}\" if s.port > 8080]" | terraform console
[
  "cart:8081",
]

Кроме backup план пересоздаёт оба веб-контейнера: метка env сменилась с dev на prod, а метки у контейнера Docker меняются только пересозданием. Так план и полезен — показывает побочные эффекты до apply. В облаке у большинства ресурсов метки меняются на месте.

Последняя команда в журнале выше — for-выражение с условием if: из map сервисов выбраны те, чей порт больше 8080.

06

dynamic: повторяющиеся вложенные блоки

Метки контейнера задаются вложенными блоками labels { label = … value = … } — по блоку на метку. Блок dynamic генерирует их из коллекции; имя итератора — имя блока: labels.key, labels.value.

  dynamic "labels" {
    for_each = merge(local.labels, { service = each.key })
    content {
      label = labels.key
      value = labels.value
    }
  }
$ docker inspect -f "{{json .Config.Labels}}" price-web | jq -c "with_entries(select(.key | test(\"^(env|managed_by|service)$\")))"
{"env":"dev","managed_by":"terraform","service":"price"}

dynamic полезен в модулях, где число вложенных блоков зависит от входа: правила группы безопасности, порты, тома. В обычном коде несколько блоков, записанных явно, читать проще.

07

for-выражения и splat

output "urls" {
  value = { for name, c in docker_container.web : name => "http://127.0.0.1:${c.ports[0].external}/" }
}

output "workers" {
  value = [for w in docker_container.worker : w.name]
}

output "bridge_subnet" {
  value = data.docker_network.bridge.ipam_config[*].subnet
}
08

Источники данных

Блок data ничего не создаёт — он читает существующий объект: сеть, созданную не этим кодом, образ диска в облаке, зону DNS, ключ из хранилища секретов. В плане чтение отмечается <=, если значение нельзя получить заранее; иначе источник читается во время refresh и в план не попадает. Здесь data "docker_network" "bridge" читает стандартную сеть Docker, а её подсеть попадает в выход bridge_subnet (вывод apply выше).

В облаке источники данных — главный способ связать проекты без копирования идентификаторов. Пример для Yandex Cloud: взять актуальный образ из семейства вместо записанного вручную ID:

data "yandex_compute_image" "debian" {
  family = "debian-12"
}

# … boot_disk { initialize_params { image_id = data.yandex_compute_image.debian.id } }

Такой образ сменится, как только Яндекс выпустит новый, — и план предложит пересоздать машины. Для прода ID образа обычно фиксируют переменной, а источник данных используют на стендах.

depends_on = [АДРЕС] — явная зависимость, когда ссылки нет, а порядок важен: например, приложение должно стартовать после того, как выдана роль сервисному аккаунту, хотя ни один атрибут роли приложению не нужен. Если есть ссылка — depends_on не нужен.

$ terraform destroy -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 7 destroyed.
09

create_before_destroy с уникальными именами

В модуле 03 create_before_destroy упёрся в занятое имя контейнера. Решение — суффикс из провайдера random. random_id генерируется один раз и хранится в состоянии; keepers — значения, при смене которых он генерируется заново. В keepers кладут то, что вызывает пересоздание контейнера, — здесь порт:

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

variable "port" {
  type    = number
  default = 8080
}

resource "docker_image" "nginx" {
  name         = "nginx:1.28-alpine"
  keep_locally = true
}

# Новый суффикс — только когда меняется то, что пересоздаёт контейнер
resource "random_id" "web" {
  byte_length = 2
  keepers = {
    port = var.port
  }
}

resource "docker_container" "web" {
  name  = "price-web-${random_id.web.hex}"
  image = docker_image.nginx.image_id

  ports {
    internal = 80
    external = var.port
    ip       = "127.0.0.1"
  }

  lifecycle {
    create_before_destroy = true
  }
}
$ terraform init >/dev/null && terraform apply -auto-approve -no-color | grep -E "Creation complete|Apply complete"
random_id.web: Creation complete after 0s [id=H84]
docker_image.nginx: Creation complete after 0s [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_container.web: Creation complete after 0s [id=267ffdac573f2bb155d6f89f77afa09c0113dd82adf1b6a69ae0b44b776e7a47]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ docker ps --format "{{.Names}}  {{.Ports}}"
price-web-1fce  127.0.0.1:8080->80/tcp
$ terraform apply -var port=8082 -auto-approve -no-color | grep -E "replacement|Creat|Destr|Apply complete"
+/- create replacement and then destroy
      ~ name                                        = "price-web-1fce" -> (known after apply) # forces replacement
          ~ external = 8080 -> 8082 # forces replacement
      ~ keepers     = { # forces replacement
random_id.web: Creating...
random_id.web: Creation complete after 0s [id=DkM]
docker_container.web: Creating...
docker_container.web: Creation complete after 0s [id=1ab383a8af96bafad5e6aef325b4f5f1726ac7e28404ab236e9f0533960e4a92]
docker_container.web (deposed object 4b5e4813): Destroying... [id=267ffdac573f2bb155d6f89f77afa09c0113dd82adf1b6a69ae0b44b776e7a47]
docker_container.web: Destruction complete after 1s
random_id.web (deposed object 7be80ee5): Destroying... [id=H84]
random_id.web: Destruction complete after 0s
Apply complete! Resources: 2 added, 0 changed, 2 destroyed.
$ docker ps --format "{{.Names}}  {{.Ports}}"
price-web-0e43  127.0.0.1:8082->80/tcp
$ terraform destroy -var port=8082 -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 3 destroyed.

+/-: новый контейнер с новым суффиксом создан, пока старый работал, и только потом старый удалён (deposed object — так Terraform называет экземпляр, который уже заменён и ждёт удаления). Суффикс случайный — у вас он будет другим.

Ключевые выводы модуля

Запомните главное

  • for_each по map или set: адреса по ключам, each.key и each.value
  • count по списку сдвигает индексы при удалении из середины — для именованных экземпляров берут for_each
  • Переход count → for_each — блоками moved, без пересоздания
  • Ресурс «только в prod» — count = условие ? 1 : 0, обращение — [0] или one(…[*].attr)
  • dynamic — для вложенных блоков, число которых зависит от входа
  • for-выражения собирают списки и map, [*] — атрибут всех элементов
  • data читает существующее, depends_on — только когда нет ссылки
  • random_id с keepers даёт уникальные имена для create_before_destroy
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 06: Модули Все модули Модуль 08: Terraform в команде