ข้ามไปยังเนื้อหา

Expressions, Functions, and Conditionals

คอนฟิก Terraform ไม่ใช่แค่ค่าคงที่ — built-in function แปลงข้อมูล, conditional expression เลือกระหว่างสองค่า และ dynamic block สร้าง nested block จำนวนไม่คงที่ ทั้งหมดนี้ถูกประเมินตอน plan ก่อนที่ GCP API call ใด ๆ จะเกิดขึ้น

Terraform มีชุด built-in function ตายตัวให้มาแล้ว — ไม่มีทางนิยามของตัวเองได้ — จัดกลุ่มคร่าว ๆ ตามชนิดของข้อมูลที่มันทำงานด้วย มีไม่กี่ตัวที่เจอบ่อยมากในคอนฟิก GCP:

locals {
# String functions
bucket_suffix = join("-", ["assets", "prod", "us"])
path_parts = split("/", "projects/my-project/zones/us-central1-a")
log_message = format("instance %s in zone %s", "web-1", "us-central1-a")
# Collection functions
tier_lookup = lookup({ dev = "db-f1-micro", prod = "db-custom-2-7680" }, "dev", "db-f1-micro")
merged_labels = merge(
{ managed_by = "terraform" },
{ environment = "prod" },
)
has_ssh = contains([22, 443, 8080], 22)
# Networking function: carve a /24 out of a /16 VPC range
subnet_a = cidrsubnet("10.0.0.0/16", 8, 0)
subnet_b = cidrsubnet("10.0.0.0/16", 8, 1)
}

join และ split เป็นด้านตรงข้ามกัน — ตัวหนึ่งแปลง list เป็น string ที่คั่นด้วยตัวคั่น อีกตัวแปลง string ที่คั่นกลับเป็น list lookup อ่าน map ด้วย key พร้อม fallback ชัดเจนเป็น argument ที่สาม ดังนั้น key ที่หายไปไม่ทำให้ error เลย merge รวมหลาย map เป็นอันเดียว โดย argument ที่มาทีหลังชนะ key ที่ซ้ำจากก่อนหน้า contains เช็คว่าอยู่ใน list ไหมแล้วคืน boolean ธรรมดา cidrsubnet(prefix, newbits, netnum) คือตัวที่ควรรู้ให้ขึ้นใจสำหรับ network ของ GCP: มันรับ CIDR block ฐาน ขยาย prefix length ด้วย newbits แล้วคืน subnet ลำดับที่ netnum ของขนาดใหม่นั้น — เครื่องมือที่เหมาะเป๊ะสำหรับแบ่ง range 10.0.0.0/16 ของ VPC network เป็นชุด subnet /24 โดยไม่ต้องคำนวณ address เอง

ก่อนใส่ function หรือ expression ที่ไม่คุ้นเคยลงใน resource argument จริง terraform console เปิด REPL แบบ interactive ที่ประเมิน expression กับคอนฟิกและ state ปัจจุบัน โดยไม่ plan หรือ apply อะไรเลย

Terminal window
terraform console
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> join("-", ["assets", "prod", "us"])
"assets-prod-us"
> var.environment
"dev"

นี่คือวิธีที่เร็วที่สุดในการตอบคำถาม “expression นี้ประเมินเป็นอะไรจริง ๆ” — พิมพ์เข้าไป อ่านผลลัพธ์ แล้วค่อย paste expression ที่ทำงานได้ลงในไฟล์ .tf มันอ่านอย่างเดียวกับ infrastructure จริง: terraform console อ่าน variable, local และ state ที่มีอยู่ได้ แต่สร้าง เปลี่ยน หรือทำลายอะไรไม่ได้เลย

บาง expression ใช้ได้แค่บางเวลา — object attribute ที่เป็นตัวเลือกซึ่งอาจถูกตั้งหรือไม่ก็ได้ try(expr1, expr2, ...) ประเมินแต่ละ argument ตามลำดับแล้วคืนตัวแรกที่ไม่ error; can(expr) ประเมิน expression เดียวแล้วคืน true/false ว่ามันสำเร็จไหม ทิ้งค่าจริงไป ทั้งสองมีไว้เพื่อให้การอ้างอิงที่ผิดพลาดหนึ่งจุดไม่ทำให้ทั้ง plan ล้มเหลว

