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

Expressions, Functions, and Conditionals

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

Terraform มี standard library ของ built-in function ขนาดใหญ่ให้มาแล้ว — ไม่มีทางนิยามฟังก์ชันของตัวเองได้ ฟังก์ชันเหล่านี้แบ่งเป็นกลุ่มที่ควรรู้จักตามหมวดหมู่มากกว่าท่องจำทีละตัว: string function อย่าง join, split, format จัดรูปแบบข้อความ; collection function อย่าง lookup, merge, contains ทำงานกับ list, map, set; numeric function จัดการเลขคณิตและการปัดเศษ; และชุด function เฉพาะด้าน network ที่สำคัญที่สุดคือ cidrsubnet มีอยู่เพราะการแบ่ง IP address range ด้วยมือคือเลขคณิตซ้ำ ๆ ที่ผิดพลาดง่ายแบบที่ควรให้ function ทำแทน

locals {
# string functions
bucket_name = join("-", [var.project, var.environment, "logs"])
az_list = split(",", "us-east-1a,us-east-1b,us-east-1c")
label = format("%s-%03d", var.project, var.build_number)
# collection functions
instance_type = lookup(var.instance_type_overrides, var.environment, "t3.micro")
full_tags = merge(var.default_tags, var.extra_tags)
is_prod_like = contains(["prod", "staging"], var.environment)
}
resource "aws_subnet" "app" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
}

cidrsubnet(prefix, newbits, netnum) รับ CIDR block แม่ ขยาย prefix length ด้วย newbits แล้วเติมบิตใหม่ด้วย netnum เพื่อผลิต subnet หนึ่งอัน จาก VPC /16 กับ newbits = 4 แต่ละครั้งที่เรียกจะได้ /20 และการส่ง count.index เป็น netnum จะแจก subnet ที่ต่อเนื่องและไม่ทับกันให้อัตโนมัติ — ไม่มีใครต้องคำนวณ 10.0.16.0/20, 10.0.32.0/20 และต่อไปด้วยมือ และไม่มีใครต้องจำว่าต้องข้าม range ที่ใช้ไปแล้ว

ก่อนใส่ function ที่ไม่คุ้นเคยลงใน resource argument terraform console คือวิธีที่เร็วที่สุดในการดูว่ามันคืนค่าอะไรจริง ๆ มันเปิด REPL แบบ interactive กับคอนฟิกและ state ปัจจุบัน โดยไม่แตะ infrastructure จริงเลย

Terminal window
terraform console
> cidrsubnet("10.0.0.0/16", 4, 2)
"10.0.32.0/20"
> join("-", ["payments", "prod", "logs"])
"payments-prod-logs"
> lookup({dev = "t3.micro", prod = "m6i.large"}, "staging", "t3.small")
"t3.small"

คำตอบทุกอันข้างบนคือสิ่งที่ปกติต้องเดา, รัน plan เต็ม ๆ เพื่อดู หรือคำนวณเองบนกระดาษ terraform console ประเมิน expression อะไรก็ได้ — เรียก function, อ้างอิง variable หรือ resource attribute, conditional ทั้งอัน — แล้วพิมพ์ผลลัพธ์ทันที ทำให้มันเป็นจุดแรกที่ควรลองทุกครั้งที่พฤติกรรมของ expression ไม่ชัดเจน

บาง expression ใช้ได้แค่บางเวลา — object attribute ที่เป็นตัวเลือกซึ่งอาจถูกตั้งหรือไม่ก็ได้, index เข้า list ที่อาจสั้นกว่าที่คาดไว้ 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 region เลย:

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

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

templatefile(path, vars) อ่านไฟล์จาก disk, render เป็น Terraform template (interpolation ${...} แบบเดียวกับที่ใช้ใน string ได้ พร้อม directive %{ for ... }/%{ if ... }) แล้วคืนผลลัพธ์ที่ render แล้วเป็น string — วิธีมาตรฐานในการเก็บข้อความ template ก้อนใหญ่ (cloud-init script, nginx config, systemd unit) ให้อยู่นอกไฟล์ 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", "east"]
})
}

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

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

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

Conditional expression มีสามส่วนพอดี: condition ? true_val : false_val Terraform ประเมิน condition เป็น boolean แล้วคืน true_val ถ้าเป็น true, คืน false_val ถ้าไม่ใช่ — ไม่มีอะไรซับซ้อนไปกว่านี้ แต่เมื่อรวมกับการเรียก function มันกลายเป็นวิธีกระชับในการเลือกระหว่างสองค่าที่คำนวณมา

variable "environment" {
type = string
default = "dev"
}
locals {
instance_type = var.environment == "prod" ? "m6i.large" : "t3.micro"
backup_retention_days = var.environment == "prod" ? 30 : 7
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = local.instance_type
}

