หลังจากสร้าง 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 ไม่ถูก tainton_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 ตามเงื่อนไข