variable "config" {
type = object({
name = string
})
default = {
name = "app"
}
}

ผลลัพธ์จริงจาก terraform console — var.config ข้างบนไม่มี attribute zone เลย:

> try(var.config.zone, "us-central1-a")
"us-central1-a"
> try(var.config.zone)
╷
│ Error: Error in function call
│
│ Call to function "try" failed: no expression succeeded:
│ - Unsupported attribute
│ This object does not have an attribute named "zone".
│
│ At least one expression must produce a successful result.
╵
> can(var.config.zone)
false
> can(var.config.name)
true

try() ที่มี fallback จะคืน fallback ทันทีที่ expression แรก error โดยไม่มี error โผล่ให้เห็นเลย try() ที่ไม่มีตัวไหนสำเร็จเลยจะโยน error จริงออกมา — ไม่ใช่ null แบบเงียบ ๆ can() ไม่เคยคืนค่าจริงเลย คืนแค่ว่าการประเมินนั้นจะสำเร็จไหม — มีประโยชน์ภายใน validation condition เพราะ condition ต้องประเมินเป็น boolean ธรรมดาเสมอ ห้าม raise error

templatefile(path, vars) อ่านไฟล์จาก disk, render เป็น Terraform template แล้วคืนผลลัพธ์เป็น string — วิธีมาตรฐานในการเก็บข้อความ template (startup script, nginx config) ให้อยู่นอกไฟล์ HCL ไปเลย

config.tftpl
server {
name = "${name}"
port = ${port}
%{ for tag in tags ~}
tag "${tag}"
%{ endfor ~}
}
locals {
rendered_config = templatefile("${path.module}/config.tftpl", {
name = "web"
port = 8080
tags = ["prod", "us-central1"]
})
}

ผลลัพธ์จริงจาก terraform console กับ template เดียวกันเป๊ะข้างบน:

> templatefile("${path.module}/config.tftpl", { name = "web", port = 8080, tags = ["prod", "us-central1"] })
<<EOT
server {
name = "web"
port = 8080
tag "prod"
tag "us-central1"
}
EOT

คู่ %{ for tag in tags ~} / %{ endfor ~} เป็น template directive ไม่ใช่ HCL — มันรันหนึ่งครั้งต่อ element ของ tags และ ~ ท้ายบรรทัดจะลบ newline ที่ปกติจะโผล่รอบบรรทัด directive เอง ทำให้ผลลัพธ์ที่ render แล้วไม่มีบรรทัดว่างแปลก ๆ ต้องส่งค่าให้ครบทุก variable ที่ template อ้างอิง — ถ้าขาดไปหนึ่งตัวคือ error จริงตอน plan ไม่ใช่ string ว่างเปล่าแบบเงียบ ๆ templatefile คือวิธีมาตรฐานในการสร้าง metadata_startup_script ของ google_compute_instance จากไฟล์จริงแทน string ที่เขียนใน HCL ตรง ๆ

CHANGELOG ของ Terraform 1.8 ระบุชัด: “Providers can now offer functions which can be used from within the Terraform configuration language” เรียกด้วย syntax provider::provider_name::function_name(...) ต่างจาก standard library ที่เป็น built-in ข้างบน function พวกนี้ถูก implement โดย provider เฉพาะ มาพร้อม provider นั้น และมีอยู่เพราะข้อมูลของ provider นั้นเอง (GCP self-link หรือ resource ID) ต้องการ logic การ parse ที่ function library ทั่วไปไม่ควรรู้

provider hashicorp/google (ยืนยันแล้วจากการ list google/functions/ ใน source ของ hashicorp/terraform-provider-google) ตอนนี้มีเจ็ดตัว: name_from_id, region_from_zone, region_from_id, zone_from_id, project_from_id, location_from_id และ element_from_id

locals {
instance_zone = provider::google::zone_from_id(google_compute_instance.web.id)
}

