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

Expressions, Functions, and Conditionals

ภาษา expression ของ Terraform มาพร้อม built-in function สำหรับแปลงค่า, REPL terraform console สำหรับเช็คว่า expression ประเมินเป็นอะไรก่อนที่มันจะเข้าใกล้ infrastructure จริงเลย, conditional expression สำหรับเลือกระหว่างสองค่า และ dynamic block สำหรับสร้าง nested block จำนวนไม่คงที่ภายใน resource เดียว

function library ของ Terraform จัดกลุ่มคร่าว ๆ ตามชนิดของค่าที่มันทำงานด้วย string function อย่าง join และ split ย้ายไปมาระหว่าง list กับ string ที่คั่นด้วยตัวคั่น และ format สร้าง string จาก template บวก argument collection function อย่าง lookup, merge, contains อ่านและรวม map กับ list numeric function ครอบคลุมเลขคณิตและการปัดเศษทั่วไป network function อย่าง cidrsubnet เฉพาะสำหรับแบ่ง address range ซึ่งเจอบ่อยมากเมื่อทำงานกับ Azure virtual network

variable "vnet_address_space" {
type = string
description = "CIDR block for the virtual network"
default = "10.0.0.0/16"
}
locals {
# cidrsubnet(prefix, newbits, netnum) carves a /16 into /24 subnets
subnet_web_cidr = cidrsubnet(var.vnet_address_space, 8, 0) # 10.0.0.0/24
subnet_app_cidr = cidrsubnet(var.vnet_address_space, 8, 1) # 10.0.1.0/24
subnet_db_cidr = cidrsubnet(var.vnet_address_space, 8, 2) # 10.0.2.0/24
# format() builds a name from a template; join() flattens a list into a string
name_prefix = format("%s-%s", var.environment, var.project)
dns_servers = join(",", ["10.0.0.4", "10.0.0.5"])
# merge() combines maps, later arguments winning on key conflicts
vm_tags = merge(
{ Project = var.project, Environment = var.environment },
{ Tier = "web" },
)
# lookup() reads a map key with a fallback instead of erroring on a miss
region_short_name = lookup(
{ eastus = "eus", eastus2 = "eus2", westeurope = "weu" },
var.location,
"unk",
)
# contains() checks membership without writing a for expression
is_allowed_region = contains(["eastus", "eastus2", "westeurope"], var.location)
}
resource "azurerm_virtual_network" "main" {
name = "${local.name_prefix}-vnet"
address_space = [var.vnet_address_space]
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
resource "azurerm_subnet" "web" {
name = "${local.name_prefix}-web-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [local.subnet_web_cidr]
}

cidrsubnet ควรเข้าใจให้ดี: มันรับ CIDR block ฐาน, จำนวนบิตเพิ่มที่จะขยาย prefix, และหมายเลข subnet แล้วคืน CIDR block ใหม่ที่ไม่ทับกัน cidrsubnet("10.0.0.0/16", 8, 0) ขยาย /16 ด้วย 8 บิตเป็น /24 แล้วเลือกบล็อกที่ศูนย์ ได้ 10.0.0.0/24; ส่ง 1 เป็น argument ที่สามได้ 10.0.1.0/24 และต่อไป การคำนวณ subnet range สามตัวแบบนี้ครั้งเดียวใน locals ทำให้ทุก azurerm_subnet resource อ้างอิง local ที่มีชื่อแทน CIDR literal ที่พิมพ์เองซึ่งต้องมานั่งเช็คว่าทับกันหรือเปล่า

terraform console เปิด REPL แบบ interactive กับคอนฟิกและ state ปัจจุบัน โดยไม่เปลี่ยนทั้งคู่ มันคือวิธีที่เร็วที่สุดในการตอบคำถาม “expression นี้ประเมินเป็นอะไรจริง ๆ” ก่อนจะ paste ลงใน resource argument ที่พิมพ์ผิดแล้วสังเกตได้ยากกว่ามาก

Terminal window
$ terraform console
> join("-", ["dev", "payments", "web"])
"dev-payments-web"
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> contains(["eastus", "eastus2", "westeurope"], "westeurope")
true
> var.vnet_address_space
"10.0.0.0/16"
> exit

ทุกบรรทัดที่พิมพ์ที่ prompt > ถูกประเมินครั้งเดียวแล้วพิมพ์ผลลัพธ์ เหมือนกับที่ expression ตอน plan จะ resolve เป๊ะ โดยใช้ค่าจริงที่มีอยู่แล้วใน variable และ state ไม่มีขั้นตอน apply และไม่มี side effect — exit หรือ Ctrl-D แค่ปิด session นี่คือเครื่องมือที่ควรใช้ทุกครั้งที่การเรียก cidrsubnet หรือ merge ของสอง map ดูไม่ถูกต้องใน plan diff แล้วต้องการแยก expression ที่แท้จริงออกมาก่อนแตะ resource block เอง

บาง 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 location เลย:

> try(var.config.location, "eastus")
"eastus"
> try(var.config.location)
╷
│ Error: Error in function call
│
│ Call to function "try" failed: no expression succeeded:
│ - Unsupported attribute
│ This object does not have an attribute named "location".
│
│ At least one expression must produce a successful result.
╵
> can(var.config.location)
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 (cloud-init 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", "eastus"]
})
}

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

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

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

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 นั้นเอง (Azure resource ID) ต้องการ logic การ parse ที่ function library ทั่วไปไม่ควรรู้