local.instance_type resolve เป็นหนึ่งในสอง string ก่อนที่ aws_instance.web จะถูก plan เลย — ไม่มีการแตกสาขาเหลืออยู่ตอน apply มีแค่ค่าเดียวที่ resolve แล้วอยู่ใน instance_type นี่คือรูปแบบที่ควรใช้เมื่อค่าขึ้นกับเงื่อนไข yes/no เดียว ส่วนอะไรที่มีผลลัพธ์มากกว่าสอง lookup กับ map (แบบที่แสดงข้างบน) หรือ for expression มักชัดเจนกว่าการซ้อน conditional เข้าไปในกันและกัน

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

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the security group"
default = [22, 443, 8080]
}
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}

มี aws_security_group.app แค่ resource เดียว แต่ dynamic "ingress" block ขยายเป็น ingress { ... } block หนึ่งอันต่อหนึ่งรายการใน var.allowed_ports — สามพอร์ตเข้า สาม ingress block ออก และถ้าไม่มีรายการเลยก็จะได้ ingress block ศูนย์อัน ภายใน content, ingress.value อ้างอิง element ปัจจุบัน บทบาทเดียวกับที่ each.value เล่นให้กับ for_each ระดับ resource ใช้ dynamic เฉพาะเมื่อสิ่งที่ซ้ำอยู่ ภายใน resource อย่าง ingress ใน aws_security_group; ใช้ count หรือ for_each บนตัว resource เองเมื่อสิ่งที่ซ้ำคือ resource ทั้งตัว

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

provider hashicorp/aws (ยืนยันแล้วจากการอ่าน internal/function/ ใน source ของ hashicorp/terraform-provider-aws) ตอนนี้มีสี่ตัว: arn_parse, arn_build, trim_iam_role_path และ user_agent

locals {
parsed_arn = provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
}

ผลลัพธ์จริงจาก terraform console:

> provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
{
"account_id" = "444455556666"
"partition" = "aws"
"region" = ""
"resource" = "role/example"
"service" = "iam"
}
> provider::aws::arn_build("aws", "iam", "", "444455556666", "role/example")
"arn:aws:iam::444455556666:role/example"
> provider::aws::trim_iam_role_path("arn:aws:iam::444455556666:role/some/deep/path/example")
"arn:aws:iam::444455556666:role/example"

arn_parse แปลง string ที่ AWS resource ทุกตัวคืนกลับมา (ARN) ให้เป็น object ที่มีโครงสร้าง โดยไม่ต้องเขียน regex() กับ split() เอง; arn_build คือด้านตรงข้าม มีประโยชน์เมื่อ resource argument ต้องการ ARN เต็มที่ประกอบจากส่วนที่คุณมีอยู่แล้วเป็น variable แยกกัน ก่อนใช้ provider-defined function ให้เช็คว่า provider ที่ ติดตั้งจริง มีอะไรให้ใช้บ้าง — นี่คือโค้ดจริงที่มี version จาก provider binary เอง ไม่ใช่ส่วนหนึ่งของ Terraform core ดังนั้นความพร้อมใช้งานขึ้นกับ version ของ provider ที่ pin ไว้ใน required_providers ไม่ใช่แค่ version ของ Terraform CLI (1.8+ จำเป็นแต่ไม่พอ)

flowchart LR
  e["var.environment"] --> c{"environment == prod ?"}
  c -->|true| a["m6i.large"]
  c -->|false| b["t3.micro"]
  a --> r["local.instance_type"]
  b --> r
  r --> res["aws_instance.web"]
conditional และการเรียก function resolve เป็นค่าเดียวก่อน resource จะถูก plan

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