ผลลัพธ์จริงจาก terraform console กับรูปแบบ GCP resource id/self-link จริง:

> provider::google::name_from_id("projects/my-project/zones/us-central1-c/instances/my-instance")
"my-instance"
> provider::google::region_from_zone("us-central1-c")
"us-central1"
> provider::google::project_from_id("projects/my-project/zones/us-central1-c/instances/my-instance")
"my-project"

name_from_id ดึงเฉพาะ path segment สุดท้าย (ชื่อสั้นของ resource) จากรูปแบบ id/URI/self-link ใด ๆ ที่ GCP resource คืนกลับมา — projects/my-project/zones/us-central1-c/instances/my-instance, self-link เต็มแบบ https://www.googleapis.com/compute/v1/... หรือชื่อบางส่วนแบบ //service.googleapis.com/... ใช้ได้หมด ตามคำอธิบาย parameter ที่ function เอกสารไว้เอง region_from_zone แปลง zone อย่าง us-central1-c เป็น region us-central1 โดยไม่ต้องเขียน regexall/join เองกับ string ของ zone ซึ่งเป็นการผ่าตัด string ที่ function library ทั่วไปไม่ควรต้อง special-case ให้ naming convention ของ provider ตัวเดียว

Conditional expression มีรูปแบบ condition ? true_val : false_val และประเมินเป็นหนึ่งในสองสาขาตามว่า condition เป็น true หรือ false

variable "environment" {
type = string
description = "Deployment environment name"
default = "dev"
}
locals {
machine_type = var.environment == "prod" ? "e2-standard-4" : "e2-medium"
}
resource "google_compute_instance" "web" {
name = "web-server"
machine_type = local.machine_type
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
access_config {}
}
}

สามส่วนคือ condition (expression ที่ต้องประเมินเป็น boolean), true_val (ใช้เมื่อ condition เป็น true) และ false_val (ใช้เมื่อ condition เป็น false) ตรงนี้ local.machine_type resolve เป็น "e2-standard-4" เฉพาะเมื่อ var.environment เป็น "prod" เป๊ะ ๆ และตกไปใช้ "e2-medium" สำหรับค่าอื่นทั้งหมด รวมถึง "dev" และ "staging"

count หรือ for_each บน resource สร้าง resource ทั้งตัวจำนวนไม่คงที่ จะพูดถึงในบทถัดไป dynamic block แก้ปัญหาที่เกี่ยวข้องกันแต่ต่างออกไป: สร้าง nested block ที่ซ้ำกันจำนวนไม่คงที่ภายใน resource เดียว

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the firewall rule"
default = [22, 80, 443]
}
resource "google_compute_firewall" "allow_multiple" {
name = "allow-multiple-ports"
network = "default"
dynamic "allow" {
for_each = var.allowed_ports
content {
protocol = "tcp"
ports = [allow.value]
}
}
}

dynamic "allow" block วนซ้ำ var.allowed_ports และสำหรับแต่ละ element สร้าง nested block allow { ... } หนึ่งอันภายใน google_compute_firewall.allow_multiple resource เดียว ภายใน content, allow.value อ้างอิง element ปัจจุบัน — iterator variable default เป็น label ของ dynamic block เอง คือ allow ใช้ dynamic เมื่อสิ่งที่ซ้ำคือ nested block ภายใน resource เดียว; ใช้ count หรือ for_each บนตัว resource เองเมื่อสิ่งที่ซ้ำคือ resource ทั้งตัว

flowchart LR
  a["var.environment"] --> c{"environment == prod ?"}
  c -->|true| t["e2-standard-4"]
  c -->|false| f["e2-medium"]
  t --> m["local.machine_type"]
  f --> m
  m --> r["google_compute_instance.web.machine_type"]
conditional expression และการเรียก function resolve เป็นค่าสุดท้ายที่ใช้ใน resource argument

