Terraform Dynamic Block: สร้าง Nested Block แบบ Programmatic

Resource บางตัวใน HCL มี nested block ที่ซ้ำ ๆ กันหลายชุด เช่น ingress rule ใน security group, statement ใน IAM policy, หรือ environment variables ใน Lambda function การเขียน nested block เดิมซ้ำหลายครั้งทำให้ config ยาวและแก้ไขยาก

Dynamic block ช่วยแก้ปัญหานี้โดยให้สร้าง nested block แบบ programmatic จาก list หรือ map ข้อมูล บทความนี้จะอธิบายวิธีใช้งาน dynamic block พร้อมตัวอย่างที่เจอบ่อย และข้อควรระวัง

โครงสร้างพื้นฐาน

Dynamic block มีโครงสร้าง 3 ส่วน: ชื่อ block ที่ต้องการสร้าง, for_each กำหนดข้อมูลที่วนซ้ำ, และ content คือ template ของ block แต่ละตัว

resource "aws_example" "name" {
  # ... normal arguments ...

  dynamic "block_name" {
    for_each = var.items
    content {
      attribute1 = block_name.value.field1
      attribute2 = block_name.value.field2
    }
  }
}

ภายใน content ใช้ block_name.value เข้าถึงค่าแต่ละ item และ block_name.key เข้าถึง key (ถ้า for_each เป็น map)

ตัวอย่าง 1: Security Group Ingress Rules

Security group ที่มี ingress rule หลายข้อเป็น use case ยอดนิยม เขียนแบบปกติต้อง copy-paste block ซ้ำ

