Expressions, Functions, and Conditionals
แนวคิดในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “แนวคิดในหนึ่งประโยค”คอนฟิก Terraform ไม่ใช่แค่ค่าคงที่ — built-in function แปลงข้อมูล, conditional expression เลือกระหว่างสองค่า และ dynamic block สร้าง nested block จำนวนไม่คงที่ ทั้งหมดนี้ถูกประเมินตอน plan ก่อนที่ GCP API call ใด ๆ จะเกิดขึ้น
Built-in function แบ่งตามหมวดหมู่
หัวข้อที่มีชื่อว่า “Built-in function แบ่งตามหมวดหมู่”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 เอง
terraform console — REPL สำหรับ expression
หัวข้อที่มีชื่อว่า “terraform console — REPL สำหรับ expression”ก่อนใส่ function หรือ expression ที่ไม่คุ้นเคยลงใน resource argument จริง terraform console เปิด REPL แบบ interactive ที่ประเมิน expression กับคอนฟิกและ state ปัจจุบัน โดยไม่ plan หรือ apply อะไรเลย
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 ที่มีอยู่ได้ แต่สร้าง เปลี่ยน หรือทำลายอะไรไม่ได้เลย
try และ can: expression ที่อาจ fail ได้
หัวข้อที่มีชื่อว่า “try และ can: expression ที่อาจ fail ได้”บาง 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)truetry() ที่มี fallback จะคืน fallback ทันทีที่ expression แรก error โดยไม่มี error โผล่ให้เห็นเลย try() ที่ไม่มีตัวไหนสำเร็จเลยจะโยน error จริงออกมา — ไม่ใช่ null แบบเงียบ ๆ can() ไม่เคยคืนค่าจริงเลย คืนแค่ว่าการประเมินนั้นจะสำเร็จไหม — มีประโยชน์ภายใน validation condition เพราะ condition ต้องประเมินเป็น boolean ธรรมดาเสมอ ห้าม raise error
templatefile: render ไฟล์ template จากภายนอก
หัวข้อที่มีชื่อว่า “templatefile: render ไฟล์ template จากภายนอก”templatefile(path, vars) อ่านไฟล์จาก disk, render เป็น Terraform template แล้วคืนผลลัพธ์เป็น string — วิธีมาตรฐานในการเก็บข้อความ template (startup script, nginx config) ให้อยู่นอกไฟล์ HCL ไปเลย
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"] })<<EOTserver { 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 ตรง ๆ
Provider-defined function (Terraform 1.8+)
หัวข้อที่มีชื่อว่า “Provider-defined function (Terraform 1.8+)”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
หัวข้อที่มีชื่อว่า “Conditional expression”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"
Dynamic block — nested block จำนวนไม่คงที่
หัวข้อที่มีชื่อว่า “Dynamic block — nested block จำนวนไม่คงที่”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"] Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้จริง ๆ แล้วคือการ resolve ค่า ก่อน Terraform เรียก provider เลย: การเรียก function, try/can, conditional, templatefile และ provider-defined function ทั้งหมดรันตอนประเมิน expression ซึ่งเร็วกว่าการเรียก API ตอน plan เสมอ dynamic เป็นข้อยกเว้นเดียวที่ดูเหมือนพวกนี้แต่ไม่ใช่ — มันไม่ได้คำนวณค่า มันขยายเป็นโครงสร้าง HCL (nested block ที่ซ้ำ) ก่อนที่สคีมาของ resource เองจะถูกประเมินด้วยซ้ำ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
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ตัวเดียวที่มีallowblock ที่ generate มาสามอัน
อ่านโค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้างให้”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 ของgoogleprovider ที่ pin ไว้จริงในrequired_providers— ความพร้อมใช้งานขึ้นกับ provider version แต่ละตัว ไม่ใช่แค่ “Terraform 1.8 ขึ้นไป” dynamicblock ที่ generate มาสำหรับ nested block จำนวนคงที่และน้อยที่รู้อยู่แล้ว (สองสามรายการallowที่ไม่เคยเปลี่ยน) — ความอ้อมที่ไม่มีประโยชน์เมื่อชุดข้อมูลไม่ได้ไม่คงที่จริง ๆgoogle_compute_instanceที่ generate มาโดยไม่มีnetwork_interfaceblock เลย — นี่ไม่ใช่ตัวเลือกterraform validateจะปฏิเสธทันทีเพราะnetwork_interfaceเป็น block ที่จำเป็น
🐛 หา bug ในโค้ดนี้
variable "zone_override" {type = stringdefault = null}
locals {# "Safely" fall back to a default zoneeffective_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 ตั้งใจส่งค่าที่มีโครงสร้างจริง ๆ