★★ 中級

07. 反復構築(count / for_each / dynamic)

「同じものを 3 つ作る」「環境ごとに違う名前で複数作る」「内部のブロックを集合から動的に生成する」。これらに対応する 3 つのメタ引数があります。役割を間違えると、リファクタ時に大量の作り直しが起きるので慎重に。

どれを使うか早見表

やりたいこと使うもの
同じ設定で N 個count
名前付きの集合(map / set)から複数for_each
resource ブロック内の 入れ子ブロック を集合から生成dynamic
迷ったら for_each 実務では for_each を選ぶ機会が圧倒的に多いです。count は「途中の 1 個を消すと残り全部が再作成される」ため、削除や順序変更に弱い。

count

resource "aws_instance" "worker" {
  count = 3

  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"

  tags = {
    Name = "worker-${count.index}"   # worker-0, worker-1, worker-2
  }
}

# 参照は配列っぽく
output "first_worker_id" {
  value = aws_instance.worker[0].id
}

output "all_worker_ids" {
  value = aws_instance.worker[*].id   # splat 式
}

count = 0 で「条件付き作成」

resource "aws_cloudtrail" "audit" {
  count = var.enable_audit ? 1 : 0
  # ...
}

# 参照は要注意(count=0 のとき [0] は無効)
output "audit_arn" {
  value = var.enable_audit ? aws_cloudtrail.audit[0].arn : null
}

for_each

for_eachmap または set(string) を取ります。各インスタンスは each.key / each.value でアクセス。

map から(属性違いで複数)

variable "instances" {
  type = map(object({
    ami_id = string
    type   = string
  }))
  default = {
    api    = { ami_id = "ami-aaa", type = "t3.small" }
    worker = { ami_id = "ami-bbb", type = "t3.micro" }
    cron   = { ami_id = "ami-aaa", type = "t3.nano"  }
  }
}

resource "aws_instance" "app" {
  for_each = var.instances

  ami           = each.value.ami_id
  instance_type = each.value.type

  tags = {
    Name = each.key   # "api", "worker", "cron"
  }
}

# 参照は map のキーで
output "api_id" {
  value = aws_instance.app["api"].id
}

set(string) から(同設定の名前付き複製)

resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.key   # set の場合 each.value も同じ
}

count vs for_each ─ 何が決定的に違うのか

count添字(0, 1, 2...)で state を管理します。途中の worker[1] を消すと、もともと worker[2] だったやつが worker[1] に繰り上がる = 同じ実体に違う添字が割り当たる。Terraform は「これは違うリソースだ」と判定して、無関係な再作成が発生します。

for_eachキー("api"、"worker" など)で state を管理します。"worker" を消しても "api" や "cron" には影響しません。順序ではなく名前で識別 しているからです。

dynamic ブロック

resource の中に書く 入れ子ブロック を、集合から動的に生成する仕組み。たとえば SG の ingress ブロックを変数の数だけ作る:

variable "ingress_rules" {
  type = list(object({
    description = string
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = [
    { description = "HTTP",  from_port = 80,  to_port = 80,  protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
    { description = "HTTPS", from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
  ]
}

resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      description = ingress.value.description
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

イテレータ名は ブロック名と同じ(上の例では ingress)。明示的に変えたい時は iterator 引数で。

dynamic "ingress" {
  for_each = var.ingress_rules
  iterator = rule
  content {
    from_port = rule.value.from_port
    # ...
  }
}
使いすぎ注意 公式が「dynamic は便利だが、入れ子の dynamic を多用すると 読めなくなる」と警告しています。引数の数が決まっているなら素直に並べるほうが、レビューもデバッグも楽です。

count から for_each への移行

あとから countfor_each に変えると、Terraform は「foo[0] を消して foo["api"] を作る」と判定し、全リソースが再作成される。これを避けるのが moved ブロック:

moved {
  from = aws_instance.app[0]
  to   = aws_instance.app["api"]
}

moved {
  from = aws_instance.app[1]
  to   = aws_instance.app["worker"]
}

これだけで、destroy/create なしで state のアドレスだけ書き換わります。リファクタ時にめちゃくちゃ便利。