Убрали из списка первую очередь — и count пересоздал все обработчики, кроме удалённого
count и for_eachdynamicforВеб-сервисы описаны 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.
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.
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, и каждому «номеру» досталось другое имя. Для контейнеров без данных это неприятно, для дисков и баз — авария.
count | for_each | |
|---|---|---|
| Адрес экземпляра | Номер: [0] | Ключ: ["mail"] |
| Удаление из середины | Сдвигает все следующие | Затрагивает только удалённый |
| Когда брать | N одинаковых экземпляров; «0 или 1» | Экземпляры с собственными именами и параметрами |
Меняем 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.
Удаляется ровно один контейнер.
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.
Метки контейнера задаются вложенными блоками 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 полезен в модулях, где число вложенных блоков зависит от входа: правила группы безопасности, порты, тома. В обычном коде несколько блоков, записанных явно, читать проще.
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
}
{ for name, c in docker_container.web : name => … } — собрать map; [for w in … : w.name] — список. Добавляется фильтр if условие.ресурс[*].атрибут — splat: атрибут каждого элемента списка. С count работает напрямую, для for_each (это map) нужен values(…)[*] или for.Блок 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.
В модуле 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.valuecount по списку сдвигает индексы при удалении из середины — для именованных экземпляров берут for_eachmoved, без пересозданияcount = условие ? 1 : 0, обращение — [0] или one(…[*].attr)dynamic — для вложенных блоков, число которых зависит от входаfor-выражения собирают списки и map, [*] — атрибут всех элементовdata читает существующее, depends_on — только когда нет ссылкиrandom_id с keepers даёт уникальные имена для create_before_destroy