Terraform null_resource + triggers: ควบคุมการรัน Script ใหม่

null_resource เป็น resource พิเศษใน HCL ที่ไม่สร้างอะไรจริง ๆ ในโลกภายนอก แต่เป็น placeholder สำหรับ attach provisioner หรือเก็บ state เพื่อ trigger การรัน script ใหม่ตามเงื่อนไข ใช้บ่อยในการ orchestrate งานที่ไม่ผูกติดกับ resource เดียว

บทความนี้อธิบายการใช้ null_resource ร่วมกับ triggers เพื่อควบคุมเมื่อไหร่ที่ provisioner ต้องรันใหม่ พร้อมตัวอย่าง use case ที่เจอบ่อย

null_resource คืออะไร

null_resource มาจาก null provider (built-in ของ HCL) ไม่สร้าง cloud resource ใด ๆ แต่มีอายุการใช้งานใน state เหมือน resource ทั่วไป — create, update (destroy+recreate), และ destroy

terraform {
  required_providers {
    null = {
      source  = "hashicorp/null"
      version = "~> 3.2"
    }
  }
}

resource "null_resource" "example" {
  # ไม่ต้องมี argument ก็ได้
}

Triggers: ตัวกำหนดการ Replace

Argument triggers รับ map ของค่า เมื่อค่าใดค่าหนึ่งเปลี่ยน HCL จะ destroy+recreate null_resource — ซึ่งทำให้ provisioner ที่ attach รันใหม่

resource "null_resource" "db_migration" {
  triggers = {
    schema_hash = filemd5("${path.module}/schema.sql")
  }

  provisioner "local-exec" {
    command = "psql -f ${path.module}/schema.sql"
  }
}

ทุกครั้งที่แก้ไข schema.sql, filemd5 ส่งค่าใหม่ → trigger เปลี่ยน → terraform replace null_resource → provisioner รันใหม่

Use Case 1: Run Script หลัง Resource พร้อม

ตัวอย่าง: รัน database migration หลัง RDS พร้อม, หรือ seed data หลังสร้าง S3 bucket

resource "aws_db_instance" "app" {
  # ... DB config ...
}

resource "null_resource" "migrate" {
  depends_on = [aws_db_instance.app]

  triggers = {
    db_endpoint = aws_db_instance.app.endpoint
    migrations  = filemd5("${path.module}/migrations.sql")
  }

  provisioner "local-exec" {
    command = <<-EOT
      psql "host=${aws_db_instance.app.address} \
            user=${aws_db_instance.app.username} \
            dbname=${aws_db_instance.app.db_name}" \
        -f ${path.module}/migrations.sql
    EOT
  }
}

Use Case 2: Deploy Notification

resource "null_resource" "notify" {
  triggers = {
    app_version = var.app_version
  }

  provisioner "local-exec" {
    command = <<-EOT
      curl -X POST -H 'Content-Type: application/json' \
        -d '{"text":"Deployed ${var.app_version} to ${var.environment}"}' \
        https://hooks.slack.com/services/XXX/YYY/ZZZ
    EOT
  }
}

รัน Slack notification เฉพาะเมื่อ app_version เปลี่ยน ไม่รันซ้ำทุกครั้งที่ terraform apply

Use Case 3: Wait for Readiness

ตัวอย่างรอ service ready ก่อน resource ถัดไปใช้งาน — เช่น Kubernetes cluster รอ DNS propagate

resource "null_resource" "wait_for_dns" {
  depends_on = [aws_route53_record.api]

  triggers = {
    dns_name = aws_route53_record.api.name
  }

  provisioner "local-exec" {
    command = <<-EOT
      until dig +short ${aws_route53_record.api.name} | grep -q .; do
        echo "Waiting for DNS..."
        sleep 10
      done
    EOT
  }
}

resource "helm_release" "app" {
  depends_on = [null_resource.wait_for_dns]
  # ...
}

Trigger Strategies

  • File hashfilemd5(path) — trigger เมื่อ file เปลี่ยน
  • Resource attribute — เช่น aws_instance.web.id — trigger เมื่อ resource ถูก recreate
  • Version stringvar.app_version — trigger เมื่อ input เปลี่ยน
  • Timestamptimestamp() — trigger ทุกครั้งที่ apply (ระวัง overuse)
  • Combination — หลายค่ารวมกัน ช่วยให้ครอบคลุม condition ต่าง ๆ

ข้อควรระวัง

  • ไม่ใช่ magic bullet — null_resource ไม่ track อะไรบน remote (เหมือน provisioner ทั่วไป) ถ้า script fail กลางทาง ต้อง taint resource แล้ว apply ใหม่เอง
  • timestamp() ทำให้ apply ทุกครั้ง trigger — อย่าใช้ใน trigger เว้นต้องการจริง
  • Destroy lifecycle — ถ้า null_resource ที่รัน destroy provisioner มีปัญหา อาจ block terraform destroy — ทำให้ต้อง remove จาก state manually
  • Debug ยาก — ไม่มี rich error context จาก provider — TF_LOG=DEBUG ช่วยได้บ้าง

Alternative: terraform_data (v1.4+)

ตั้งแต่ HCL v1.4 มี terraform_data resource built-in (ไม่ต้อง declare null provider) ใช้แทน null_resource ได้โดยตรง — เขียนสั้นกว่า

resource "terraform_data" "migration" {
  input = filemd5("${path.module}/schema.sql")

  provisioner "local-exec" {
    command = "psql -f ${path.module}/schema.sql"
  }
}

# อ้างถึงค่าเดิมด้วย terraform_data.migration.output
# ค่า input ทำงานคล้าย trigger: เปลี่ยนค่า → replace resource

Best Practices

  • ใช้ null_resource/terraform_data เฉพาะเมื่อไม่มีทางเลือกอื่น (user_data, Packer, ansible แยก)
  • กำหนด triggers ให้ครอบคลุม input ทุกตัวที่ทำให้ script ต้องรันใหม่
  • เขียน script ให้ idempotent เสมอ (ถ้า rerun ไม่ทำลายของเดิม)
  • ใช้ depends_on ชัดเจนเพื่อบอก HCL ว่าต้อง wait ให้ dependency พร้อมก่อน
  • ถ้าใช้ HCL v1.4+ ให้ใช้ terraform_data แทน — บำรุงรักษาง่ายกว่า (ไม่ต้อง config null provider)
  • Log ข้อความให้ชัดเจนจาก script — ช่วย debug เมื่อ pipeline ติดปัญหา

สรุป

null_resource และ terraform_data เป็นเครื่องมือที่ช่วย orchestrate script หรือ side-effect ภายใน terraform apply โดยใช้ triggers ควบคุมเมื่อไหร่ที่ต้องรันใหม่ เหมาะกับงานอย่าง database migration, deploy notification, หรือ wait for readiness แต่ควรใช้เท่าที่จำเป็นและพึ่งเครื่องมือ configuration management ที่เหมาะสมกว่าเมื่อเนื้องานมีความซับซ้อน ในบทความถัดไปจะพูดถึง best practices ในการจัดระเบียบโครงสร้าง project HCL ให้ maintain ได้ในระยะยาว