ตัวเลือกข้อดีต้นทุน
try(a, b, ...)การอ้างอิงที่ผิดพลาดจุดเดียวไม่ทำให้ plan ทั้งหมดล้มเหลว — พิสูจน์ข้างบนด้วย fallback จริงกลืน เหตุผล ที่ expression แรก fail แบบเงียบ ๆ ถ้าตัวถัดไปสำเร็จ ใช้มากเกินไปอาจซ่อน bug ของคอนฟิกจริงไว้หลัง fallback
can(expr)boolean ที่สะอาดสำหรับ validation condition พิสูจน์ข้างบน (false/true บน object เดียวกัน)ทิ้งค่าจริงไปทั้งหมด — ไม่มีประโยชน์เลยเมื่อต้องการค่าจริง ต้องการแค่ว่ามันจะสำเร็จไหม
templatefileเก็บข้อความ template ก้อนใหญ่ (cloud-init, config file) ไว้นอก HCL string ทั้งหมด พิสูจน์ข้างบนด้วยการ render จริงต้องส่งค่าให้ครบทุก variable ที่ template อ้างอิง — ขาดไปหนึ่งตัวคือ error จริงตอน plan ไม่ใช่ค่าว่าง
Provider-defined function (provider::...)logic การ parse เฉพาะของ provider (โครงสร้าง ARN, รูปแบบ resource ID) โดยไม่ต้องเขียน regex() เองผูกกับ version ของ provider ที่ติดตั้ง ไม่ใช่แค่ version ของ Terraform CLI — พิสูจน์ข้างบนแล้วว่ามีสี่ตัวจริงใน hashicorp/aws ~> 6.0 ที่คอร์สนี้ pin ไว้
dynamic blockสร้าง nested block จำนวนไม่คงที่จาก collection พิสูจน์ข้างบนด้วย ingressเพิ่มชั้นความอ้อม (ingress.value แทน block ตรง ๆ) ทำให้ resource อ่านยากกว่า block ตายตัวจำนวนน้อยที่เขียนตรง ๆ
  • “try() คืน null แบบเงียบ ๆ ถ้าทุก expression fail” พิสูจน์ข้างบนแล้วว่ามันโยน error จริงออกมา (“no expression succeeded”) — try() จะกลืน error ก็ต่อเมื่อมี argument ไหนสำเร็จจริง ๆ เท่านั้น
  • “can() คืนค่าจริงถ้า expression สำเร็จ” พิสูจน์ข้างบนแล้วว่า can() คืน boolean ธรรมดาเสมอ ไม่เคยคืนค่าจริง — can(var.config.name) คืน true ไม่ใช่ "app"
  • “Provider-defined function เป็นฟีเจอร์ของ Terraform core ดังนั้นทุก provider version รองรับทันทีที่อยู่บน Terraform 1.8+” พิสูจน์ข้างบนแล้วจากการเช็ค source ของ provider จริง: ถูก implement และมาพร้อม provider ดังนั้นความพร้อมใช้งานขึ้นกับ version ของ provider ไม่ใช่แค่ version ของ Terraform CLI
  • “มีแค่ AWS ที่มี provider-defined function azurerm กับ google ไม่มี” ขัดแย้งกับสิ่งที่พิสูจน์ข้างบนจากการเช็ค source ของทั้งสอง provider โดยตรง — ทั้งคู่มีของจริง (parse_resource_id/normalise_resource_id สำหรับ azurerm, function *_from_id/region_from_zone เจ็ดตัวสำหรับ google)
  • “dynamic กับ for_each บน resource แก้ปัญหาเดียวกัน” ไม่ใช่: for_each บน resource สร้าง resource instance ทั้งตัวหลายอัน; dynamic สร้าง nested block หลายอัน ภายใน resource instance เดียว — พิสูจน์ข้างบนด้วย aws_security_group.app ตัวเดียวที่มี ingress block ที่ generate มาสามอัน
  • try(a.b.c, "default") ที่ generate มาใช้ทุกที่ที่ค่าอาจหายไป โดยไม่เช็คว่าค่าที่หายไปควรเป็น error ดัง ๆ ตอน plan แทนไหม — try() คือการกลืน error โดยตั้งใจ ไม่ใช่รูปแบบที่ควรใช้ทุกที่
  • regex() และแยก string ด้วยมือเพื่อดึงข้อมูลจาก ARN หรือ resource ID ของ cloud ทั้งที่ provider-defined function (provider::aws::arn_parse, provider::azurerm::parse_resource_id, provider::google::name_from_id) ทำแบบนั้นให้อยู่แล้ว น่าเชื่อถือกว่า พิสูจน์ข้างบนแล้ว
  • templatefile() ที่เรียกโดยขาด variable ที่ template อ้างอิงจริง — พิสูจน์ข้างบนแล้วว่านี่คือ error จริงตอน plan ไม่ใช่การแทนที่ด้วยค่าว่าง เช็คว่า ${...} reference ในไฟล์ template ตรงกับ vars map ไหม
  • syntax provider:: ที่โผล่ในตัวอย่างที่ generate มาโดยไม่เช็ค version กับ version ของ provider ที่ pin ไว้จริงใน required_providers — พิสูจน์ข้างบนแล้วว่าความพร้อมใช้งานขึ้นกับ provider version แต่ละตัว ไม่ใช่แค่ “Terraform 1.8 ขึ้นไป”
  • dynamic block ที่ generate มาสำหรับสิ่งที่จริง ๆ แล้วเป็น nested block จำนวนคงที่และน้อย ที่รู้อยู่แล้ว (สองสามกฎ ingress ที่ไม่เคยเปลี่ยน) — dynamic block เพิ่มความอ้อมโดยไม่ได้ประโยชน์อะไรเมื่อชุดข้อมูลไม่ได้ไม่คงที่จริง ๆ block ตรง ๆ ที่ซ้ำกันอ่านง่ายกว่า

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

variable "region_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default region
effective_region = try(var.region_override.name, "us-east-1")
}
เฉลย

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

$ TF_VAR_region_override="eu-west-1" terraform console
> try(var.region_override.name, "us-east-1")
"us-east-1"

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

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

(ถ้าเป็น string ที่ไม่ใช่ null ก็ fail แบบเดียวกันแค่ข้อความต่าง — ค่า string ไม่มี attribute ที่มีชื่อเลย) ทางแก้คือเลิกใช้ try() แทนการทำ type ของ variable ให้ถูกต้อง: อ้างอิง var.region_override ตรง ๆ (try(var.region_override, "us-east-1") ไม่มี .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 หมด
ความต่างหลักระหว่าง try() กับ can() คืออะไร
Terraform version ไหนที่เพิ่ม provider-defined function (syntax provider::name::function())
azurerm provider มี provider-defined function ให้ใช้ไหม