HCL 语言基础
前面两篇讲了 Terraform 的核心概念与架构和安装与快速上手,能把一个最小配置跑起来。但要写得动真实项目的配置,得先把 HCL 这门语言本身摸熟。这篇笔记按「语法 → 变量 → 类型 → 函数 → 条件循环」的顺序过一遍日常最常用的部分。示例基于 Terraform 1.9+,全部用 local_file、random_pet 这类本地 provider 演示,macOS / Linux 下不需要任何云账号,可以直接照抄执行。
HCL 基本语法
HCL 配置文件以 .tf 结尾,核心结构只有三样:块(block)、属性(attribute)、表达式(expression)。
块是配置的主体,格式是「块类型 + 若干标签 + 一对花括号包起来的主体」。拿最常见的 resource 块拆解:
# resource 是块类型
# "local_file" 是资源类型标签,"demo" 是资源名标签
# 花括号里是块主体,里面写属性赋值和嵌套块
resource "local_file" "demo" {
filename = "${path.module}/hello.txt"
content = "hello terraform"
}
- 属性:
名字 = 值,值可以是字面量,也可以是表达式,比如上面的插值字符串。 - 嵌套块:块里面还可以套块,比如后面会写的
validation块、lifecycle块,嵌套块没有等号。 - 表达式:等号右边的一切,从字面量、变量引用到函数调用都算。
注释有三种写法,团队里统一用 # 就好:
# 单行注释,最常用
// 单行注释,等价于 #
/*
多行注释
适合临时屏蔽一大段配置
*/
标识符(资源名、变量名)可以包含字母、数字、下划线和连字符,但不能以数字开头。实际项目里建议只用小写字母和下划线,local_file.demo_web 这样的名字在任何地方引用都不会有歧义。
缩进、等号对齐这些不用手工纠结,terraform fmt 一键格式化整个目录,提交前跑一遍即可。
输入变量 variable
变量用 variable 块声明,块标签就是变量名,引用时写 var.变量名:
variable "env" {
type = string
default = "dev"
description = "部署环境,只能是 dev / staging / prod"
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env 只能是 dev、staging 或 prod。"
}
}
variable "db_password" {
type = string
sensitive = true
description = "数据库密码,没有默认值,必须显式提供"
}
几个参数的用途:
type:类型约束,不写则接受任意类型。写上之后传错类型会在 plan 阶段直接报错,越早失败越好。default:默认值。写了默认值的变量是可选的;不写默认值就是必填变量,没赋值时 Terraform 会交互式提问。description:说明文字,写清楚用途和取值范围,交互式提问和文档里都会显示。validation:自定义校验,condition为 false 时报error_message并中止,1.9+ 还支持在 condition 里引用其他变量做交叉校验。sensitive:敏感值标记。标记后 plan/apply 的输出里会显示为(sensitive value),不会明文打印。
赋值的四种方式与优先级
同一个变量可以在多个地方赋值,后面的来源覆盖前面的。优先级从低到高:
默认值 < TF_VAR_ 环境变量 < terraform.tfvars / *.auto.tfvars 文件 < -var / -var-file 命令行参数
具体写法各来一个:
# terraform.tfvars —— 放在工作目录下会被自动加载
env = "staging"
# prod.auto.tfvars —— 以 .auto.tfvars 结尾的文件也会自动加载
env = "prod"
# 方式一:环境变量,适合 CI 里注入敏感值
TF_VAR_db_password='s3cret' terraform apply
# 方式二:命令行 -var,适合临时覆盖单个变量
terraform plan -var="env=prod"
# 方式三:命令行 -var-file,指定额外的变量文件
terraform apply -var-file="prod.tfvars"
日常只需要记住:默认值兜底,-var 和 -var-file 最大。中间两层(环境变量、自动加载的 tfvars)发生冲突时按「tfvars 文件压过环境变量、后指定的 -var-file 压过先指定的」处理。
locals 与 output
locals:整理复用的表达式
同一个表达式在多个资源里用到,或者一串拼接逻辑太长时,用 locals 块起个名字收起来:
locals {
name_prefix = "${var.env}-demo"
common_tags = {
environment = var.env
managed_by = "terraform"
}
}
resource "local_file" "demo" {
filename = "${path.module}/${local.name_prefix}.txt"
content = "tags: ${jsonencode(local.common_tags)}"
}
引用写 local.名字。locals 可以在任何模块里定义多次,建议按用途拆成几个 locals 块,比如一个放命名规则、一个放通用标签。
locals 的值在模块内固定,不能像 variable 那样从外部覆盖。需要外部传参的地方用 variable,纯内部复用才用 locals。
output:把结果吐出来
output 块把 apply 之后的结果打印出来,也能被其他模块或脚本消费:
output "file_path" {
value = local_file.demo.filename
description = "生成的文件路径"
}
output "db_password" {
value = var.db_password
sensitive = true
}
配套的 terraform output 命令:
# 列出所有输出(sensitive 的值也会在这里明文显示)
terraform output
# 取单个输出
terraform output file_path
# 去引号的原始值,适合接进 shell 脚本
terraform output -raw file_path
# JSON 格式,接 jq 过滤
terraform output -json | jq -r '.file_path.value'
sensitive = true 的输出不会在 plan/apply 结尾打印,但 terraform output 主动查询时仍会显示明文——它防的是「不小心出现在日志里」,不是加密存储。
类型系统
HCL 的类型分两类:简单类型(string、number、bool)和集合类型。速查表:
| 类型 | 说明 | 字面量示例 |
|---|---|---|
string | UTF-8 字符串 | "hello" |
number | 数字,不区分整数和浮点 | 42、3.14 |
bool | 布尔 | true、false |
list | 有序列表,元素同类型,可重复 | ["a", "b"] |
set | 无序集合,元素不重复 | toset(["a", "b"]) |
map | 键值对,键为字符串,值同类型 | { name = "web", port = "80" } |
object | 结构化对象,各字段类型可不同 | { name = "web", port = 80 } |
tuple | 定长有序列表,各元素类型可不同 | ["web", 80, true] |
变量声明里的写法:
variable "ports" {
type = list(number)
default = [80, 443]
}
variable "server" {
type = object({
name = string
port = number
ssl = optional(bool, true)
})
default = {
name = "web"
port = 8080
}
}
optional() 是 object 类型里的可选字段语法,第二个参数是缺省值。集合类型之间可以用 tolist()、toset()、tomap() 互转,list 去重最省事的方法就是 tolist(toset(x))。
常用函数速览
HCL 不允许自定义函数,但内置函数覆盖了日常八九成的需求。下面这张表按使用频率排序,示例都可以粘到 terraform console 里直接试:
| 函数 | 作用 | 示例 |
|---|---|---|
join | 列表拼成字符串 | join(",", ["a", "b"]) 得 "a,b" |
split | 字符串切成列表 | split(",", "a,b") 得列表 |
concat | 拼接多个列表 | concat([1], [2, 3]) |
merge | 合并 map,后者覆盖前者 | merge(local.common_tags, var.extra_tags) |
lookup | 取 map 的键,带兜底默认值 | lookup(var.ports, "http", 80) |
length | 字符串 / 列表 / map 的长度 | length(var.ports) |
keys / values | map 的键列表 / 值列表 | keys(local.common_tags) |
format | printf 风格格式化 | format("%s-%02d", "web", 3) 得 "web-03" |
file | 读文件内容成字符串 | file("${path.module}/init.sh") |
templatefile | 渲染模板文件 | 见下文专门一节 |
try | 依次尝试表达式,取第一个不报错的 | try(var.config.port, 8080) |
coalesce | 取第一个非 null、非空字符串的值 | coalesce(var.custom_name, "default") |
can | 表达式能否求值,返回布尔 | can(regex("^prod", var.env)) |
try、can、coalesce 三个是处理「不确定是否存在的数据」的组合拳,多用在读外部数据源或可选模块输出的场景。
不确定函数行为就开 terraform console,它是一个交互式求值器,直接输入 join("-", ["a", "b"]) 回车看结果,比改配置跑 plan 快得多。
条件与循环
HCL 没有 if/else 语句和传统 for 循环,流程控制靠三样东西:三元表达式、count、for_each。
三元条件配 count:建与不建
count 是资源的元参数,控制这个资源创建几个实例。配合三元表达式 条件 ? 真值 : 假值,就是「建与不建」的开关:
variable "enable_debug_file" {
type = bool
default = false
}
resource "local_file" "debug" {
count = var.enable_debug_file ? 1 : 0
filename = "${path.module}/debug.txt"
content = "debug on"
}
注意开了 count 的资源变成实例列表,引用时要带下标:local_file.debug[0].filename。用 count = 0 关掉之后这个引用就不存在了,output 里要用 try(local_file.debug[0].filename, "") 之类的写法兜底。
for_each:按集合创建,推荐优先用
for_each 接收 map 或 set of string,每个元素创建一个实例,实例用键来寻址:
resource "local_file" "configs" {
for_each = {
app = "port=8080"
web = "port=80"
}
filename = "${path.module}/${each.key}.conf"
content = each.value
}
resource "random_pet" "pets" {
for_each = toset(["cat", "dog"])
length = 2
}
循环体内用 each.key 和 each.value 取当前项。map 的键、set 的元素值就是实例的地址后缀:local_file.configs["app"]、random_pet.pets["cat"]。
for_each vs count:索引漂移坑
同一个需求用 count 写,看起来差不多,但藏着坑:
variable "names" {
type = list(string)
default = ["a", "b", "c"]
}
resource "local_file" "by_count" {
count = length(var.names)
filename = "${path.module}/${var.names[count.index]}.txt"
content = var.names[count.index]
}
apply 之后 state 里是 by_count[0]=a、by_count[1]=b、by_count[2]=c。某天把列表中间的 "b" 删掉,"c" 从索引 2 挪到索引 1,Terraform 看到的计划就是:销毁 by_count[2],并修改 by_count[1] 的文件名和内容——c 对应的资源被推倒重建了。这就是索引漂移:count 实例的身份绑定在序号上,列表一变序,全员错位。for_each 的实例身份绑定在键上,删 "b" 只影响 ["b"] 这一个实例。
经验法则:能用 for_each 就不用 count,count 只留给「建与不建」的 0/1 开关。
for 表达式:列表与映射变换
资源之外的纯数据变换用 for 表达式,写在中括号(产出列表)或花括号(产出对象)里:
locals {
# 列表变换:全部转大写
upper_names = [for n in var.names : upper(n)]
# 生成 map:名字到名字长度的映射
name_length = { for n in var.names : n => length(n) }
# 带过滤条件:跳过 b
filtered = [for n in var.names : n if n != "b"]
}
templatefile 与模板
配置文件内容一复杂,全塞进 content 属性里没法看。把内容抽到独立的模板文件,用 templatefile 渲染:
resource "local_file" "nginx_conf" {
filename = "${path.module}/output/nginx.conf"
content = templatefile("${path.module}/templates/nginx.conf.tftpl", {
listen_port = var.listen_port
server_name = "demo.internal"
})
}
模板文件里是普通的文本,变量用插值语法嵌入,还支持模板内的 for 循环:
server {
listen ${listen_port};
server_name ${server_name};
}
%{ for upstream in upstreams ~}
upstream ${upstream.name} {
server ${upstream.addr};
}
%{ endfor ~}
%{ ... } 是模板里的控制指令,~ 用来吃掉多余空白行。模板语法细节查官方文档的 string template 一节,日常只记住插值和 for 两个就够用了。
常见坑
按踩坑频率排序:
-
变量未赋值导致 CI 卡死:必填变量没提供时 Terraform 会交互式提问等输入,本地敲一下回车就过去了,但在 CI 流水线里没有终端,任务会一直挂到超时。CI 里统一加
-input=false,让缺变量直接报错而不是等待:terraform plan -input=false -var-file="ci.tfvars" -
count 索引漂移:前面「for_each vs count」一节详细讲过,再强调一次:列表中间删元素会导致后续所有实例被计划重建,资源换成云主机就是事故。
-
map 的键顺序与书写无关:map 在 Terraform 内部按键名字母序存储和遍历,写配置时排列的顺序不会保留。遍历顺序有要求的场景(比如按顺序生成启动参数),必须用 list。
-
敏感变量打印泄漏:
sensitive只挡住 Terraform 自己的输出。如果把敏感值写进local_file的 content、或者传给local-exec命令行,明文照样落盘、照样进 shell 历史。另外记住:state 文件里敏感值始终是明文,state 的存储和访问权限要单独管好。
小结
- HCL 三件套:块、属性、表达式;
terraform fmt管格式,标识符统一小写下划线。 - 变量优先级记两端:默认值兜底,
-var/-var-file最大;CI 里加-input=false防卡死。 - locals 收内部复用的表达式,output 吐结果;敏感输出标
sensitive = true,但 state 仍是明文。 - 类型系统八种记速查表;函数不确定就开
terraform console试。 - 建与不建用三元条件配
count = 0/1,按集合批量建用 for_each;count 有索引漂移坑,能不用就不用。 - 复杂文件内容抽模板,
templatefile渲染,插值和控制指令只出现在模板文件里。