Terraform Provisioners: รัน Script หลังสร้าง Resource

หลังจากสร้าง resource เสร็จ บางครั้งต้องรัน script เพิ่มเติมบน resource นั้น เช่น ติดตั้งซอฟต์แวร์บน EC2 instance, copy ไฟล์ config, หรือ run command บน server ปลายทาง — Provisioner คือเครื่องมือของ HCL ที่ใช้สำหรับงานเหล่านี้

บทความนี้จะอธิบาย provisioner ประเภทต่าง ๆ, วิธีใช้, เมื่อไหร่ควรใช้, และเมื่อไหร่ควรหลีกเลี่ยง (เพราะ HashiCorp แนะนำให้ใช้เป็นทางเลือกสุดท้ายเท่านั้น)

ประเภทของ Provisioner

  • local-exec — รันคำสั่งบนเครื่องที่รัน Terraform (local machine / CI runner)
  • remote-exec — รันคำสั่งบน resource ที่เพิ่งสร้าง (ผ่าน SSH หรือ WinRM)
  • file — copy ไฟล์หรือ directory จาก local ไป remote resource

local-exec: รันคำสั่งบนเครื่อง local

resource "aws_instance" "web" {
  ami           = "ami-0abc1234"
  instance_type = "t3.micro"

  provisioner "local-exec" {
    command = "echo ${self.private_ip} >> private_ips.txt"
  }
}

self.xxx อ้างถึง attribute ของ resource ปัจจุบัน ใช้ใน provisioner เท่านั้น — ใน argument อื่นใช้ aws_instance.web.xxx แทน

remote-exec: รันคำสั่งบน Remote Server

ต้องการ connection block กำหนดวิธีเชื่อมต่อ (SSH key, user, host)

resource "aws_instance" "web" {
  ami           = "ami-0abc1234"
  instance_type = "t3.small"
  key_name      = aws_key_pair.deploy.key_name
  subnet_id     = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/deploy_key")
    host        = self.public_ip
  }

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx",
      "sudo systemctl start nginx",
    ]
  }
}

ถ้าคำสั่งยาวมาก ให้ใช้ script ซึ่ง upload script file ขึ้นไปรันบน remote

provisioner "remote-exec" {
  script = "${path.module}/scripts/bootstrap.sh"
}

file Provisioner: Copy ไฟล์

resource "aws_instance" "web" {
  # ... ami, instance_type, connection block ...

  provisioner "file" {
    source      = "${path.module}/configs/nginx.conf"
    destination = "/tmp/nginx.conf"
  }

  provisioner "remote-exec" {
    inline = [
      "sudo mv /tmp/nginx.conf /etc/nginx/nginx.conf",
      "sudo nginx -t && sudo systemctl reload nginx",
    ]
  }
}

ลำดับการรัน provisioner เป็นไปตามที่เขียน — file block รันก่อน remote-exec ที่มา reload nginx

Creation-Time vs Destroy-Time

Default provisioner รันตอนสร้าง resource แต่สามารถตั้ง when = destroy ให้รันตอน destroy

resource "aws_instance" "web" {
  # ...

  provisioner "local-exec" {
    when    = destroy
    command = "echo 'Instance ${self.id} destroyed' >> destroy.log"
  }
}

ข้อจำกัด destroy-time provisioner: ต้องอ้าง self, count.index, หรือ each.key เท่านั้น — อ้างถึง variable หรือ resource อื่นไม่ได้ เพื่อป้องกัน dependency cycle ตอน destroy

Handling Failures

ถ้า provisioner fail default behavior: resource จะถูก mark เป็น tainted (รอ destroy+recreate ครั้งถัดไป) และ apply จะ fail ทั้ง command

provisioner "remote-exec" {
  inline = [
    "some-command-that-might-fail",
  ]
  on_failure = continue   # หรือ fail (default)
}
  • on_failure = continue — ignore error, resource ไม่ถูก taint
  • on_failure = fail — default, apply fail และ resource taint

ทำไม HashiCorp ไม่แนะนำให้ใช้ Provisioner