provider hashicorp/azurerm (ยืนยันแล้วจากการอ่าน internal/provider/function/ ใน source ของ hashicorp/terraform-provider-azurerm) ตอนนี้มีสองตัว: parse_resource_id และ normalise_resource_id

locals {
parsed_id = provider::azurerm::parse_resource_id(azurerm_linux_virtual_machine.web.id)
}

ผลลัพธ์จริงจาก terraform console กับรูปแบบ Azure resource ID จริง:

> provider::azurerm::parse_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web")
{
"full_resource_type" = "Microsoft.Compute/virtualMachines"
"parent_resources" = tomap({})
"resource_group_name" = "rg-demo"
"resource_name" = "web"
"resource_provider" = "Microsoft.Compute"
"resource_scope" = ""
"resource_type" = "virtualMachines"
"subscription_id" = "00000000-0000-0000-0000-000000000000"
}
> provider::azurerm::normalise_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/rg-demo/providers/microsoft.compute/virtualmachines/web")
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web"

parse_resource_id แปลง ARM resource ID string ยาว ๆ ที่พิมพ์ผิดง่ายซึ่งทุก azurerm_* resource คืนเป็น .id ให้เป็น object ที่มีโครงสร้าง — resource_group_name, resource_type, subscription_id โดยไม่ต้องเขียน split("/", ...) เอง normalise_resource_id แก้ปัญหาตรงข้ามที่ลึกซึ้งกว่า: Azure resource ID string สองอันที่หมายถึง resource เดียวกันเป๊ะอาจต่างกันแค่ตัวพิมพ์ใหญ่เล็ก (resourcegroups กับ resourceGroups, microsoft.compute กับ Microsoft.Compute) ขึ้นอยู่กับว่า API ไหนคืนมา ซึ่งทำให้การเทียบ string ตรง ๆ พัง; normalise_resource_id เขียนทับตัวพิมพ์ให้เป็นรูปแบบมาตรฐานของ ARM ทำให้ ID สองอันของ resource เดียวกันเทียบเท่ากันได้

Conditional expression มีสามส่วน: condition ที่ต้องประเมินเป็น true หรือ false, ค่าที่ใช้เมื่อมันเป็น true, และค่าที่ใช้เมื่อมันเป็น false เขียนเป็น condition ? true_val : false_val มันคือ construct การแตกสาขาระดับค่าเดียวที่ Terraform มี — ไม่มี if statement มีแค่ expression ที่ resolve เป็นหนึ่งในสองค่า

locals {
vm_size = var.environment == "prod" ? "Standard_D8s_v3" : "Standard_D2s_v3"
vm_tier_tag = upper(var.environment == "prod" ? "critical" : "standard")
}
resource "azurerm_linux_virtual_machine" "web" {
name = "${local.name_prefix}-web"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = local.vm_size
admin_username = "azureuser"
admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
tags = {
Tier = local.vm_tier_tag
}
}