# แบบเดิม — ingress rule 3 ข้อ ต้องเขียน 3 block
resource "aws_security_group" "web" {
  name = "web-sg"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
  }
}
# แบบ dynamic block — ตัว rule อยู่ใน variable
variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
  }))
  default = [
    { port = 80,  protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTP" },
    { port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" },
    { port = 22,  protocol = "tcp", cidr_blocks = ["10.0.0.0/8"], description = "SSH admin" },
  ]
}

resource "aws_security_group" "web" {
  name = "web-sg"

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

เพิ่ม/ลด rule เพียงแก้ที่ variable เดียว ไม่ต้องแตะ resource block

ตัวอย่าง 2: IAM Policy Statements

IAM policy มี statement block หลายข้อ ซึ่งสามารถประกอบจาก list ของ permission ได้

variable "policy_statements" {
  default = [
    {
      sid     = "AllowS3Read"
      actions = ["s3:GetObject", "s3:ListBucket"]
      resources = [
        "arn:aws:s3:::mybucket",
        "arn:aws:s3:::mybucket/*",
      ]
    },
    {
      sid     = "AllowCloudWatchLogs"
      actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
      resources = ["arn:aws:logs:*:*:*"]
    },
  ]
}

data "aws_iam_policy_document" "app" {
  dynamic "statement" {
    for_each = var.policy_statements
    content {
      sid       = statement.value.sid
      actions   = statement.value.actions
      resources = statement.value.resources
      effect    = "Allow"
    }
  }
}

resource "aws_iam_policy" "app" {
  name   = "app-policy"
  policy = data.aws_iam_policy_document.app.json
}

ตัวอย่าง 3: Lambda Environment Variables

Lambda รับ environment variables เป็น block ที่มี variables map สามารถ generate จาก locals ตาม environment ได้

locals {
  common_env = {
    LOG_LEVEL    = "info"
    AWS_REGION   = "ap-southeast-1"
    ENVIRONMENT  = var.environment
  }
  env_specific = var.environment == "prod" ? {
    DB_POOL_SIZE = "20"
    CACHE_TTL    = "3600"
  } : {
    DB_POOL_SIZE = "5"
    CACHE_TTL    = "60"
  }
  all_env = merge(local.common_env, local.env_specific)
}

resource "aws_lambda_function" "api" {
  function_name = "api-handler"
  # ...

  dynamic "environment" {
    for_each = length(local.all_env) > 0 ? [1] : []
    content {
      variables = local.all_env
    }
  }
}

สังเกต trick: Lambda environment block ยอมให้มีแค่ 0 หรือ 1 ชุด ใช้ for_each กับ list [1] เมื่อต้องการสร้าง block, หรือ list ว่าง [] เมื่อไม่ต้องการ — เป็น idiom สำหรับ “conditional block”

Conditional Dynamic Block

สร้าง nested block เฉพาะเมื่อตรงเงื่อนไข ใช้ for_each กับ list ว่างหรือ list หนึ่ง item

resource "aws_s3_bucket" "example" {
  bucket = "my-logs-bucket"

  # สร้าง versioning block เฉพาะ prod
  dynamic "versioning" {
    for_each = var.environment == "prod" ? [1] : []
    content {
      enabled = true
    }
  }
}

Nested Dynamic Block

บาง resource มี block ซ้อน block เช่น CloudFront distribution ที่มี ordered_cache_behavior ซึ่งภายในมี forwarded_values สามารถซ้อน dynamic ได้ตามต้องการ

resource "aws_cloudfront_distribution" "main" {
  # ...

  dynamic "ordered_cache_behavior" {
    for_each = var.cache_behaviors
    content {
      path_pattern     = ordered_cache_behavior.value.path
      target_origin_id = ordered_cache_behavior.value.origin

      dynamic "forwarded_values" {
        for_each = ordered_cache_behavior.value.forward_config != null ? [1] : []
        content {
          query_string = ordered_cache_behavior.value.forward_config.query_string
          headers      = ordered_cache_behavior.value.forward_config.headers
          cookies {
            forward = "none"
          }
        }
      }
    }
  }
}

ข้อควรระวัง

  • อย่าใช้ dynamic block เกินความจำเป็น — ถ้ามี block คงที่ 1-2 ตัว เขียนตรง ๆ อ่านง่ายกว่า
  • Error message อ่านยาก — เมื่อเกิดข้อผิดพลาดใน dynamic block, error จะชี้ที่ resource ไม่ใช่ item ที่ผิด ต้องตรวจข้อมูลใน list เอง
  • Type ต้องตรงกัน — ทุก item ใน for_each ต้องมี structure เดียวกัน ไม่งั้น HCL จะ fail ตั้งแต่ plan
  • Nested dynamic ซับซ้อนเร็วมาก — ถ้าต้อง nest 3 ชั้นขึ้นไป ให้พิจารณา refactor เป็น module หรือใช้ locals ช่วย flatten ข้อมูล

Flatten ข้อมูลก่อนใส่ dynamic block

บางครั้งโครงสร้างข้อมูลต้นทางไม่เหมาะกับ for_each โดยตรง เช่น map ซ้อน map — ต้อง flatten ด้วย for expression ก่อน

locals {
  # ต้นทาง: map ของ env แต่ละตัวมี rule หลายข้อ
  env_rules = {
    prod = [
      { port = 443, cidr = ["0.0.0.0/0"] },
      { port = 22,  cidr = ["10.0.0.0/8"] },
    ]
    dev = [
      { port = 80, cidr = ["0.0.0.0/0"] },
    ]
  }

  # Flatten เป็น list ของ { env, port, cidr }
  flat_rules = flatten([
    for env, rules in local.env_rules : [
      for r in rules : {
        env  = env
        port = r.port
        cidr = r.cidr
      }
    ]
  ])
}

resource "aws_security_group_rule" "all" {
  count             = length(local.flat_rules)
  type              = "ingress"
  from_port         = local.flat_rules[count.index].port
  to_port           = local.flat_rules[count.index].port
  protocol          = "tcp"
  cidr_blocks       = local.flat_rules[count.index].cidr
  security_group_id = aws_security_group.web[local.flat_rules[count.index].env].id
}

Best Practices

  • ใช้ dynamic block เมื่อ nested block มีจำนวนไม่แน่นอน หรือ depends on variable
  • ตั้งชื่อ iterator ให้สื่อความหมาย (default ใช้ชื่อ block เอง ซึ่งพอใช้ได้) — iterator = rule ช่วยให้ rule.value อ่านง่ายขึ้น
  • ประกาศ variable ด้วย type ชัดเจน — ช่วยให้ error ตอน validate ก่อน apply
  • ถ้า dynamic block เริ่มซับซ้อน (nested หลายชั้น, conditional หลายตัว) — แยกเป็น module ช่วยให้ reuse ได้
  • ทดสอบด้วย terraform plan ดู diff ก่อน apply — dynamic block มักสร้าง resource หลายชิ้นพร้อมกัน

สรุป

Dynamic block เป็นเครื่องมือสำคัญในการสร้าง nested block แบบ data-driven ลดการเขียน code ซ้ำและทำให้ config ยืดหยุ่นต่อการเปลี่ยนแปลง เหมาะอย่างยิ่งสำหรับ security group rules, IAM policy statements, และ optional block ที่ต้องการสร้างตามเงื่อนไข แต่ควรใช้เมื่อจำเป็นและหลีกเลี่ยงการซ้อนหลายชั้นซึ่งทำให้ config อ่านยาก ในบทความถัดไปจะพูดถึง provisioner ซึ่งเป็นเครื่องมือสำหรับรัน script หลังสร้าง resource