Provisioner ทำให้ HCL ทำงานแบบ imperative (มีลำดับขั้น, ผลลัพธ์ไม่คงที่) ขัดกับปรัชญา declarative ของ HCL ปัญหาที่เจอ:

  • State drift — HCL จำได้แค่ว่า provisioner รันสำเร็จหรือไม่ ไม่รู้ว่า script เปลี่ยนอะไรบน server — ถ้า script mutated อะไรภายหลัง state ไม่สะท้อน
  • Idempotency issue — script อาจ fail ถ้ารันซ้ำ (เช่น useradd) — ต้องเขียนให้ idempotent เอง
  • Error handling จำกัด — error ใน provisioner มักไม่ได้ context ว่าเกิดที่ขั้นไหน
  • Dependency — ต้องมี network access, SSH key, security group rule ก่อน — setup เยอะ

ทางเลือกที่ดีกว่า

  • user_data / cloud-init — ส่ง script ตอนสร้าง EC2/GCP instance ให้ OS รันเองตอน boot
  • Custom AMI / Image — สร้าง image ที่ pre-install ทุกอย่างด้วย Packer แล้ว terraform แค่เรียก image id
  • Configuration Management — ใช้ Ansible, Chef, Puppet แยกจาก HCL หลังสร้าง infrastructure เสร็จ
  • Container / Kubernetes — deploy app ผ่าน Helm/ArgoCD, HCL แค่สร้าง cluster
# ตัวอย่างใช้ user_data แทน provisioner
resource "aws_instance" "web" {
  ami           = "ami-0abc1234"
  instance_type = "t3.small"

  user_data = <<-EOF
    #!/bin/bash
    apt-get update
    apt-get install -y nginx
    systemctl start nginx
  EOF

  user_data_replace_on_change = true
}

วิธีนี้มีข้อดี: HCL ไม่ต้องเชื่อม SSH, ไม่ต้องรอ resource ready, และ cloud provider จัดการ boot script ให้

เมื่อไหร่ Provisioner ยังจำเป็น

  • ต้องการ trigger event บน resource ที่ไม่มี user_data (เช่น on-prem server, VMware VM)
  • ต้องการ post-provisioning check ที่ยืนยันว่า resource พร้อมใช้จริง (เช่น wait until port 443 ตอบ) — ใช้ remote-exec กับ script polling
  • งาน one-shot ที่ไม่ได้เกิดบน resource ปลายทาง (เช่น push notification ไป Slack ตอน deploy เสร็จ) — ใช้ local-exec

Best Practices

  • พิจารณาใช้ cloud-init, Packer image, หรือ configuration management เป็นตัวเลือกแรก
  • ถ้าจำเป็นต้องใช้ provisioner — เขียน script ให้ idempotent (รันซ้ำได้ผลเหมือนเดิม)
  • ใช้ null_resource กับ triggers สำหรับงานที่ไม่ผูกติดกับ resource เดียว (บทความถัดไปจะเจาะลึก)
  • หลีกเลี่ยง provisioner ใน module ที่ reuse หลายที่ — เพิ่ม complexity และ dependency
  • ใช้ on_failure = continue เฉพาะเมื่อ script เป็น best-effort ไม่ critical
  • Log output ของ local-exec ให้ชัดเจน (append timestamp, PID) เพื่อ debug ได้เมื่อมีปัญหา

สรุป

Provisioner เป็นเครื่องมือที่ช่วยรัน script หลังสร้าง resource ทั้ง local-exec, remote-exec และ file แต่ควรใช้เป็นทางเลือกสุดท้ายเพราะขัดกับหลัก declarative และสร้างปัญหา state drift ในงานจริงส่วนใหญ่ user_data, Packer image และ configuration management tool แยกต่างหากเป็นทางเลือกที่ดีกว่า ในบทความถัดไปจะพูดถึง null_resource และ triggers ซึ่งเป็นอีกเครื่องมือที่ใช้ร่วมกับ provisioner สำหรับงาน side-effect ที่ต้อง rerun ตามเงื่อนไข