Terraform 代码的书写
Terraform 使用 HCL(HashiCorp Configuration Language)作为配置语言。HCL 融合了声明式语言的简洁和命令式语言的表达力,是 Terraform 的核心组成部分。
本章将系统介绍 HCL 的语法体系,涵盖以下内容:
目录
- 配置语法 — 块、参数、注释等基础语法元素
- 类型 — Terraform 的类型系统:基本类型与复合类型
- 表达式 — 引用、运算符、条件、循环等
- 输入变量 (variable) — 参数化配置,提高复用性
- 输出值 (output) — 导出资源属性供外部使用
- 局部值 (local) — 简化重复表达式
- 资源 (resource) — Terraform 的核心:声明基础设施组件
- 数据源 (data) — 查询已有资源或外部信息
- 临时资源 (ephemeral) — 不持久化的临时数据与凭据
- 重载文件 — override 文件的用法
- Checks — 自定义校验与断言
配置语法
HCL 的基本构建单元是块(Block)、参数(Argument)和注释(Comment)。
块(Block)、标签(Label)与参数(Argument)
块是 HCL 配置的基本结构单元。每个块由块类型、零个或多个标签和一个块体组成:
块类型 "标签1" "标签2" {
# 块体:包含参数和嵌套块
参数名 = 参数值
}不同的块类型有不同数量的标签,块体内通过 名称 = 值 的参数赋值语句来配置块的行为:
# terraform 块:无标签
terraform {
required_version = ">= 1.0"
}
# output 块:一个标签(输出名称)
output "greeting" {
value = "Hello!"
}
# resource 块:两个标签(资源类型、资源名称)
resource "aws_s3_bucket" "data" {
bucket = "my-bucket"
}标签和参数名都是标识符,遵循相同的命名规则——可以包含字母、数字、下划线(_)和连字符(-),但首字母不可以为数字:
# ✅ 合法
resource "aws_s3_bucket" "my_bucket" { }
output "server-info" { value = "ok" }
my_project = "app"
server01 = "web"
# ❌ 不合法
# resource "aws_s3_bucket" "1st_bucket" { } # 标签以数字开头
# 1st_project = "app" # 参数名以数字开头
# my project = "app" # 包含空格
# my.project = "app" # 包含点号参数值可以是不同类型:
locals {
project = "my-app" # 字符串
enabled = true # 布尔值
count = 3 # 数字
tags = { # Map
Environment = "dev"
Team = "platform"
}
}对齐风格
Terraform 社区推荐使用 terraform fmt 命令自动格式化代码,它会对齐同一块内的等号。
注释(Comment)
# 单行注释(推荐风格)
// 单行注释(C 风格,不推荐)
/*
多行注释
可以跨越多行
*/字符串与 Heredoc
普通字符串使用双引号:
name = "Hello, Terraform!"字符串插值使用 ${} 引用表达式:
variable "name" {
default = "World"
}
locals {
project = "my-app"
environment = "dev"
}
greeting = "Hello, ${var.name}!"
# => "Hello, World!"
full_name = "${local.project}-${local.environment}"
# => "my-app-dev"Heredoc 语法用于多行字符串:
# <<EOF 保留原始缩进
config = <<EOF
server {
listen 80;
server_name example.com;
}
EOF
# <<-EOF 去除公共前导空格(推荐)
config = <<-EOF
server {
listen 80;
server_name example.com;
}
EOF🧪 动手实验
类型
Terraform 中每个值都有类型,类型决定了值可以在哪里使用以及可以对它应用哪些操作。Terraform 的类型分为原始类型、复杂类型(集合类型 + 结构化类型),以及特殊的 any 和 null。
原始类型
原始类型有三种:
| 类型 | 描述 | 示例 |
|---|---|---|
string | Unicode 字符串 | "hello" |
number | 数字(整数或小数) | 42、3.14 |
bool | 布尔值 | true、false |
number 和 bool 都可以与 string 进行隐式类型转换:
# number ↔ string
# "42" 可以自动转换为 42,反之亦然
# "3.14" ↔ 3.14
# bool ↔ string
# "true" ↔ true
# "false" ↔ false这意味着将 "42" 赋给 number 类型的变量不会报错,Terraform 会自动完成转换。
集合类型
集合类型包含一组同一类型的值。Terraform 支持三种集合类型:
list(...)— 有序序列,可用下标访问(从0开始)hclvariable "zones" { type = list(string) default = ["us-east-1a", "us-east-1b", "us-east-1c"] } # var.zones[0] => "us-east-1a"map(...)— 键值对集合,键必须是stringhclvariable "tags" { type = map(string) default = { Environment = "dev" Team = "platform" } } # var.tags["Environment"] => "dev"map 的两种写法
map值可以用{"foo": "bar"}或{foo = "bar"}两种语法。推荐使用=号——terraform fmt会自动对齐等号,代码更整洁。set(...)— 无序、不重复的集合,不能用下标访问hclvariable "cidrs" { type = set(string) default = ["10.0.0.0/8", "172.16.0.0/12"] } # contains(var.cidrs, "10.0.0.0/8") => true
集合类型支持通配类型缩写:list 等价于 list(any),map 等价于 map(any),set 等价于 set(any)。any 要求所有元素是同一类型——赋值时 Terraform 会自动推断并在必要时进行隐式转换。
"同一类型"比你想象的更严格
string、number、bool 之间可以隐式互转,所以 ["hello", 42, true] 能赋给 list(any)(全部转为 string)。但一旦混入无法互转的类型,就会报错:
# ❌ 报错!string 和 list 无法转换为同一类型
locals {
bad_list = tolist(["hello", ["a", "b"]])
}
# Error: Invalid value for "v" parameter: cannot convert tuple to list
# of any single type
# ❌ 报错!string 和 object 不是同一类型
variable "bad_map" {
type = map(any)
default = {
name = "alice"
config = { port = 8080 }
}
}
# Error: all map elements must have the same type这种情况在 map 中尤其容易踩坑——看起来像一个普通 map,但值的类型不一致就会失败。如果你需要每个键有不同类型的值,应该用 object 而不是 map。
结构化类型
结构化类型允许多个不同类型的值组成一个复合类型:
object({...})— 由命名属性组成,每个属性有独立类型hclvariable "server" { type = object({ name = string port = number active = bool }) default = { name = "web-01" port = 8080 active = true } } # var.server.name => "web-01"赋给
object的值必须包含所有必填属性,但多余的属性会被丢弃。tuple([...])— 定长序列,每个位置有独立类型hclvariable "record" { type = tuple([string, number, bool]) default = ["hello", 42, true] } # var.record[0] => "hello" # var.record[1] => 42
object vs map,tuple vs list
map的所有值类型相同,object的每个属性可以是不同类型list的所有元素类型相同,tuple的每个位置可以是不同类型object↔map、tuple↔list之间在满足条件时可以隐式转换
object 的 optional 成员
自 Terraform 1.3 起,object 中可以使用 optional 声明可选属性:
variable "config" {
type = object({
name = string # 必填
port = optional(number, 8080) # 可选,默认 8080
debug = optional(bool, false) # 可选,默认 false
})
}optional 有两个参数:
- 类型(必填)— 属性的类型
- 默认值(选填)— 省略该属性时使用的值。未指定默认值时,默认为
null
这对于包含大量属性的 object 尤其有用——用户只需提供关心的属性,其余自动获得合理的默认值:
variable "database" {
type = object({
engine = string
version = optional(string, "14")
port = optional(number, 5432)
config = optional(object({
max_connections = optional(number, 100)
ssl_enabled = optional(bool, true)
}), {})
})
}
# 只需提供必填属性
# database = { engine = "postgresql" }
# 其他属性自动获得默认值:version="14", port=5432, config.max_connections=100, ...any 与 null
any— 类型占位符,不是真正的类型。Terraform 会根据赋值推断实际类型。声明type = any表示接受任意类型。null— 表示数据缺失,无类型。将参数设为null时,如果有默认值则使用默认值,如果是必填参数则报错。null在条件表达式中很有用:hcl# 条件不满足时跳过赋值(使用默认值) error_document = var.legacy ? "ERROR.HTM" : null
显式类型转换 (convert,Terraform 1.15+)
Terraform 大部分时候能从上下文自动推断类型,但在以下场景会出问题:
- 条件表达式两端的推断类型不同(例如
var.flag ? [] : ["a"],[]是 tuple 而["a"]是 list of string)。 - 需要构造空集合字面量:
[]是空 tuple、{}是空 object,它们都没有元素类型,无法直接当作list(string)、map(number)使用。 - 相等比较
==不会做隐式类型转换,类型不一致会一直为false。
Terraform 1.15 引入了内置 convert 函数,可以内联地把表达式强制转换为目标类型:
locals {
# 构造一个真正的空 list(string),而不是空 tuple
empty_tags = convert([], list(string))
# 构造一个真正的空 map(number)
empty_quotas = convert({}, map(number))
# 修正条件表达式两端类型不一致
subnets = convert(
var.enable_private ? var.private_subnets : [],
list(string),
)
}convert(value, type_constraint) 的第二个参数是类型约束表达式(和 variable 的 type 写法完全一致),可以是 string / number / bool / list(...) / set(...) / map(...) / object({...}) / tuple([...])。
TIP
之前常见的 tolist() / tomap() / toset() / tostring() / tonumber() 仍然可用,但它们一次只能转一种集合,且不能表达 list(object({...})) 这类复合约束。convert 是更通用的替代方案。
🧪 动手实验
表达式
表达式是 Terraform 配置中用于计算值的核心机制。前面我们已经用到了字符串字面量、变量引用等简单表达式,本节将系统介绍 Terraform 支持的各种表达式。
引用
在 Terraform 中,可以通过以下方式引用各种命名对象的值:
| 引用形式 | 含义 |
|---|---|
var.<NAME> | 输入变量 |
local.<NAME> | 局部值 |
resource_type.name | 资源属性 |
data.data_type.name | 数据源属性 |
module.<NAME> | 模块输出 |
self | 在 provisioner/connection 中引用当前资源 |
terraform.workspace | 当前工作区名称 |
path.module | 当前模块的文件系统路径 |
path.root | 根模块的文件系统路径 |
path.cwd | 当前工作目录的文件系统路径 |
locals {
project = "demo"
}
output "info" {
value = "项目: ${local.project}, 工作区: ${terraform.workspace}"
}算术与逻辑运算符
运算符要么是把两个值计算为第三个值(二元操作符),要么是把一个值转换为另一个值(一元操作符)。
当一个表达式中含有多个运算符时,它们的优先级顺序为:
!,-(负号)*,/,%+,-(减号)>,>=,<,<===,!=&&||
可以使用小括号覆盖默认优先级,例如 1+2*3 会被计算为 7,而 (1+2)*3 为 9。
算术运算符
a + b:返回 a 与 b 的和a - b:返回 a 与 b 的差a * b:返回 a 与 b 的积a / b:返回 a 与 b 的商a % b:返回 a 除以 b 的余数(取模),一般仅在两者为整数时有效-a:返回 a 的相反数
locals {
sum = 3 + 4 # 7
remainder = 10 % 3 # 1
negative = -(5) # -5
}相等性运算符
a == b:如果 a 与 b 类型与值都相等返回true,否则返回falsea != b:与==相反
比较运算符
a < b:如果 a 比 b 小则为truea > b:如果 a 比 b 大则为truea <= b:如果 a 小于等于 b 则为truea >= b:如果 a 大于等于 b 则为true
逻辑运算符
a || b:a 或 b 中有至少一个为true则为truea && b:a 与 b 都为true则为true!a:如果 a 为true则为false,反之亦然
locals {
is_prod = true
has_budget = false
should_warn = local.is_prod && !local.has_budget # true
}条件表达式
条件表达式根据布尔条件在两个值中选择一个:
condition ? true_val : false_val如果 condition 为 true,结果是 true_val,否则为 false_val。两个候选值的类型必须相同。
variable "environment" {
type = string
default = "dev"
}
locals {
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
}一个常见用法是用默认值替代非法值:
# 如果 var.name 为空字符串,使用默认值
name = var.name != "" ? var.name : "default-name"TIP
上面的表达式推荐写为:coalesce(var.name, "default-name")
条件表达式与 null 结合可以实现"可选赋值"——条件不满足时跳过赋值,使用资源属性的默认值:
error_document = var.legacy ? "ERROR.HTM" : null函数调用
Terraform 支持在表达式中使用内建函数。通用语法是:
函数名(参数1, 参数2, ...)例如 min 函数接收任意多个数值参数,返回最小值:
min(55, 3453, 2) # => 2展开函数入参
如果想把列表或元组的元素作为参数传递给函数,可以使用展开符 ...:
min([55, 2453, 2]...) # => 2展开符由三个独立的 . 组成,只能用在函数调用场景中。
常用内建函数
Terraform 提供了丰富的内建函数,按类别包括:
- 字符串:
upper、lower、format、join、split、trimspace、replace、regex - 集合:
length、contains、merge、flatten、keys、values、lookup、element、concat、distinct、sort - 数值:
min、max、abs、ceil、floor、log、pow - 类型转换:
tostring、tonumber、tobool、tolist、toset、tomap - 编码:
jsonencode、jsondecode、yamlencode、base64encode - 文件:
file、fileexists、templatefile、basename、dirname - 日期:
timestamp、formatdate、timeadd - 逻辑:
can、try、coalesce、coalescelist
完整列表参见 Terraform 函数文档。
字符串模板
字符串模板允许在字符串中嵌入表达式或通过循环动态构造字符串。
插值 (Interpolation)
${...} 序列计算花括号之间的表达式的值,将结果转换为字符串后插入模板:
"Hello, ${var.name}!"
# var.name = "Juan" => "Hello, Juan!"指令 (Directive)
%{...} 序列用于在字符串内部进行条件判断或遍历。
if/else/endif 指令根据布尔表达式选择模板:
"Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"for/endfor 指令遍历集合,用每个元素渲染模板,然后拼接起来:
<<-EOT
%{ for ip in var.ip_list ~}
server ${ip}
%{ endfor ~}
EOT去除空白的 ~ 符号
所有模板序列的首尾都可以添加 ~ 符号。~ 会去除模板序列相邻一侧的空白(空格和换行),常与 Heredoc 配合使用以控制输出格式。
如果需要输出字面量 ${ 或 %{,重复第一个字符即可:$${ 和 %%{。
for 表达式
for 表达式将一种复合类型映射成另一种。输入类型中的每个元素都会被映射为一个或零个结果。
输出元组——用方括号包裹:
[for s in var.list : upper(s)]
# var.list = ["hello", "world"] => ["HELLO", "WORLD"]输出对象——用花括号包裹,使用 => 分隔键值:
{for s in var.list : s => upper(s)}
# var.list = ["hello"] => { "hello" = "HELLO" }带过滤的 for——添加 if 子句过滤元素:
[for s in var.list : upper(s) if s != ""]遍历 map/object——迭代器表示为两个临时变量(键和值):
[for k, v in var.map : "${k}=${v}"]分组 (group by)——在输出对象时使用 ... 将同键的值聚合为列表:
{for s in var.list : substr(s, 0, 1) => s...}
# ["apple", "avocado", "banana"]
# => { "a" = ["apple", "avocado"], "b" = ["banana"] }Splat 表达式
Splat 表达式提供了一种简洁的方式来提取列表中所有元素的某个属性,等价于特定模式的 for 表达式。
假设 var.list 包含一组对象,每个对象有 id 属性,以下两种写法等价:
# for 表达式
[for o in var.list : o.id]
# splat 表达式
var.list[*].id[*] 符号迭代列表中每一个元素,并返回它们在 . 右边的属性值。
如果 splat 表达式被用于一个既不是列表又不是元组的值,该值会被自动包装成单元素列表。这在访问可能带有 count 参数的资源时很有用:
# 不论 aws_instance.example 是否定义了 count,都能正确返回 id 列表
aws_instance.example[*].id避免旧式 splat 语法
Terraform 有一种使用 .* 的旧式 splat 语法。对于简单的单层属性访问(如 var.list.*.id),旧语法和新语法 var.list[*].id 结果相同。但在链式访问嵌套属性时,两者行为不同:
variable "servers" {
default = [
{ interfaces = [{ ip = "10.0.0.1" }, { ip = "10.0.0.2" }] },
{ interfaces = [{ ip = "10.0.1.1" }, { ip = "10.0.1.2" }] },
]
}
# 新语法 [*]:对每个元素完整求值 interfaces[0].ip
var.servers[*].interfaces[0].ip
# 等价于 [for s in var.servers : s.interfaces[0].ip]
# => ["10.0.0.1", "10.0.1.1"] ← 每个 server 的第一个接口 IP
# 旧语法 .*:[0] 跳出 splat,作用在 splat 的结果列表上
var.servers.*.interfaces[0]
# 等价于 [for s in var.servers : s.interfaces][0]
# => [{ ip = "10.0.0.1" }, { ip = "10.0.0.2" }] ← 第一个 server 的所有接口
var.servers.*.interfaces[0].ip
# ❌ Error: Unsupported attribute
# [0] 返回的是一个列表,无法再 .ip简单来说:[*] 把后续的 . 和 [] 都应用到每个元素上,而 .* 只 splat . 属性访问,[] 下标会跳出 splat 作用在结果列表上,导致后续链式访问出错。始终使用 [*] 即可。
🧪 动手实验
输入变量 (variable)
输入变量是 Terraform 配置的参数化机制。把一组 Terraform 代码想象成一个函数,输入变量就是函数的入参——通过变量,我们可以让同一份代码在不同场景下创建不同的基础设施。
variable 块
输入变量用 variable 块定义,紧跟关键字的标签是变量名:
variable "image_id" {
type = string
description = "机器镜像 ID"
}
variable "docker_ports" {
type = list(object({
internal = number
external = number
protocol = string
}))
default = [
{
internal = 8300
external = 8300
protocol = "tcp"
}
]
}在代码中通过 var.<NAME> 引用变量值:
resource "aws_instance" "web" {
ami = var.image_id
}在同一个模块(同一目录下的所有 .tf 文件)中,变量名必须唯一。以下关键字不可以作为变量名:source、version、providers、count、for_each、lifecycle、depends_on、locals。
类型约束 (type)
通过 type 参数限制变量接受的值的类型:
variable "name" {
type = string
}
variable "ports" {
type = list(number)
}
variable "server" {
type = object({
name = string
port = number
})
}关于各种类型的详细说明,参见前文类型一节。
默认值 (default)
default 定义变量在未被赋值时使用的默认值:
variable "region" {
type = string
default = "us-east-1"
}没有默认值的变量在执行时必须被赋值,否则 Terraform 会在交互界面提示输入。
描述 (description)
description 向调用者说明变量的用途。当 Terraform 需要在命令行提示输入时,会显示这段描述:
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."
}$ terraform apply
var.image_id
The id of the machine image (AMI) to use for the server.
Enter a value:TIP
描述应站在使用者的角度编写,而非维护者。描述不是代码注释——它是面向模块调用者的 API 文档。
断言 (validation)
validation 块允许对输入值进行自定义校验。condition 表达式为 true 时合法,为 false 时 Terraform 返回 error_message 中的错误信息:
variable "instance_count" {
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "instance_count 必须在 1 到 10 之间。"
}
}结合 can 函数可以捕获表达式执行中的错误,常用于正则校验:
variable "image_id" {
type = string
validation {
condition = can(regex("^ami-", var.image_id))
error_message = "image_id 必须以 \"ami-\" 开头。"
}
}一个变量可以有多个 validation 块,所有校验都必须通过:
variable "bucket_name" {
type = string
validation {
condition = length(var.bucket_name) >= 3 && length(var.bucket_name) <= 63
error_message = "长度必须在 3-63 个字符之间。"
}
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.bucket_name))
error_message = "只能包含小写字母、数字、点和连字符,且必须以字母或数字开头和结尾。"
}
}跨变量引用
自 Terraform v1.9 起,condition 中可以引用其他变量,不再局限于当前变量自身。这使得跨变量的联合校验成为可能:
variable "min_count" {
type = number
default = 1
}
variable "max_count" {
type = number
default = 10
validation {
condition = var.max_count >= var.min_count
error_message = "max_count(${var.max_count})不能小于 min_count(${var.min_count})。"
}
}注意循环引用
跨变量校验时,如果两个变量的 validation 互相引用对方,会形成循环依赖,Terraform 将报错。确保引用关系是单向的:
# ✅ 单向引用:max_count 的校验引用 min_count
variable "min_count" { type = number }
variable "max_count" {
type = number
validation {
condition = var.max_count >= var.min_count
error_message = "max_count 不能小于 min_count。"
}
}
# ❌ 循环引用:两者互相引用,会报错
# variable "a" {
# validation { condition = var.a < var.b ... }
# }
# variable "b" {
# validation { condition = var.b > var.a ... }
# }敏感值 (sensitive)
将 sensitive 设为 true 后,Terraform 在 plan 和 apply 输出中会用 (sensitive value) 隐藏该变量的值:
variable "db_password" {
type = string
sensitive = true
} + resource "aws_db_instance" "main" {
+ password = (sensitive value)
}WARNING
sensitive 只影响命令行输出。Terraform 仍然会将敏感数据以明文记录在状态文件中——任何能访问状态文件的人都能读取这些值。
禁止为空 (nullable)
nullable(默认 true)控制变量是否接受 null 值:
variable "region" {
type = string
default = "us-east-1"
nullable = false
}当 nullable = false 时,即使调用者显式传入 null,Terraform 也会使用默认值,确保变量在模块内永远不为空。
临时变量 (ephemeral)
自 Terraform v1.10 起,可以将变量标记为 ephemeral。临时变量的值在当前 Terraform 运行期间可用,但不会被记录到状态文件和计划文件中:
variable "session_token" {
type = string
ephemeral = true
}这对于短生命周期的数据特别有用,例如临时令牌、会话标识符等——它们只需在执行期间存在,不应被持久化。
ephemeral 与 sensitive 的区别
| 特性 | sensitive | ephemeral |
|---|---|---|
| plan/apply 输出中隐藏 | ✅ | ✅ |
| 状态文件中记录 | ✅(明文) | ❌ |
| 计划文件中记录 | ✅ | ❌ |
| 运行结束后可读取 | ✅(从状态文件) | ❌ |
简单来说:sensitive 只是"遮住眼睛",数据仍然存在于状态文件中;ephemeral 则彻底不持久化,运行结束后数据消失。
引用限制
临时变量只能在以下上下文中被引用,否则 Terraform 会报错:
- 另一个临时变量
local表达式- 临时输出值(
ephemeral = true的output) provider块中的参数provisioner和connection块precondition和postcondition中的condition
variable "api_token" {
type = string
ephemeral = true
}
# ✅ 可以在 local 中引用
locals {
auth_header = "Bearer ${var.api_token}"
}
# ✅ 可以在 provider 中引用
provider "http" {
token = var.api_token
}
# ❌ 不能直接赋给普通资源属性(会被写入状态文件)
# resource "some_resource" "example" {
# token = var.api_token # Error!
# }常量变量 (const,Terraform 1.15+)
module 块的 source 和 version 默认只能写字面量字符串,因为它们在 terraform init 阶段就要被解析。Terraform 1.15 新增 const = true 属性,标记该变量在 init 阶段就必须确定值,从而允许它出现在 source / version 表达式中:
variable "module_folder" {
type = string
const = true
}
module "zoo" {
source = "./${var.module_folder}"
}const与sensitive、ephemeral互斥——一个变量只能选其一。- 嵌套模块要在自己
source中引用var.xxx,对应 variable 同样必须const = true。 - 详细用法见 Terraform 模块 → 动态模块来源。
弃用变量 (deprecated,Terraform 1.15+)
模块演进时,某个变量可能要逐步下线。Terraform 1.15 为 variable 新增了 deprecated 属性,只要外部还在给这个变量赋值,validate / plan 就会发出警告诊断:
variable "bad" {
type = string
default = null
deprecated = "请改用 good 变量,bad 将在 v2.0 移除。"
}触发警告的情况:
- 调用方在
module块中给该 variable 传值。 - 根模块的 deprecated variable 通过 CLI
-var、tfvars、TF_VAR_*环境变量、Terraform Cloud workspace variable 等任何方式被赋值。
如果完全没人传值,则不会有警告——这给了下游一个观察期来清理用法。
对输入变量赋值
有四种方式为变量赋值:
1. 命令行参数 (-var)
terraform apply -var="image_id=ami-abc123"
terraform apply -var='tags={"env":"prod","team":"platform"}'2. 参数文件 (.tfvars)
# prod.tfvars
image_id = "ami-abc123"
availability_zones = ["us-east-1a", "us-east-1b"]terraform apply -var-file="prod.tfvars"名为 terraform.tfvars 或 *.auto.tfvars 的文件会被自动加载,无需 -var-file。
3. 环境变量 (TF_VAR_)
export TF_VAR_image_id=ami-abc123
terraform plan环境变量特别适合在 CI/CD 流水线中传递敏感数据。
4. 交互式输入
当以上方式都未提供值且变量没有默认值时,Terraform 会在终端提示输入。
赋值优先级
当多种方式同时为同一变量赋值时,后者覆盖前者(优先级从低到高):
- 环境变量
terraform.tfvarsterraform.tfvars.json*.auto.tfvars/*.auto.tfvars.json(按文件名字母序)-var和-var-file命令行参数(按出现顺序)
🧪 动手实验
输出值 (output)
我们在介绍输入变量时提到过,如果我们把一组 Terraform 代码想象成一个函数,那么输入变量就是函数的入参;函数可以有入参,也可以有返回值——输出值就是 Terraform 代码的返回值。
与大部分语言的函数只支持单返回值不同,Terraform 支持多返回值。apply 成功后,命令行会输出所有定义的输出值。也可以随时通过 terraform output 命令查看当前状态文件中的输出值。
output 块
输出值使用 output 块定义,紧跟关键字的标签是输出名称:
output "instance_ip_addr" {
value = aws_instance.server.private_ip
}value 参数是必填的,可以是任意合法的表达式——资源属性、变量引用、函数调用等。在同一个模块内,所有输出值的名称必须唯一。
INFO
Terraform 代码中无法引用本模块内定义的输出值——输出值是给模块调用者或命令行使用的。
描述 (description)
与输入变量一样,description 向调用者说明输出值的含义:
output "instance_ip_addr" {
value = aws_instance.server.private_ip
description = "The private IP address of the main server instance."
}TIP
描述应站在使用者的角度编写——它是面向模块调用者的 API 文档。
断言 (precondition)
自 Terraform v1.2.0 起,output 块支持 precondition 块。与 variable 的 validation 类似,precondition 确保输出值满足某种约束。Terraform 在计算 value 表达式之前执行 precondition 检查,可以防止不合法的值被写入状态文件:
output "api_endpoint" {
value = "https://${aws_instance.web.public_ip}:8443/"
precondition {
condition = aws_instance.web.public_ip != ""
error_message = "Web 实例没有分配公网 IP 地址。"
}
}在命令行输出中隐藏值 (sensitive)
将 sensitive 设为 true 后,terraform apply 成功后会打印 <sensitive> 代替真实值;terraform output 列出所有输出时也会显示 <sensitive>,但指定输出名称(如 terraform output db_password)或使用 terraform output -json 时仍可看到实际值:
output "db_password" {
value = aws_db_instance.main.password
sensitive = true
}Outputs:
db_password = <sensitive>WARNING
sensitive 只影响命令行输出。输出值仍然会以明文记录在状态文件中——任何有权限读取状态文件的人都能读取敏感数据。
临时值 (ephemeral)
自 Terraform v1.10 起,可以在子模块中将 output 标记为 ephemeral。临时输出的值在 plan 和 apply 期间可用,但不会被记录到状态文件和计划文件中,适合传递凭据、令牌等短生命周期数据:
# modules/db/main.tf
output "secret_id" {
value = aws_secretsmanager_secret.example.id
description = "Temporary secret ID for accessing database."
ephemeral = true
}临时输出只能在以下上下文中被引用:
- 另一个临时输出值
- 临时输入变量
- 临时资源(ephemeral resource)
WARNING
根模块中不可以将 output 声明为 ephemeral。
弃用输出 (deprecated,Terraform 1.15+)
与 variable 对应,output 也支持 deprecated,用于通知调用方某个输出即将移除:
# 子模块
output "old" {
value = local.legacy_value
deprecated = "请改用 new 输出,old 将在 v2.0 移除。"
}- 当调用方写
module.mymod.old时,terraform validate/plan会发出警告。 - 在另一个本身已被
deprecated的 output 中再引用module.mymod.old,不会再叠加警告——这允许模块作者一层层地把弃用值传出去。 - 根模块的
output不能标记deprecated——根模块输出没有下一级调用者,Terraform 会直接报错。
depends_on
一般来说,Terraform 会自动分析资源之间的依赖关系来决定创建顺序。但有时某些依赖关系无法通过代码分析得出,这时可以通过 depends_on 显式声明:
output "instance_ip_addr" {
value = aws_instance.server.private_ip
description = "The private IP address of the main server instance."
depends_on = [
# Security group rule must be created before this IP address could
# actually be used, otherwise the services will be unreachable.
aws_security_group_rule.local_access,
]
}WARNING
output 很少需要 depends_on,它只应作为最后的手段使用。如果不得不使用,请务必通过注释说明原因,方便后人维护。
🧪 动手实验
局部值 (local)
我们在介绍输入变量时提到过,如果我们把一组 Terraform 代码想象成一个函数,那么输入变量就是函数的入参;函数可以有入参,也可以有返回值——输出值就是 Terraform 代码的返回值。那么局部值就相当于函数内部定义的局部变量。
有时我们需要用一个比较复杂的表达式计算某个值,并且在多处反复使用。这时可以把这个复杂表达式赋予一个局部值,然后反复引用该局部值——避免重复,也让代码更易读、更易维护。
locals 块
局部值通过 locals 块定义:
locals {
project = "my-app"
environment = "dev"
}一个 locals 块可以定义多个局部值。一个模块中也可以定义任意多个 locals 块——你可以按逻辑将局部值分组到不同的 locals 块中:
# 项目信息
locals {
project = "my-app"
environment = "dev"
}
# 计算值
locals {
full_name = "${local.project}-${local.environment}"
is_prod = local.environment == "prod"
}引用局部值
在代码中通过 local.<NAME> 引用局部值:
locals {
project = "my-app"
}
output "project_name" {
value = local.project
}注意 locals vs local
定义时使用 locals(复数),引用时使用 local(单数)。这是一个常见的混淆点——定义块是 locals {},但引用表达式是 local.<NAME>。
局部值只能在同一模块内的代码中引用。
表达式计算
赋给局部值的不仅可以是简单的字面量,还可以是更复杂的表达式——引用其他资源的属性、输入变量、数据源,甚至是其他的局部值:
variable "project" {
type = string
default = "demo"
}
variable "environment" {
type = string
default = "dev"
}
locals {
# 引用输入变量
full_name = "${var.project}-${var.environment}"
# 引用其他局部值
is_prod = var.environment == "prod"
log_level = local.is_prod ? "warn" : "debug"
# 使用函数和复杂表达式
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}使用场景
局部值最适合在以下场景中使用:
1. 避免重复复杂表达式
当同一个表达式在多个地方使用时,用局部值提取它:
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "data" {
bucket = "data-bucket"
tags = local.common_tags
}
resource "aws_s3_bucket" "logs" {
bucket = "logs-bucket"
tags = local.common_tags
}2. 给复杂表达式起一个有意义的名字
局部值可以充当"命名表达式",提高可读性:
locals {
is_production = var.environment == "prod"
needs_encryption = local.is_production || var.force_encryption
instance_type = local.is_production ? "m5.large" : "t3.micro"
}3. 预处理输入数据
variable "raw_cidrs" {
type = list(string)
default = ["10.0.0.0/8", " 172.16.0.0/12 ", "192.168.0.0/16"]
}
locals {
# 去除每个 CIDR 的前后空格
clean_cidrs = [for cidr in var.raw_cidrs : trimspace(cidr)]
}适度使用
局部值可以帮助我们避免重复复杂的表达式,提升代码的可读性。但如果过度使用也有可能增加代码的复杂度——使得维护者需要在多个 locals 块之间跳转才能理解一个值的来源。
适度使用局部值,仅用于反复引用同一复杂表达式的场景。当我们需要修改该表达式时,局部值将使得修改变得相当轻松。
临时局部值 (ephemeral)
自 Terraform v1.10 起,如果局部值的表达式中引用了临时值(如 ephemeral = true 的输入变量),则该局部值会隐式地成为临时值:
variable "service_token" {
type = string
ephemeral = true
}
locals {
session_token = "Bearer ${var.service_token}"
}local.session_token 隐式成为临时值,因为它依赖于临时输入变量 var.service_token。临时局部值不会被记录到状态文件和计划文件中,与其他临时值有相同的引用限制。
🧪 动手实验
资源 (resource)
资源是 Terraform 最重要的组成部分。资源通过 resource 块来定义,一个 resource 可以定义一个或多个基础设施资源对象,例如 VPC、虚拟机,或是 DNS 记录、S3 存储桶等。
resource 块
资源通过 resource 块定义。紧跟 resource 关键字的第一个标签是资源类型,第二个标签是资源的本地名称(Local Name):
resource "aws_instance" "web" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
}- 资源类型(如
aws_instance)决定了被管理的基础设施对象的种类,以及该资源支持哪些参数和输出属性。资源类型名的第一个单词(下划线前)通常对应 Provider 名称——aws_instance由awsProvider 提供。 - 本地名称(如
web)用于在同一模块内引用该资源。类型 + 本地名称的组合在模块内必须唯一。 - 花括号内的块体包含资源的参数赋值,不同资源类型有不同的可用参数,可查阅 Provider 文档了解。
声明式语言
Terraform 是声明式语言——描述的是期望的资源状态,而不是达到该状态的步骤。块的顺序和所在文件通常不重要,Terraform 根据资源间的依赖关系自动决定操作顺序。
资源的行为
对 Terraform 代码执行 terraform apply 时,Terraform 会:
- 创建(
+)代码中定义但状态文件中不存在的资源 - 更新(
~)状态文件中已有但与代码定义不一致的资源 - 替换(
-/+或+/-)某些属性变更导致资源必须重建 - 销毁(
-)状态文件中存在但代码中已删除的资源
每当 Terraform 创建一个新资源,该资源的 ID 会被保存到状态文件中,使得后续可以对它进行更新或销毁。
就地更新 vs 替换(ForceNew)
当修改资源的某个参数时,Terraform 会根据该参数的性质决定操作方式:
- 就地更新(update in-place,
~)— 大部分参数可以在不销毁资源的情况下直接修改。例如修改 S3 桶的标签、修改 SQS 队列的visibility_timeout_seconds等。 - 替换(replacement,
-/+)— 某些参数被 Provider 标记为 ForceNew,意味着该参数的变更无法通过 API 就地修改,只能销毁旧资源、创建新资源。例如 SQS 队列的name、EC2 实例的ami、S3 桶的bucket名称等。
ForceNew 是由底层云 API 的限制决定的——有些属性在资源创建后就不可变(immutable),想要改变只能重新创建。Provider 文档中通常会标注哪些参数是 ForceNew 的。
terraform plan 中会用 # forces replacement 注释标明是哪个参数触发了替换:
-/+ resource "aws_sqs_queue" "example" {
~ name = "old-name" -> "new-name" # forces replacement
}-/+ 与 +/-
替换操作有两种顺序:
-/+(先销毁后创建)— 默认行为。Terraform 先销毁旧资源,再创建新资源。两步之间资源短暂不可用。+/-(先创建后销毁)— 当资源配置了create_before_destroy = true时,Terraform 先创建新资源,确认成功后再销毁旧资源,可减少服务中断时间,或是某些场景下如果没有新资源替换,旧资源无法删除的场景。
terraform plan 的输出开头会同时列出两种符号的图例:
-/+ destroy and then create replacement
+/- create replacement and then destroy访问资源属性
资源创建后会输出一些只读属性(Attribute),通常包含创建前无法预知的数据(如资源 ID、ARN 等)。在同一模块内通过 <资源类型>.<名称>.<属性> 引用:
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
}
# 引用 S3 桶的 ARN 属性
output "bucket_arn" {
value = aws_s3_bucket.data.arn
}
# 在其他资源中引用
resource "aws_s3_object" "readme" {
bucket = aws_s3_bucket.data.id
key = "readme.txt"
content = "Hello from Terraform!"
}TIP
要了解某个资源类型的所有可用属性,查阅对应 Provider 的文档。文档中通常会分别列出参数(Argument)和属性(Attribute)。
资源的依赖关系
大部分情况下,Terraform 会通过分析表达式中的引用链自动推导资源间的依赖关系。例如上面的 aws_s3_object.readme 引用了 aws_s3_bucket.data.id,Terraform 会自动确保先创建桶再创建对象。
但有时依赖关系无法从代码中推导出来——例如某个资源运行时需要另一个权限资源已经存在,但代码中并没有直接引用。这种场景需要用 depends_on 显式声明(见下文元参数部分)。
元参数 (Meta-Arguments)
resource 块支持一组特殊的元参数,它们可以用在所有资源类型上,改变资源的行为:
| 元参数 | 用途 |
|---|---|
depends_on | 显式声明依赖关系 |
count | 创建多个资源实例 |
for_each | 迭代集合,为每个元素创建资源实例 |
provider | 指定非默认 Provider 实例 |
lifecycle | 自定义资源的生命周期行为 |
depends_on
depends_on 用于显式声明 Terraform 无法自动推导的隐含依赖关系:
resource "aws_iam_role" "example" {
name = "example"
assume_role_policy = "..."
}
resource "aws_iam_role_policy" "s3_access" {
name = "s3-access"
role = aws_iam_role.example.name
policy = jsonencode({
Statement = [{
Action = "s3:*"
Effect = "Allow"
}]
})
}
resource "aws_instance" "app" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
# 代码中没有直接引用 s3_access,但运行时需要该权限,depends_on 可以确保只有当 s3 的权限配置完成后才会尝试创建虚拟机
depends_on = [
aws_iam_role_policy.s3_access,
]
}WARNING
depends_on 只应作为最后的手段使用。如果不得不使用,请通过注释说明原因,方便后人维护。
count
count 参数用于从单个 resource 块创建多个相似的资源实例:
resource "aws_s3_bucket" "logs" {
count = 3
bucket = "app-logs-${count.index}"
}count.index 是当前实例的索引号(从 0 开始)。声明了 count 的资源通过下标访问:
# 访问第一个桶
output "first_bucket" {
value = aws_s3_bucket.logs[0].id
}
# 获取所有桶的 ID
output "all_bucket_ids" {
value = aws_s3_bucket.logs[*].id
}count 的陷阱
count 使用数字索引标识资源。如果从列表中间删除一个元素,后续所有资源的索引都会移位,导致大量不必要的更新或重建。对于这类场景,推荐使用 for_each。
for_each
for_each 参数接受一个 map 或 set(string),为集合中的每个元素创建一个资源实例:
# 使用 map
resource "aws_s3_bucket" "this" {
for_each = {
data = "my-data-bucket"
logs = "my-logs-bucket"
}
bucket = each.value
tags = {
Name = each.key
}
}在 for_each 块内通过 each 对象访问当前元素:
each.key— map 的键,或 set 中的值each.value— map 的值,或 set 中的值
声明了 for_each 的资源通过键访问:
output "data_bucket_id" {
value = aws_s3_bucket.this["data"].id
}使用 set(string) 时,需要用 toset 转换:
resource "aws_sqs_queue" "this" {
for_each = toset(["orders", "notifications", "events"])
name = "${each.key}-queue"
}count 与 for_each 的选择
- 资源实例几乎完全一致(只差一个序号)→
count - 资源实例各有差异,或需要稳定标识 →
for_each
for_each 使用键而非数字索引标识资源,删除集合中的某个元素只会影响对应的那一个资源实例,不会引起其他实例的变更。
count 和 for_each 不可同时使用
一个 resource 块中不允许同时声明 count 和 for_each。
count 与 for_each 的值必须在 plan 阶段已知
count 的数值和 for_each 的集合必须在 Terraform 执行计划(plan)阶段就能确定,不能依赖于其他资源的输出属性——因为这些属性只有在 apply 阶段资源实际创建后才知道。
# ❌ 报错!count 依赖了尚未创建的资源的输出
resource "random_integer" "num" {
min = 1
max = 5
}
resource "aws_s3_bucket" "dynamic" {
count = random_integer.num.result # Error!
bucket = "dynamic-bucket-${count.index}"
}
# ❌ 报错!for_each 依赖了尚未创建的资源的输出
resource "aws_sqs_queue" "per_bucket" {
for_each = toset([for i in range(random_integer.num.result) : "q-${i}"]) # Error!
name = each.key
}Terraform 会报错:
│ Error: Invalid count argument
│ The "count" value depends on resource attributes that cannot be
│ determined until apply, so Terraform cannot predict how many
│ instances will be created.这是因为 Terraform 需要在 plan 阶段就确定要管理哪些资源实例——如果实例数量或标识依赖于运行时才知道的值,Terraform 无法构建正确的依赖图。
以下值可以安全地用于 count 和 for_each:
- 字面量:
count = 3、for_each = toset(["a", "b"]) - 输入变量:
count = var.instance_count(前提是变量本身的值在 plan 阶段已知——如果调用模块时用另一个资源的输出给变量赋值,同样会报错) - 局部值:
for_each = local.config_map(同理,局部值的表达式不能间接依赖未知的资源输出) - 数据源属性(查询参数在 plan 阶段已知时):
count = length(data.aws_availability_zones.available.names)
关键在于整条求值链上不能出现 apply 阶段才确定的值。例如,即使 count 写的是 var.n,但如果调用模块时 n = aws_xxx.foo.result,最终 count 仍然依赖了资源输出,Terraform 一样会报错。
解决方案
如果确实需要根据一个资源的输出来决定另一个资源的数量,通常的做法是:
- 将已知的输入(如变量)作为
count/for_each的来源,而非资源输出 - 将操作拆分为两个
terraform apply:第一次创建上游资源,第二次用-var或数据源引用其结果
provider
当声明了同一类型 Provider 的多个实例(使用 alias)时,可以通过 provider 元参数指定资源使用哪个实例:
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_s3_bucket" "west_bucket" {
provider = aws.west
bucket = "my-west-bucket"
}未指定 provider 时,Terraform 默认使用资源类型名第一个单词对应的默认 Provider 实例。
lifecycle
lifecycle 块用于自定义资源的生命周期行为,嵌套在 resource 块内:
resource "aws_s3_bucket" "important" {
bucket = "critical-data-bucket"
lifecycle {
prevent_destroy = true
}
}lifecycle 支持以下参数:
create_before_destroy — 当资源因 ForceNew 参数变更需要被替换时,先创建新资源,再销毁旧资源(默认是先删后建):
lifecycle {
create_before_destroy = true
}这在需要保持服务可用性的场景下非常有用——确保新资源就绪后再删除旧资源,避免服务中断,或是某些场景下如果没有新资源替换,旧资源无法删除的场景。要特别注意的是,create_before_destroy = true 的资源的命名,创建新资源的行为不能引发与旧资源的命名冲突。配置后,terraform plan 的替换操作会显示为 +/-(而非默认的 -/+)。
prevent_destroy — 阻止 Terraform 销毁该资源。如果执行计划包含销毁操作,Terraform 会报错并中止:
lifecycle {
prevent_destroy = true
}这是一种安全机制,适用于数据库、存储桶等不应被意外删除的资源。但它有两个重要限制:
prevent_destroy只在resource块存在时生效——如果直接删除整个resource块,Terraform 不会阻止销毁- 由于
lifecycle不支持变量,prevent_destroy的值只能硬编码为true或false,无法根据环境动态切换(参见前文 lifecycle 不支持动态表达式 的说明)
因此在实际项目中 prevent_destroy 的使用并不多见——它在开发阶段会阻碍 terraform destroy 清理资源,而切换环境时又无法通过变量控制。
ignore_changes — 忽略指定属性的变更。当某些属性在资源创建后会被外部系统修改时,这可以防止 Terraform 覆盖这些变更:
resource "aws_instance" "web" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
lifecycle {
ignore_changes = [
tags, # 忽略 tags 的变化
]
}
}使用 ignore_changes = all 可以忽略所有属性的变更——资源创建后 Terraform 不再管理它。
replace_triggered_by — 当指定的引用发生变化时,强制替换该资源:
resource "aws_instance" "web" {
# ...
lifecycle {
replace_triggered_by = [
null_resource.always_replace # 当这个资源变化时,替换 web 实例
]
}
}lifecycle 不支持动态表达式
lifecycle 块中的参数值必须是字面量,不能使用变量(var.xxx)、局部值(local.xxx)、资源属性或任何其他引用和表达式。例如:
# ❌ 不合法!lifecycle 参数不能引用变量
variable "is_prod" {
type = bool
default = true
}
resource "aws_s3_bucket" "data" {
bucket = "my-bucket"
lifecycle {
prevent_destroy = var.is_prod # Error: Variables not allowed
}
}这是 Terraform 的一个已知限制——lifecycle 块在表达式求值之前就需要被解析,因此无法依赖运行时才确定的值。社区对此有长期的讨论(参见 hashicorp/terraform#25534),但截至目前该限制仍未解除。
Precondition 与 Postcondition
自 Terraform v1.2 起,resource 块支持 precondition 和 postcondition 块,用于在创建/更新资源前后进行自定义断言:
resource "aws_s3_bucket" "data" {
bucket = var.bucket_name
# 创建前检查
lifecycle {
precondition {
condition = length(var.bucket_name) >= 3
error_message = "桶名长度必须至少 3 个字符。"
}
postcondition {
condition = self.arn != ""
error_message = "桶创建后未返回 ARN。"
}
}
}precondition— 在 Terraform 计算资源配置之前执行,可以引用输入变量和其他已知值postcondition— 在资源创建/更新之后执行,可以通过self引用当前资源的属性
Provisioner
provisioner 块用于在资源创建或销毁时执行额外操作(如运行脚本)。Provisioner 是 Terraform 的"逃生舱"——当 Provider 不支持某些操作时的最后手段:
resource "aws_instance" "web" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
# 创建时执行
provisioner "local-exec" {
command = "echo ${self.private_ip} >> ip_list.txt"
}
# 销毁时执行
provisioner "local-exec" {
when = destroy
command = "echo 'Instance destroyed' >> ip_list.txt"
}
}常用的 provisioner 类型:
local-exec— 在运行 Terraform 的机器上执行命令remote-exec— 通过 SSH 或 WinRM 在远程资源上执行命令(需配合connection块)file— 将文件或目录复制到远程资源
在 provisioner 块内不能通过父资源名称引用自身,必须使用特殊的 self 对象。例如 self.private_ip 而非 aws_instance.web.private_ip,因为按名称引用会产生循环依赖。
慎用 Provisioner
Terraform 官方建议尽量避免使用 provisioner,因为:
- Provisioner 不会被记录在状态文件中,Terraform 无法跟踪其执行状态
- 如果 provisioner 失败,资源会被标记为"受损"(tainted)
- 大多数场景可以通过 Provider 原生功能、
cloud-init、配置管理工具等更好地实现
local-exec provisioner 相对安全,常用于触发外部操作(如通知、记录等)。
when — 控制执行时机
when 参数控制 provisioner 在什么时候执行。默认值为创建时执行,设为 destroy 则在资源销毁时执行:
resource "aws_instance" "web" {
# ...
# 创建时执行(默认行为,可省略 when)
provisioner "local-exec" {
command = "echo 'Created ${self.id}' >> deploy.log"
}
# 销毁时执行
provisioner "local-exec" {
when = destroy
command = "echo 'Destroying ${self.id}' >> deploy.log"
}
}创建时预置器(Creation-time provisioner)——如果失败,Terraform 会将资源标记为受损(tainted),下次 apply 时会销毁并重新创建该资源。这是因为失败的 provisioner 可能导致资源处于半配置状态,重建是唯一可靠的恢复方式。
销毁时预置器(Destroy-time provisioner)——在资源被销毁之前执行。如果失败,Terraform 会报错并在下次 apply 时重新尝试。需要注意:
- 销毁时预置器必须确保可以安全地多次执行(幂等性)
- 如果资源配置了
create_before_destroy = true,销毁时预置器不会执行 - 如果直接从配置中删除整个
resource块,销毁时预置器也不会执行——因为 provisioner 配置随资源块一起消失了
安全删除带销毁时预置器的资源
如果资源包含销毁时预置器,不要直接删除 resource 块。应分两步操作:
- 先设置
count = 0,执行apply销毁资源(此时销毁时预置器会正常执行) - 再从配置中删除整个
resource块
on_failure — 失败行为
默认情况下,provisioner 失败会导致 terraform apply 失败。可以通过 on_failure 参数改变行为:
provisioner "local-exec" {
command = "echo 'setup complete'"
on_failure = continue # 失败时继续,不中止 apply
}on_failure = fail(默认)— provisioner 失败时中止操作,资源被标记为受损on_failure = continue— 忽略错误,继续后续操作
多个 provisioner
一个资源可以包含多个 provisioner 块,Terraform 按定义顺序依次执行。可以在同一资源中混合使用创建时和销毁时预置器——Terraform 只会在对应的阶段执行相应的预置器。
无资源的 provisioner
如果需要运行不与特定资源关联的 provisioner,可以搭配 terraform_data 资源使用:
resource "terraform_data" "notify" {
triggers_replace = [aws_instance.web.id]
provisioner "local-exec" {
command = "notify-team.sh 'Instance ${aws_instance.web.id} deployed'"
}
}terraform_data 不管理真实的基础设施对象,但支持完整的资源生命周期,可以通过 triggers_replace 控制何时重新执行 provisioner。
dynamic 块
在编写资源配置时,有时需要根据变量动态生成重复的嵌套块。dynamic 块提供了这种能力:
variable "ingress_rules" {
type = list(object({
port = number
description = string
}))
default = [
{ port = 80, description = "HTTP" },
{ port = 443, description = "HTTPS" },
{ port = 8080, description = "Alt HTTP" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = ingress.value.description
}
}
}dynamic 块的工作方式:
- 标签(
"ingress")对应要生成的嵌套块类型 for_each— 要遍历的集合content— 嵌套块的内容模板- 在
content内通过<标签>.value访问当前元素(类似each.value)
可以通过 iterator 参数自定义迭代器变量名:
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule # 使用 rule 代替默认的 ingress
content {
from_port = rule.value.port
to_port = rule.value.port
protocol = "tcp"
}
}适度使用
dynamic 块虽然强大,但过度使用会严重降低代码可读性。Terraform 官方建议只在需要根据变量动态生成嵌套块时使用——如果嵌套块的数量和内容是固定的,直接写出来更清晰。
删除资源
有三种方式从 Terraform 管理中移除资源:
- 删除
resource块并执行apply— Terraform 会销毁对应的实际资源 terraform state rm— 仅从状态文件中移除记录,不销毁实际资源("解除管理")removed块(Terraform v1.7+)— 在代码中声明某个资源不再受管理:
removed {
from = aws_instance.old_server
lifecycle {
destroy = false # 不销毁实际资源,只从状态中移除
}
}操作超时设置
部分资源类型支持 timeouts 嵌套块,用于设置创建、更新、删除操作的超时时间:
resource "aws_db_instance" "main" {
# ...
timeouts {
create = "60m"
delete = "2h"
}
}超时时间的格式是一个数字加单位后缀(s 秒、m 分钟、h 小时)。支持哪些操作取决于具体的资源类型。
🧪 动手实验
本节实验分为三个部分,每部分创建一个基于 LocalStack 的仿真应用:
数据源 (data)
资源(resource)是 Terraform 中的"托管资源"——Terraform 会对其进行增删改操作。而数据源(data)是一种特殊的只读资源,它只是查询已存在的数据或外部信息,不会创建或修改任何基础设施对象。
data 块
数据源通过 data 块声明。紧跟 data 关键字的第一个标签是数据源类型,第二个标签是本地名称:
data "aws_ami" "latest_ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}块体内的参数是查询条件——不同的数据源类型接受不同的查询条件参数,这些参数告诉数据源要查询什么数据。
数据源类型加本地名称的组合在同一模块内必须唯一——这与资源的命名规则一致。
引用数据源
通过 data.<类型>.<名称>.<属性> 引用数据源的输出属性:
resource "aws_instance" "web" {
ami = data.aws_ami.latest_ubuntu.id
instance_type = "t3.micro"
}注意引用路径以 data. 开头,这将其与托管资源(resource)的引用区分开来。
data 与 resource 的区别
| 对比 | resource(托管资源) | data(数据源) |
|---|---|---|
| 操作类型 | 增、删、改 | 只读 |
| 状态文件 | 记录资源状态 | 缓存查询结果 |
| 销毁操作 | terraform destroy 会销毁 | 不会销毁任何东西 |
| 用途 | 声明要管理的基础设施 | 查询/计算辅助信息 |
简单来说:resource 是"我要创建这个东西",data 是"帮我查一下这个东西的信息"。
典型使用场景
数据源最常见的使用场景是:
1. 查询已有资源的信息
很多时候,某些基础设施资源不由当前 Terraform 代码管理(可能由另一个团队、另一份代码、或手动创建),但当前代码需要引用它的属性:
# 查询由其他团队管理的 VPC
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = ["production-vpc"]
}
}
# 在这个 VPC 中创建子网
resource "aws_subnet" "app" {
vpc_id = data.aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}2. 查询动态信息
# 获取当前 AWS 账号信息
data "aws_caller_identity" "current" {}
# 获取当前区域
data "aws_region" "current" {}
output "account_id" {
value = data.aws_caller_identity.current.account_id
}
output "region" {
value = data.aws_region.current.name
}3. 读取本地或外部数据
某些数据源不访问远程 API,而是在 Terraform 进程内部计算。这些"本地数据源"每次执行计划时都会重新计算:
# 读取本地文件内容
data "local_file" "config" {
filename = "${path.module}/config.json"
}
# 构建 IAM 策略文档
data "aws_iam_policy_document" "s3_read" {
statement {
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
}
}数据源行为
数据源的读取时机取决于查询参数中引用的值是否在 plan 阶段就已知:
- 如果查询参数只引用字面量或已知值(如输入变量),数据源在 refresh 阶段读取——
terraform plan时就可以使用其结果 - 如果查询参数引用了尚未创建的资源属性,数据源的读取会被推迟到 apply 阶段——在依赖的资源创建完成后才执行
# plan 阶段就能读取(参数是字面量)
data "aws_s3_bucket" "existing" {
bucket = "my-known-bucket"
}
# 推迟到 apply 阶段(依赖尚未创建的资源)
data "aws_s3_bucket" "dynamic" {
bucket = aws_s3_bucket.new_bucket.id
}数据源参数
数据源的大部分参数由对应的数据源类型定义。但与资源类似,Terraform 也为所有数据源提供了一些元参数:
depends_on— 显式声明依赖关系count/for_each— 创建多个数据源实例provider— 指定使用的 Provider 实例
这些元参数的行为与 resource 块上的完全一致——在资源一节中已经详细介绍,这里不再赘述。
lifecycle
与资源不同,数据源的 lifecycle 块只支持 precondition 和 postcondition,不支持 prevent_destroy、ignore_changes 等参数——这很合理,因为数据源本身就不会创建或销毁任何东西。
data "aws_ami" "app" {
most_recent = true
owners = ["self"]
filter {
name = "tag:Component"
values = ["app-server"]
}
lifecycle {
postcondition {
condition = self.tags["Component"] == "app-server"
error_message = "查到的 AMI 缺少 Component=app-server 标签。"
}
}
}🧪 动手实验
临时资源 (ephemeral)
自 Terraform v1.10 起,Terraform 引入了一种全新的块类型——ephemeral(临时资源)。临时资源本质上是临时性的 Terraform 资源:它们拥有独特的生命周期,不会存储在状态文件或计划文件中。每个 ephemeral 块描述一个或多个临时资源,典型用途包括获取临时密码、短期凭据,或与另一个系统建立临时连接。
前面我们已经在输入变量和输出值中介绍了 ephemeral 关键字——临时变量和临时输出只是标记数据的临时性。而 ephemeral 资源则更进一步:它由 Provider 提供,拥有自己的生命周期(打开、续约、关闭),可以与外部系统交互获取敏感数据。
ephemeral 块
ephemeral 块的结构与 resource 块和 data 块一致——紧跟关键字的第一个标签是资源类型,第二个标签是本地名称:
ephemeral "类型" "名称" {
# 属性赋值
}例如,使用临时资源从 AWS Secrets Manager 获取数据库凭据:
ephemeral "aws_secretsmanager_secret_version" "db_master" {
secret_id = data.aws_db_instance.example.master_user_secret[0].secret_arn
}
locals {
credentials = jsondecode(ephemeral.aws_secretsmanager_secret_version.db_master.secret_string)
}
provider "postgresql" {
host = data.aws_db_instance.example.address
port = data.aws_db_instance.example.port
username = local.credentials["username"]
password = local.credentials["password"]
}在这个例子中,数据库凭据通过临时资源获取,传递给 provider 配置——整个过程中凭据不会被写入状态文件。
生命周期
ephemeral 的生命周期与 resource 和 data 截然不同:
打开 (Open) — 当 Terraform 需要访问临时资源的结果时,它会"打开"该资源。例如,打开一个包含 Vault 机密的临时资源时,Vault Provider 会获取租约并返回机密。
续约 (Renew) — 如果 Terraform 需要使用临时资源的时间超过了远程系统设置的过期时间,Terraform 会要求 Provider 定期续约。例如,对 Vault 机密续约时,Provider 会调用租约续约 API 延长到期时间。
关闭 (Close) — 当 Terraform 不再需要临时资源时,将其关闭。这发生在所有依赖临时资源的操作完成之后。例如,关闭 Vault 机密意味着 Provider 明确吊销租约,令 Vault 立即撤销相关凭证。
这种"打开—续约—关闭"的生命周期意味着临时资源持有的数据只在当前 Terraform 运行期间存在,运行结束后自动消失。
为什么会有续约呢?因为这里所谓的“打开”,有时候并不是说我们打开一个盒子,读取里面存放的静态的数据,对于动态机密,所谓“打开”指的是创建了一个机密,例如一个数据库用户名和对应的密码,在关闭时会将它删除,那么这时如果我们临近了这个机密租约的时候我们必须续约,告知管理动态机密的系统延长其租约有效期,否则一旦到期,相关的机密真的会从系统中被删除。
引用临时资源
通过 ephemeral.<类型>.<名称>.<属性> 引用临时资源的属性。但临时资源的引用有严格的限制——只能在以下临时上下文中使用:
- 另一个
ephemeral资源 local表达式(引用临时值后该 local 也隐式变为临时)- 临时输入变量(
ephemeral = true的variable) - 临时输出值(
ephemeral = true的output) provider块中的参数provisioner和connection块precondition和postcondition中的condition
如果尝试在普通 resource 的属性中直接引用临时资源,Terraform 会报错——因为普通资源的属性会被写入状态文件,而临时数据不允许被持久化。
ephemeral "random_password" "db" {
length = 32
special = true
}
# ✅ 可以在 local 中引用
locals {
db_password = ephemeral.random_password.db.result
}
# ✅ 可以在 provider 中引用
provider "postgresql" {
password = local.db_password
}
# ❌ 不能直接赋给普通资源属性
# resource "aws_db_instance" "main" {
# password = local.db_password # Error! 临时值只能赋给 write-only 属性
# }Write-Only 属性
上面看到,临时资源的值不能赋给普通资源的属性——因为普通属性会被写入状态文件。那如果我们确实需要把一个临时生成的密码传递给资源(比如写入 Secrets Manager),该怎么办?
答案是 Write-Only 属性。自 Terraform v1.11 起,Provider 可以将资源的某些属性标记为 write-only:这些属性的值会被发送给云 API,但不会保存到状态文件中。这样临时资源的值就可以安全地"穿过"资源边界。
命名约定
Write-only 属性遵循统一的命名约定——在原属性名后添加 _wo 后缀,并搭配一个 _wo_version 字段:
| 原属性 | Write-Only 属性 | 版本控制属性 |
|---|---|---|
secret_string | secret_string_wo | secret_string_wo_version |
password | password_wo | password_wo_version |
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string_wo = local.db_credentials # 值发送给 API,但不保存到状态
secret_string_wo_version = 1 # 递增此数字触发更新
}_wo 属性和对应的原属性(如 secret_string)是互斥的——不能同时使用。
为什么需要 _wo_version
如果只有一个 _wo 属性,Terraform 无法判断值是否发生了变化——因为旧值不在状态文件中,无从对比。为了解决这个问题,每个 write-only 属性都搭配一个 _wo_version 数字:状态文件只记录版本号,不记录值本身。
Terraform 通过 _wo_version 来决定是否需要更新:
- 版本号没变 → Terraform 认为值也没变,跳过更新
- 版本号递增 → Terraform 知道值已更新,重新发送给 API
# 初始版本
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string_wo = local.db_credentials
secret_string_wo_version = 1 # 版本 1
}
# 密码轮换时,递增版本号
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string_wo = local.new_db_credentials
secret_string_wo_version = 2 # 版本 2 → 触发更新
}为什么其他方案不可行
你可能会想:为什么不直接对比 _wo 属性的新旧值来决定是否更新?或者为什么不用哈希值?以下是几种替代方案及其问题:
方案 1:直接对比新旧值
状态文件中不保存旧值(这正是 write-only 的意义),所以没有旧值可供对比。如果保存了旧值就不是 write-only 了——敏感数据又回到了状态文件中。
方案 2:保存值的哈希
理论上可以在状态中保存 sha256(secret_string_wo) 来检测变更。但这有安全风险:
- 哈希值可被用于彩虹表攻击——如果密码空间较小,攻击者可以枚举候选值并比对哈希
- 即使使用加盐哈希(salted hash),它仍然泄露了"值是否发生了变化"这一信息,在某些合规场景下不被接受
- 不同的 Provider 和属性对安全级别的要求不同,统一的哈希方案难以满足所有场景
方案 3:每次 apply 都发送
如果不做任何检测,每次 terraform apply 都重新发送 _wo 的值,那么:
- 每次
plan都会显示该资源有变更(即使值完全没变),产生大量噪音 - 某些 API 对写入操作有频率限制或会产生新的版本记录(如 Secrets Manager 每次写入创建一个新版本)
- 在团队协作中,"每次都有变更"会破坏变更审查的信号——无法区分哪些是真正的变更
- 更关键的是,许多
ephemeral资源(如 Vault 动态机密)每次"打开"都会生成全新的值——每次apply都会产生一个新密码并写入目标系统,导致密码被频繁轮换,而这完全不受使用者控制
_wo_version 方案刚好在安全性和可用性之间取得了平衡:状态文件中只保存一个无意义的数字(不泄露任何机密信息),而更新与否完全由使用者通过递增版本号来显式控制。只有当用户主动递增版本号时,Terraform 才会重新打开 ephemeral 资源获取新值并发送给 API——避免了每次运行都意外触发密码轮换的问题。
与 resource 和 data 的区别
| 对比 | resource | data | ephemeral |
|---|---|---|---|
| 操作类型 | 增、删、改 | 只读查询 | 打开、续约、关闭 |
| 状态文件 | ✅ 记录 | ✅ 缓存 | ❌ 不记录 |
| 计划文件 | ✅ 记录 | ✅ 记录 | ❌ 不记录 |
| 引用限制 | 无 | 无 | 仅限临时上下文 |
| 典型用途 | 创建基础设施 | 查询已有信息 | 获取短期凭据/机密 |
何时使用 ephemeral
临时资源最适合以下场景:
1. 获取短期凭据
从 Vault、AWS Secrets Manager 等系统获取凭据,用于配置 Provider 或 Provisioner,不留痕迹:
ephemeral "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/api-key"
}
provider "datadog" {
api_key = ephemeral.aws_secretsmanager_secret_version.api_key.secret_string
}2. 生成不持久化的随机值
使用 random Provider 的临时资源生成密码,确保密码不出现在状态文件中:
ephemeral "random_password" "secret" {
length = 24
special = true
}与 resource "random_password" 不同,ephemeral "random_password" 生成的密码不会保存到状态文件。每次 Terraform 运行都会重新生成——这正是临时凭据的期望行为。
3. 建立临时连接
Provider 可以实现临时资源来管理短暂的连接或会话:
ephemeral "vault_token" "deploy" {
policies = ["deploy-policy"]
ttl = "1h"
}Terraform 运行期间 token 有效,运行结束后自动吊销。
元参数
临时资源支持与 resource 和 data 相同的元参数:depends_on、count、for_each、provider、lifecycle。它们的行为与前文资源一节中介绍的完全一致。
临时资源不支持 provisioner 元参数。