ทุกอย่างในบทนี้จริง ๆ แล้วคือการ resolve ค่า ก่อน Terraform เรียก provider เลย: การเรียก function, try/can, conditional, templatefile และ provider-defined function ทั้งหมดรันตอนประเมิน expression ซึ่งเร็วกว่าการเรียก API ตอน plan เสมอ dynamic เป็นข้อยกเว้นเดียวที่ดูเหมือนพวกนี้แต่ไม่ใช่ — มันไม่ได้คำนวณค่า มันขยายเป็นโครงสร้าง HCL (nested block ที่ซ้ำ) ก่อนที่สคีมาของ resource เองจะถูกประเมินด้วยซ้ำ

ตัวเลือกข้อดีต้นทุน
try(a, b, ...)การอ้างอิงที่ผิดพลาดจุดเดียวไม่ทำให้ plan ทั้งหมดล้มเหลว — พิสูจน์ข้างบนด้วย fallback จริงกลืน เหตุผล ที่ expression แรก fail แบบเงียบ ๆ ถ้าตัวถัดไปสำเร็จ
can(expr)boolean ที่สะอาดสำหรับ validation condition พิสูจน์ข้างบนทิ้งค่าจริงไปทั้งหมด
templatefileเก็บข้อความ template ก้อนใหญ่ไว้นอก HCL string ทั้งหมด พิสูจน์ข้างบนด้วยการ render จริงต้องส่งค่าให้ครบทุก variable ที่อ้างอิง — ขาดไปหนึ่งตัวคือ error จริงตอน plan
Provider-defined function (provider::...)parse GCP self-link/id โดยไม่ต้องเขียน split()/regexall() เอง พิสูจน์ข้างบนผูกกับ version ของ google provider ที่ติดตั้ง ไม่ใช่แค่ version ของ Terraform CLI
dynamic blockสร้าง nested block จำนวนไม่คงที่จาก collection พิสูจน์ข้างบนด้วย allowเพิ่มความอ้อม (allow.value) ทำให้ resource อ่านยากกว่า block ตายตัวจำนวนน้อยที่เขียนตรง ๆ
  • “try() คืน null แบบเงียบ ๆ ถ้าทุก expression fail” พิสูจน์ข้างบนแล้วว่ามันโยน error จริงออกมา (“no expression succeeded”) — try() จะกลืน error ก็ต่อเมื่อมี argument ไหนสำเร็จจริง ๆ เท่านั้น
  • “can() คืนค่าจริงถ้า expression สำเร็จ” พิสูจน์ข้างบนแล้วว่า can() คืน boolean ธรรมดาเสมอ ไม่เคยคืนค่าจริง
  • “provider::google::name_from_id รับแค่ id รูปแบบสั้น projects/.../instances/name เท่านั้น” ไม่จริงตามที่ parameter ของ function เอกสารไว้เอง — มันรับ self-link เต็มแบบ https://www.googleapis.com/... และชื่อบางส่วนแบบ //service.googleapis.com/... ได้ด้วย
  • “มีแค่ AWS ที่มี provider-defined function” ขัดแย้งกับสิ่งที่พิสูจน์ข้างบนจากการอ่าน source ของทั้งสอง provider โดยตรง — hashicorp/azurerm มีของตัวเองสองตัว (parse_resource_id, normalise_resource_id) และ hashicorp/aws มีสี่ตัว
  • “dynamic กับ for_each บน resource แก้ปัญหาเดียวกัน” ไม่ใช่: for_each บน resource สร้าง resource instance ทั้งตัวหลายอัน; dynamic สร้าง nested block หลายอัน ภายใน resource instance เดียว — พิสูจน์ข้างบนด้วย google_compute_firewall.allow_multiple ตัวเดียวที่มี allow block ที่ generate มาสามอัน
  • try(a.b.c, "default") ที่ generate มาใช้ทุกที่ที่ค่าอาจหายไป โดยไม่เช็คว่าค่าที่หายไปควรเป็น error ดัง ๆ ตอน plan แทนไหม
  • split("/", google_compute_instance.web.id) ด้วยมือ หรือ regexall() ที่เขียนเองเพื่อดึง zone, region หรือชื่อสั้นจาก GCP resource id/self-link ทั้งที่ provider-defined function (provider::google::zone_from_id, region_from_zone, name_from_id) ทำแบบนั้นให้อยู่แล้ว พิสูจน์ข้างบนแล้ว
  • templatefile() ที่เรียกโดยขาด variable ที่ template อ้างอิงจริง — พิสูจน์ข้างบนแล้วว่านี่คือ error จริงตอน plan ไม่ใช่การแทนที่ด้วยค่าว่าง
  • syntax provider:: ที่โผล่ในตัวอย่างที่ generate มาโดยไม่เช็ค version กับ version ของ google provider ที่ pin ไว้จริงใน required_providers — ความพร้อมใช้งานขึ้นกับ provider version แต่ละตัว ไม่ใช่แค่ “Terraform 1.8 ขึ้นไป”
  • dynamic block ที่ generate มาสำหรับ nested block จำนวนคงที่และน้อยที่รู้อยู่แล้ว (สองสามรายการ allow ที่ไม่เคยเปลี่ยน) — ความอ้อมที่ไม่มีประโยชน์เมื่อชุดข้อมูลไม่ได้ไม่คงที่จริง ๆ
  • google_compute_instance ที่ generate มาโดยไม่มี network_interface block เลย — นี่ไม่ใช่ตัวเลือก terraform validate จะปฏิเสธทันทีเพราะ network_interface เป็น block ที่จำเป็น