local.vm_size เลือก virtual machine ขนาดใหญ่ขึ้นเฉพาะใน prod ที่อื่นตกไปใช้ default ที่เล็กกว่า — ทดแทนสิ่งที่ปกติต้องเป็นสอง variable default ที่แทบเหมือนกัน หรือตาราง lookup สำหรับตัวเลือกแบบไบนารี ด้วยบรรทัดเดียว local.vm_tier_tag ต่อ conditional เข้ากับการเรียก function โดยตรง: conditional resolve เป็น "critical" หรือ "standard" ก่อน แล้ว upper() ก็แปลง string ที่ชนะก่อนจะถูกกำหนดให้ tag การซ้อนแบบนี้เป็นเรื่องปกติ — conditional ก็แค่ expression และปรากฏได้ทุกที่ที่ expression ใช้ได้ รวมถึงเป็น argument ของอีก function หนึ่ง

count และ for_each บน resource (จะพูดถึงในบทถัดไป) ตัดสินใจว่าจะสร้างสำเนาของ resource ทั้งตัวกี่ชุด dynamic block แก้ปัญหาที่เกี่ยวข้องกันแต่ต่างออกไป: สร้าง nested block ที่ซ้ำ ๆ กันจำนวนไม่คงที่ ภายใน resource เดียว ขับเคลื่อนด้วย collection azurerm_network_security_group เป็นตัวอย่างที่ดี — มันรับ security_rule block ได้ตั้งแต่ศูนย์ตัวขึ้นไป และจำนวนกฎมักมาจาก list ที่ caller ควบคุม ไม่ใช่จำนวนคงที่ที่รู้ล่วงหน้า

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the network security group"
default = [22, 443, 8080]
}
resource "azurerm_network_security_group" "web" {
name = "${local.name_prefix}-web-nsg"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
dynamic "security_rule" {
for_each = var.allowed_ports
content {
name = "allow-port-${security_rule.value}"
priority = 100 + security_rule.key
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = tostring(security_rule.value)
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
}

dynamic "security_rule" block วนซ้ำ var.allowed_ports แล้วปล่อย nested block security_rule หนึ่งอันต่อหนึ่ง element โดยไม่ต้องเขียน security_rule { ... } แยกสามครั้ง หรือแก้ resource ทุกครั้งที่รายการพอร์ตเปลี่ยน ภายใน content, security_rule.value คือ element ปัจจุบันของ list และ security_rule.key คือ index ของมัน ทั้งคู่ตั้งชื่อตาม label ของ block โดย default เพิ่มพอร์ตที่สี่เข้าไปใน allowed_ports แล้ว security_rule block ที่สี่จะปรากฏใน plan ถัดไป โดยไม่ต้องแก้ resource block เอง — นี่คือความสะดวกเดียวกับที่ count/for_each มอบให้ในระดับ resource ทั้งตัว แค่ประยุกต์ลึกลงไปอีกชั้น กับ block ที่อยู่ภายใน resource เดียว

flowchart LR
  a["var.environment"] --> b{"conditional: environment == prod ?"}
  b -->|true| c["critical"]
  b -->|false| d["standard"]
  c --> e["upper() function call"]
  d --> e
  e --> f["local.vm_tier_tag"]
  f --> g["azurerm_linux_virtual_machine.web tags"]
conditional และการเรียก 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/normalize Azure resource ID โดยไม่ต้องเขียน split() เองและไม่ต้องกังวลการเทียบ string ที่พังเพราะตัวพิมพ์ พิสูจน์ข้างบนผูกกับ version ของ azurerm provider ที่ติดตั้ง ไม่ใช่แค่ version ของ Terraform CLI
dynamic blockสร้าง nested block จำนวนไม่คงที่จาก collection พิสูจน์ข้างบนด้วย security_ruleเพิ่มความอ้อม (security_rule.value/.key) ทำให้ resource อ่านยากกว่า block ตายตัวจำนวนน้อยที่เขียนตรง ๆ
  • “try() คืน null แบบเงียบ ๆ ถ้าทุก expression fail” พิสูจน์ข้างบนแล้วว่ามันโยน error จริงออกมา (“no expression succeeded”) — try() จะกลืน error ก็ต่อเมื่อมี argument ไหนสำเร็จจริง ๆ เท่านั้น
  • “can() คืนค่าจริงถ้า expression สำเร็จ” พิสูจน์ข้างบนแล้วว่า can() คืน boolean ธรรมดาเสมอ ไม่เคยคืนค่าจริง
  • “Azure resource ID สอง string ที่ดูต่างกันต้องเป็นคนละ resource แน่นอน” ไม่จำเป็นเสมอไป — พิสูจน์ข้างบนด้วย normalise_resource_id: ความต่างของตัวพิมพ์ (resourcegroups กับ resourceGroups) ทำให้ ID ของ resource เดียวกัน เทียบเป็น string ตรง ๆ แล้วไม่เท่ากันได้ ต้อง normalize ก่อนเทียบ
  • “มีแค่ AWS ที่มี provider-defined function” ขัดแย้งกับสิ่งที่พิสูจน์ข้างบนจากการอ่าน source ของทั้งสอง provider โดยตรง — hashicorp/google มีของตัวเองเจ็ดตัว (name_from_id, region_from_zone และอื่น ๆ) และ hashicorp/aws มีสี่ตัว
  • “dynamic กับ for_each บน resource แก้ปัญหาเดียวกัน” ไม่ใช่: for_each บน resource สร้าง resource instance ทั้งตัวหลายอัน; dynamic สร้าง nested block หลายอัน ภายใน resource instance เดียว — พิสูจน์ข้างบนด้วย azurerm_network_security_group.web ตัวเดียวที่มี security_rule block ที่ generate มาสามอัน
  • try(a.b.c, "default") ที่ generate มาใช้ทุกที่ที่ค่าอาจหายไป โดยไม่เช็คว่าค่าที่หายไปควรเป็น error ดัง ๆ ตอน plan แทนไหม
  • split("/", azurerm_resource.thing.id) ด้วยมือเพื่อดึง resource group หรือ resource type จาก Azure resource ID ทั้งที่ provider::azurerm::parse_resource_id ทำแบบนั้นให้อยู่แล้ว พิสูจน์ข้างบนแล้ว — และน่าเชื่อถือกว่า เพราะตัวพิมพ์ของ ARM ID ไม่สอดคล้องกันเสมอไปข้าม API
  • การเทียบ string == ตรง ๆ ระหว่าง Azure resource ID สองอันที่มาจากแหล่งต่างกัน (อันหนึ่งจาก resource attribute อีกอันจาก data source หรือ variable) โดยไม่ normalize ตัวพิมพ์ก่อน — พิสูจน์ข้างบนแล้วว่านี่คือสาเหตุจริงของผลลัพธ์ “ไม่เท่ากัน” ที่ผิดพลาด
  • templatefile() ที่เรียกโดยขาด variable ที่ template อ้างอิงจริง — พิสูจน์ข้างบนแล้วว่านี่คือ error จริงตอน plan ไม่ใช่การแทนที่ด้วยค่าว่าง
  • dynamic block ที่ generate มาสำหรับ nested block จำนวนคงที่และน้อยที่รู้อยู่แล้ว (สองสามกฎ security_rule ที่ไม่เคยเปลี่ยน) — ความอ้อมที่ไม่มีประโยชน์เมื่อชุดข้อมูลไม่ได้ไม่คงที่จริง ๆ

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

variable "location_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default location
effective_location = try(var.location_override.name, "eastus")
}
เฉลย

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

$ TF_VAR_location_override="westeurope" terraform console
> try(var.location_override.name, "eastus")
"eastus"

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

$ terraform console
> var.location_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.location_override ตรง ๆ (try(var.location_override, "eastus") ไม่มี .name) เพราะมันเป็น string ธรรมดา หรือเปลี่ยน type ของ variable เป็น object({ name = string }) ถ้า caller ตั้งใจส่งค่าที่มีโครงสร้างจริง ๆ

terraform console ใช้ทำอะไร
conditional expression ที่เขียนเป็น condition ? true_val : false_val มีสามส่วนอะไรบ้าง
ควรใช้ dynamic block แทน count หรือ for_each บน resource ทั้งตัวเมื่อไหร่
cidrsubnet(var.vnet_address_space, 8, 1) ทำอะไร
เกิดอะไรขึ้นเมื่อทุก expression ที่ส่งให้ try() fail หมด
provider::azurerm::normalise_resource_id แก้ปัญหาอะไร
Terraform version ไหนที่เพิ่ม provider-defined function (syntax provider::name::function())
google provider มี provider-defined function ให้ใช้ไหม