🐛 หา bug ในโค้ดนี้

variable "zone_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default zone
effective_zone = try(var.zone_override.name, "us-central1-a")
}
เฉลย

นี่แย่กว่า error เสียอีก — มันคือ bug แบบเงียบ ๆ var.zone_override ถูก type เป็น string ธรรมดา (หรือ null) ไม่ใช่ object ดังนั้นมันไม่มี attribute .name เลย — var.zone_override.name fail ทุกครั้ง ไม่ว่าค่าของ var.zone_override จะเป็นอะไร รวมถึง override จริงที่ caller ตั้งไว้ด้วย เพราะมัน fail เสมอ fallback "us-central1-a" ของ try() เลย fire ทุกครั้งเช่นกัน โดยไม่มี error โผล่ให้เห็นเลย พิสูจน์ด้วยรูปแบบเดียวกันเป๊ะใน terraform console โดยตั้ง override จริง:

$ TF_VAR_zone_override="europe-west1-b" terraform console
> try(var.zone_override.name, "us-central1-a")
"us-central1-a"

caller ที่ตั้ง zone_override = "europe-west1-b" ยังได้ "us-central1-a" กลับมาอยู่ดี — override ของเขาถูกทิ้งอย่างเงียบ ๆ ไม่มี error ไม่มีคำเตือน ไม่มีอะไรใน output ของ plan ที่บอกว่ามีอะไรผิดพลาด พิสูจน์ด้วย expression ดิบ (ไม่มี try()) กับ variable เดียวกัน นี่คือสิ่งที่ try() กำลังกลืนอยู่:

$ terraform console
> var.zone_override.name
╷
│ Error: Attempt to get attribute from null value
│
│ This value is null, so it does not have any attributes.
╵

ทางแก้คือเลิกใช้ try() แทนการทำ type ของ variable ให้ถูกต้อง: อ้างอิง var.zone_override ตรง ๆ (try(var.zone_override, "us-central1-a") ไม่มี .name) เพราะมันเป็น string ธรรมดา หรือเปลี่ยน type ของ variable เป็น object({ name = string }) ถ้า caller ตั้งใจส่งค่าที่มีโครงสร้างจริง ๆ

terraform console ใช้ทำอะไร
conditional expression มีสามส่วนอะไรบ้าง
ควรใช้ dynamic block แทน count หรือ for_each บน resource เมื่อไหร่
cidrsubnet(prefix, newbits, netnum) ทำอะไร
เกิดอะไรขึ้นเมื่อทุก expression ที่ส่งให้ try() fail หมด
provider::google::region_from_zone("us-central1-c") คืนค่าอะไร
Terraform version ไหนที่เพิ่ม provider-defined function (syntax provider::name::function())
azurerm provider มี provider-defined function ให้ใช้ไหม