Skip to content

Terraform 代码的书写

Terraform 使用 HCL(HashiCorp Configuration Language)作为配置语言。HCL 融合了声明式语言的简洁和命令式语言的表达力,是 Terraform 的核心组成部分。

本章将系统介绍 HCL 的语法体系,涵盖以下内容:

目录


配置语法

HCL 的基本构建单元是(Block)、参数(Argument)和注释(Comment)。

块(Block)、标签(Label)与参数(Argument)

块是 HCL 配置的基本结构单元。每个块由块类型、零个或多个标签和一个块体组成:

hcl
块类型 "标签1" "标签2" {
  # 块体:包含参数和嵌套块
  参数名 = 参数值
}

不同的块类型有不同数量的标签,块体内通过 名称 = 值参数赋值语句来配置块的行为:

hcl
# terraform 块:无标签
terraform {
  required_version = ">= 1.0"
}

# output 块:一个标签(输出名称)
output "greeting" {
  value = "Hello!"
}

# resource 块:两个标签(资源类型、资源名称)
resource "aws_s3_bucket" "data" {
  bucket = "my-bucket"
}

标签和参数名都是标识符,遵循相同的命名规则——可以包含字母、数字、下划线(_)和连字符(-),但首字母不可以为数字

hcl
# ✅ 合法
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"                         # 包含点号

参数值可以是不同类型:

hcl
locals {
  project = "my-app"       # 字符串
  enabled = true            # 布尔值
  count   = 3               # 数字
  tags    = {                # Map
    Environment = "dev"
    Team        = "platform"
  }
}

对齐风格

Terraform 社区推荐使用 terraform fmt 命令自动格式化代码,它会对齐同一块内的等号。

注释(Comment)

hcl
# 单行注释(推荐风格)

// 单行注释(C 风格,不推荐)

/*
  多行注释
  可以跨越多行
*/

字符串与 Heredoc

普通字符串使用双引号:

hcl
name = "Hello, Terraform!"

字符串插值使用 ${} 引用表达式:

hcl
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 语法用于多行字符串:

hcl
# <<EOF 保留原始缩进
config = <<EOF
server {
  listen 80;
  server_name example.com;
}
EOF

# <<-EOF 去除公共前导空格(推荐)
config = <<-EOF
  server {
    listen 80;
    server_name example.com;
  }
EOF

🧪 动手实验


类型

Terraform 中每个值都有类型,类型决定了值可以在哪里使用以及可以对它应用哪些操作。Terraform 的类型分为原始类型复杂类型(集合类型 + 结构化类型),以及特殊的 anynull

原始类型

原始类型有三种:

类型描述示例
stringUnicode 字符串"hello"
number数字(整数或小数)423.14
bool布尔值truefalse

numberbool 都可以与 string 进行隐式类型转换

hcl
# number ↔ string
# "42" 可以自动转换为 42,反之亦然
# "3.14" ↔ 3.14

# bool ↔ string
# "true" ↔ true
# "false" ↔ false

这意味着将 "42" 赋给 number 类型的变量不会报错,Terraform 会自动完成转换。

集合类型

集合类型包含一组同一类型的值。Terraform 支持三种集合类型:

  • list(...) — 有序序列,可用下标访问(从 0 开始)

    hcl
    variable "zones" {
      type    = list(string)
      default = ["us-east-1a", "us-east-1b", "us-east-1c"]
    }
    # var.zones[0] => "us-east-1a"
  • map(...) — 键值对集合,键必须是 string

    hcl
    variable "tags" {
      type = map(string)
      default = {
        Environment = "dev"
        Team        = "platform"
      }
    }
    # var.tags["Environment"] => "dev"

    map 的两种写法

    map 值可以用 {"foo": "bar"}{foo = "bar"} 两种语法。推荐使用 = 号——terraform fmt 会自动对齐等号,代码更整洁。

  • set(...) — 无序、不重复的集合,不能用下标访问

    hcl
    variable "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 会自动推断并在必要时进行隐式转换。

"同一类型"比你想象的更严格

stringnumberbool 之间可以隐式互转,所以 ["hello", 42, true] 能赋给 list(any)(全部转为 string)。但一旦混入无法互转的类型,就会报错:

hcl
# ❌ 报错!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({...}) — 由命名属性组成,每个属性有独立类型

    hcl
    variable "server" {
      type = object({
        name   = string
        port   = number
        active = bool
      })
      default = {
        name   = "web-01"
        port   = 8080
        active = true
      }
    }
    # var.server.name => "web-01"

    赋给 object 的值必须包含所有必填属性,但多余的属性会被丢弃

  • tuple([...]) — 定长序列,每个位置有独立类型

    hcl
    variable "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 的每个位置可以是不同类型
  • objectmaptuplelist 之间在满足条件时可以隐式转换

object 的 optional 成员

自 Terraform 1.3 起,object 中可以使用 optional 声明可选属性:

hcl
variable "config" {
  type = object({
    name     = string                    # 必填
    port     = optional(number, 8080)    # 可选,默认 8080
    debug    = optional(bool, false)     # 可选,默认 false
  })
}

optional 有两个参数:

  1. 类型(必填)— 属性的类型
  2. 默认值(选填)— 省略该属性时使用的值。未指定默认值时,默认为 null

这对于包含大量属性的 object 尤其有用——用户只需提供关心的属性,其余自动获得合理的默认值:

hcl
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 函数,可以内联地把表达式强制转换为目标类型:

hcl
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) 的第二个参数是类型约束表达式(和 variabletype 写法完全一致),可以是 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当前工作目录的文件系统路径
hcl
locals {
  project = "demo"
}

output "info" {
  value = "项目: ${local.project}, 工作区: ${terraform.workspace}"
}

算术与逻辑运算符

运算符要么是把两个值计算为第三个值(二元操作符),要么是把一个值转换为另一个值(一元操作符)。

当一个表达式中含有多个运算符时,它们的优先级顺序为:

  1. !- (负号)
  2. */%
  3. +- (减号)
  4. >>=<<=
  5. ==!=
  6. &&
  7. ||

可以使用小括号覆盖默认优先级,例如 1+2*3 会被计算为 7,而 (1+2)*39

算术运算符

  • a + b:返回 a 与 b 的和
  • a - b:返回 a 与 b 的差
  • a * b:返回 a 与 b 的积
  • a / b:返回 a 与 b 的商
  • a % b:返回 a 除以 b 的余数(取模),一般仅在两者为整数时有效
  • -a:返回 a 的相反数
hcl
locals {
  sum       = 3 + 4       # 7
  remainder = 10 % 3      # 1
  negative  = -(5)        # -5
}

相等性运算符

  • a == b:如果 a 与 b 类型与值都相等返回 true,否则返回 false
  • a != b:与 == 相反

比较运算符

  • a < b:如果 a 比 b 小则为 true
  • a > b:如果 a 比 b 大则为 true
  • a <= b:如果 a 小于等于 b 则为 true
  • a >= b:如果 a 大于等于 b 则为 true

逻辑运算符

  • a || b:a 或 b 中有至少一个为 true 则为 true
  • a && b:a 与 b 都为 true 则为 true
  • !a:如果 a 为 true 则为 false,反之亦然
hcl
locals {
  is_prod     = true
  has_budget  = false
  should_warn = local.is_prod && !local.has_budget  # true
}

条件表达式

条件表达式根据布尔条件在两个值中选择一个:

hcl
condition ? true_val : false_val

如果 conditiontrue,结果是 true_val,否则为 false_val。两个候选值的类型必须相同。

hcl
variable "environment" {
  type    = string
  default = "dev"
}

locals {
  instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
}

一个常见用法是用默认值替代非法值:

hcl
# 如果 var.name 为空字符串,使用默认值
name = var.name != "" ? var.name : "default-name"

TIP

上面的表达式推荐写为:coalesce(var.name, "default-name")

条件表达式与 null 结合可以实现"可选赋值"——条件不满足时跳过赋值,使用资源属性的默认值:

hcl
error_document = var.legacy ? "ERROR.HTM" : null

函数调用

Terraform 支持在表达式中使用内建函数。通用语法是:

hcl
函数名(参数1, 参数2, ...)

例如 min 函数接收任意多个数值参数,返回最小值:

hcl
min(55, 3453, 2)  # => 2

展开函数入参

如果想把列表或元组的元素作为参数传递给函数,可以使用展开符 ...

hcl
min([55, 2453, 2]...)  # => 2

展开符由三个独立的 . 组成,只能用在函数调用场景中。

常用内建函数

Terraform 提供了丰富的内建函数,按类别包括:

  • 字符串upperlowerformatjoinsplittrimspacereplaceregex
  • 集合lengthcontainsmergeflattenkeysvalueslookupelementconcatdistinctsort
  • 数值minmaxabsceilfloorlogpow
  • 类型转换tostringtonumbertobooltolisttosettomap
  • 编码jsonencodejsondecodeyamlencodebase64encode
  • 文件filefileexiststemplatefilebasenamedirname
  • 日期timestampformatdatetimeadd
  • 逻辑cantrycoalescecoalescelist

完整列表参见 Terraform 函数文档

字符串模板

字符串模板允许在字符串中嵌入表达式或通过循环动态构造字符串。

插值 (Interpolation)

${...} 序列计算花括号之间的表达式的值,将结果转换为字符串后插入模板:

hcl
"Hello, ${var.name}!"
# var.name = "Juan" => "Hello, Juan!"

指令 (Directive)

%{...} 序列用于在字符串内部进行条件判断或遍历。

if/else/endif 指令根据布尔表达式选择模板:

hcl
"Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"

for/endfor 指令遍历集合,用每个元素渲染模板,然后拼接起来:

hcl
<<-EOT
%{ for ip in var.ip_list ~}
server ${ip}
%{ endfor ~}
EOT

去除空白的 ~ 符号

所有模板序列的首尾都可以添加 ~ 符号。~ 会去除模板序列相邻一侧的空白(空格和换行),常与 Heredoc 配合使用以控制输出格式。

如果需要输出字面量 ${%{,重复第一个字符即可:$${%%{

for 表达式

for 表达式将一种复合类型映射成另一种。输入类型中的每个元素都会被映射为一个或零个结果。

输出元组——用方括号包裹:

hcl
[for s in var.list : upper(s)]
# var.list = ["hello", "world"] => ["HELLO", "WORLD"]

输出对象——用花括号包裹,使用 => 分隔键值:

hcl
{for s in var.list : s => upper(s)}
# var.list = ["hello"] => { "hello" = "HELLO" }

带过滤的 for——添加 if 子句过滤元素:

hcl
[for s in var.list : upper(s) if s != ""]

遍历 map/object——迭代器表示为两个临时变量(键和值):

hcl
[for k, v in var.map : "${k}=${v}"]

分组 (group by)——在输出对象时使用 ... 将同键的值聚合为列表:

hcl
{for s in var.list : substr(s, 0, 1) => s...}
# ["apple", "avocado", "banana"]
# => { "a" = ["apple", "avocado"], "b" = ["banana"] }

Splat 表达式

Splat 表达式提供了一种简洁的方式来提取列表中所有元素的某个属性,等价于特定模式的 for 表达式。

假设 var.list 包含一组对象,每个对象有 id 属性,以下两种写法等价:

hcl
# for 表达式
[for o in var.list : o.id]

# splat 表达式
var.list[*].id

[*] 符号迭代列表中每一个元素,并返回它们在 . 右边的属性值。

如果 splat 表达式被用于一个既不是列表又不是元组的值,该值会被自动包装成单元素列表。这在访问可能带有 count 参数的资源时很有用:

hcl
# 不论 aws_instance.example 是否定义了 count,都能正确返回 id 列表
aws_instance.example[*].id

避免旧式 splat 语法

Terraform 有一种使用 .* 的旧式 splat 语法。对于简单的单层属性访问(如 var.list.*.id),旧语法和新语法 var.list[*].id 结果相同。但在链式访问嵌套属性时,两者行为不同:

hcl
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 块定义,紧跟关键字的标签是变量名:

hcl
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> 引用变量值:

hcl
resource "aws_instance" "web" {
  ami = var.image_id
}

在同一个模块(同一目录下的所有 .tf 文件)中,变量名必须唯一。以下关键字不可以作为变量名:sourceversionproviderscountfor_eachlifecycledepends_onlocals

类型约束 (type)

通过 type 参数限制变量接受的值的类型:

hcl
variable "name" {
  type = string
}

variable "ports" {
  type = list(number)
}

variable "server" {
  type = object({
    name = string
    port = number
  })
}

关于各种类型的详细说明,参见前文类型一节。

默认值 (default)

default 定义变量在未被赋值时使用的默认值:

hcl
variable "region" {
  type    = string
  default = "us-east-1"
}

没有默认值的变量在执行时必须被赋值,否则 Terraform 会在交互界面提示输入。

描述 (description)

description 向调用者说明变量的用途。当 Terraform 需要在命令行提示输入时,会显示这段描述:

hcl
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 中的错误信息:

hcl
variable "instance_count" {
  type = number

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "instance_count 必须在 1 到 10 之间。"
  }
}

结合 can 函数可以捕获表达式执行中的错误,常用于正则校验:

hcl
variable "image_id" {
  type = string

  validation {
    condition     = can(regex("^ami-", var.image_id))
    error_message = "image_id 必须以 \"ami-\" 开头。"
  }
}

一个变量可以有多个 validation 块,所有校验都必须通过:

hcl
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 中可以引用其他变量,不再局限于当前变量自身。这使得跨变量的联合校验成为可能:

hcl
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 将报错。确保引用关系是单向的:

hcl
# ✅ 单向引用: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 在 planapply 输出中会用 (sensitive value) 隐藏该变量的值:

hcl
variable "db_password" {
  type      = string
  sensitive = true
}
  + resource "aws_db_instance" "main" {
      + password = (sensitive value)
    }

WARNING

sensitive 只影响命令行输出。Terraform 仍然会将敏感数据以明文记录在状态文件中——任何能访问状态文件的人都能读取这些值。

禁止为空 (nullable)

nullable(默认 true)控制变量是否接受 null 值:

hcl
variable "region" {
  type     = string
  default  = "us-east-1"
  nullable = false
}

nullable = false 时,即使调用者显式传入 null,Terraform 也会使用默认值,确保变量在模块内永远不为空。

临时变量 (ephemeral)

自 Terraform v1.10 起,可以将变量标记为 ephemeral。临时变量的值在当前 Terraform 运行期间可用,但不会被记录到状态文件和计划文件中

hcl
variable "session_token" {
  type      = string
  ephemeral = true
}

这对于短生命周期的数据特别有用,例如临时令牌、会话标识符等——它们只需在执行期间存在,不应被持久化。

ephemeral 与 sensitive 的区别

特性sensitiveephemeral
plan/apply 输出中隐藏
状态文件中记录✅(明文)
计划文件中记录
运行结束后可读取✅(从状态文件)

简单来说:sensitive 只是"遮住眼睛",数据仍然存在于状态文件中;ephemeral 则彻底不持久化,运行结束后数据消失。

引用限制

临时变量只能在以下上下文中被引用,否则 Terraform 会报错:

  • 另一个临时变量
  • local 表达式
  • 临时输出值(ephemeral = trueoutput
  • provider 块中的参数
  • provisionerconnection
  • preconditionpostcondition 中的 condition
hcl
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 块的 sourceversion 默认只能写字面量字符串,因为它们在 terraform init 阶段就要被解析。Terraform 1.15 新增 const = true 属性,标记该变量在 init 阶段就必须确定值,从而允许它出现在 source / version 表达式中

hcl
variable "module_folder" {
  type  = string
  const = true
}

module "zoo" {
  source = "./${var.module_folder}"
}
  • constsensitiveephemeral 互斥——一个变量只能选其一。
  • 嵌套模块要在自己 source 中引用 var.xxx,对应 variable 同样必须 const = true
  • 详细用法见 Terraform 模块 → 动态模块来源

弃用变量 (deprecated,Terraform 1.15+)

模块演进时,某个变量可能要逐步下线。Terraform 1.15 为 variable 新增了 deprecated 属性,只要外部还在给这个变量赋值,validate / plan 就会发出警告诊断

hcl
variable "bad" {
  type       = string
  default    = null
  deprecated = "请改用 good 变量,bad 将在 v2.0 移除。"
}

触发警告的情况:

  • 调用方在 module 块中给该 variable 传值。
  • 根模块的 deprecated variable 通过 CLI -vartfvarsTF_VAR_* 环境变量、Terraform Cloud workspace variable 等任何方式被赋值。

如果完全没人传值,则不会有警告——这给了下游一个观察期来清理用法。

对输入变量赋值

有四种方式为变量赋值:

1. 命令行参数 (-var)

bash
terraform apply -var="image_id=ami-abc123"
terraform apply -var='tags={"env":"prod","team":"platform"}'

2. 参数文件 (.tfvars)

hcl
# prod.tfvars
image_id            = "ami-abc123"
availability_zones  = ["us-east-1a", "us-east-1b"]
bash
terraform apply -var-file="prod.tfvars"

名为 terraform.tfvars*.auto.tfvars 的文件会被自动加载,无需 -var-file

3. 环境变量 (TF_VAR_)

bash
export TF_VAR_image_id=ami-abc123
terraform plan

环境变量特别适合在 CI/CD 流水线中传递敏感数据。

4. 交互式输入

当以上方式都未提供值且变量没有默认值时,Terraform 会在终端提示输入。

赋值优先级

当多种方式同时为同一变量赋值时,后者覆盖前者(优先级从低到高):

  1. 环境变量
  2. terraform.tfvars
  3. terraform.tfvars.json
  4. *.auto.tfvars / *.auto.tfvars.json(按文件名字母序)
  5. -var-var-file 命令行参数(按出现顺序)

🧪 动手实验


输出值 (output)

我们在介绍输入变量时提到过,如果我们把一组 Terraform 代码想象成一个函数,那么输入变量就是函数的入参;函数可以有入参,也可以有返回值——输出值就是 Terraform 代码的返回值。

与大部分语言的函数只支持单返回值不同,Terraform 支持多返回值apply 成功后,命令行会输出所有定义的输出值。也可以随时通过 terraform output 命令查看当前状态文件中的输出值。

output 块

输出值使用 output 块定义,紧跟关键字的标签是输出名称:

hcl
output "instance_ip_addr" {
  value = aws_instance.server.private_ip
}

value 参数是必填的,可以是任意合法的表达式——资源属性、变量引用、函数调用等。在同一个模块内,所有输出值的名称必须唯一。

INFO

Terraform 代码中无法引用本模块内定义的输出值——输出值是给模块调用者或命令行使用的。

描述 (description)

与输入变量一样,description 向调用者说明输出值的含义:

hcl
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 块。与 variablevalidation 类似,precondition 确保输出值满足某种约束。Terraform 在计算 value 表达式之前执行 precondition 检查,可以防止不合法的值被写入状态文件:

hcl
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 时仍可看到实际值:

hcl
output "db_password" {
  value     = aws_db_instance.main.password
  sensitive = true
}
Outputs:

db_password = <sensitive>

WARNING

sensitive 只影响命令行输出。输出值仍然会以明文记录在状态文件中——任何有权限读取状态文件的人都能读取敏感数据。

临时值 (ephemeral)

自 Terraform v1.10 起,可以在子模块中将 output 标记为 ephemeral。临时输出的值在 planapply 期间可用,但不会被记录到状态文件和计划文件中,适合传递凭据、令牌等短生命周期数据:

hcl
# 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,用于通知调用方某个输出即将移除:

hcl
# 子模块
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 显式声明:

hcl
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 块定义:

hcl
locals {
  project     = "my-app"
  environment = "dev"
}

一个 locals 块可以定义多个局部值。一个模块中也可以定义任意多个 locals 块——你可以按逻辑将局部值分组到不同的 locals 块中:

hcl
# 项目信息
locals {
  project     = "my-app"
  environment = "dev"
}

# 计算值
locals {
  full_name = "${local.project}-${local.environment}"
  is_prod   = local.environment == "prod"
}

引用局部值

在代码中通过 local.<NAME> 引用局部值:

hcl
locals {
  project = "my-app"
}

output "project_name" {
  value = local.project
}

注意 locals vs local

定义时使用 locals(复数),引用时使用 local(单数)。这是一个常见的混淆点——定义块是 locals {},但引用表达式是 local.<NAME>

局部值只能在同一模块内的代码中引用。

表达式计算

赋给局部值的不仅可以是简单的字面量,还可以是更复杂的表达式——引用其他资源的属性、输入变量、数据源,甚至是其他的局部值:

hcl
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. 避免重复复杂表达式

当同一个表达式在多个地方使用时,用局部值提取它:

hcl
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. 给复杂表达式起一个有意义的名字

局部值可以充当"命名表达式",提高可读性:

hcl
locals {
  is_production    = var.environment == "prod"
  needs_encryption = local.is_production || var.force_encryption
  instance_type    = local.is_production ? "m5.large" : "t3.micro"
}

3. 预处理输入数据

hcl
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 的输入变量),则该局部值会隐式地成为临时值:

hcl
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):

hcl
resource "aws_instance" "web" {
  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"
}
  • 资源类型(如 aws_instance)决定了被管理的基础设施对象的种类,以及该资源支持哪些参数和输出属性。资源类型名的第一个单词(下划线前)通常对应 Provider 名称——aws_instanceaws Provider 提供。
  • 本地名称(如 web)用于在同一模块内引用该资源。类型 + 本地名称的组合在模块内必须唯一。
  • 花括号内的块体包含资源的参数赋值,不同资源类型有不同的可用参数,可查阅 Provider 文档了解。

声明式语言

Terraform 是声明式语言——描述的是期望的资源状态,而不是达到该状态的步骤。块的顺序和所在文件通常不重要,Terraform 根据资源间的依赖关系自动决定操作顺序。

资源的行为

对 Terraform 代码执行 terraform apply 时,Terraform 会:

  1. 创建+)代码中定义但状态文件中不存在的资源
  2. 更新~)状态文件中已有但与代码定义不一致的资源
  3. 替换-/++/-)某些属性变更导致资源必须重建
  4. 销毁-)状态文件中存在但代码中已删除的资源

每当 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 等)。在同一模块内通过 <资源类型>.<名称>.<属性> 引用:

hcl
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 无法自动推导的隐含依赖关系:

hcl
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 块创建多个相似的资源实例:

hcl
resource "aws_s3_bucket" "logs" {
  count  = 3
  bucket = "app-logs-${count.index}"
}

count.index 是当前实例的索引号(从 0 开始)。声明了 count 的资源通过下标访问:

hcl
# 访问第一个桶
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 参数接受一个 mapset(string),为集合中的每个元素创建一个资源实例:

hcl
# 使用 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 的资源通过键访问:

hcl
output "data_bucket_id" {
  value = aws_s3_bucket.this["data"].id
}

使用 set(string) 时,需要用 toset 转换:

hcl
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 块中不允许同时声明 countfor_each

count 与 for_each 的值必须在 plan 阶段已知

count 的数值和 for_each 的集合必须在 Terraform 执行计划(plan)阶段就能确定,不能依赖于其他资源的输出属性——因为这些属性只有在 apply 阶段资源实际创建后才知道。

hcl
# ❌ 报错!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 无法构建正确的依赖图。

以下值可以安全地用于 countfor_each

  • 字面量count = 3for_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 一样会报错。

解决方案

如果确实需要根据一个资源的输出来决定另一个资源的数量,通常的做法是:

  1. 将已知的输入(如变量)作为 count / for_each 的来源,而非资源输出
  2. 将操作拆分为两个 terraform apply:第一次创建上游资源,第二次用 -var 或数据源引用其结果

provider

当声明了同一类型 Provider 的多个实例(使用 alias)时,可以通过 provider 元参数指定资源使用哪个实例:

hcl
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 块内:

hcl
resource "aws_s3_bucket" "important" {
  bucket = "critical-data-bucket"

  lifecycle {
    prevent_destroy = true
  }
}

lifecycle 支持以下参数:

create_before_destroy — 当资源因 ForceNew 参数变更需要被替换时,先创建新资源,再销毁旧资源(默认是先删后建):

hcl
lifecycle {
  create_before_destroy = true
}

这在需要保持服务可用性的场景下非常有用——确保新资源就绪后再删除旧资源,避免服务中断,或是某些场景下如果没有新资源替换,旧资源无法删除的场景。要特别注意的是,create_before_destroy = true 的资源的命名,创建新资源的行为不能引发与旧资源的命名冲突。配置后,terraform plan 的替换操作会显示为 +/-(而非默认的 -/+)。

prevent_destroy — 阻止 Terraform 销毁该资源。如果执行计划包含销毁操作,Terraform 会报错并中止:

hcl
lifecycle {
  prevent_destroy = true
}

这是一种安全机制,适用于数据库、存储桶等不应被意外删除的资源。但它有两个重要限制:

  1. prevent_destroy 只在 resource 块存在时生效——如果直接删除整个 resource 块,Terraform 不会阻止销毁
  2. 由于 lifecycle 不支持变量,prevent_destroy 的值只能硬编码为 truefalse,无法根据环境动态切换(参见前文 lifecycle 不支持动态表达式 的说明)

因此在实际项目中 prevent_destroy 的使用并不多见——它在开发阶段会阻碍 terraform destroy 清理资源,而切换环境时又无法通过变量控制。

ignore_changes — 忽略指定属性的变更。当某些属性在资源创建后会被外部系统修改时,这可以防止 Terraform 覆盖这些变更:

hcl
resource "aws_instance" "web" {
  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  lifecycle {
    ignore_changes = [
      tags,          # 忽略 tags 的变化
    ]
  }
}

使用 ignore_changes = all 可以忽略所有属性的变更——资源创建后 Terraform 不再管理它。

replace_triggered_by — 当指定的引用发生变化时,强制替换该资源:

hcl
resource "aws_instance" "web" {
  # ...

  lifecycle {
    replace_triggered_by = [
      null_resource.always_replace  # 当这个资源变化时,替换 web 实例
    ]
  }
}

lifecycle 不支持动态表达式

lifecycle 块中的参数值必须是字面量,不能使用变量(var.xxx)、局部值(local.xxx)、资源属性或任何其他引用和表达式。例如:

hcl
# ❌ 不合法!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 块支持 preconditionpostcondition 块,用于在创建/更新资源前后进行自定义断言:

hcl
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 不支持某些操作时的最后手段:

hcl
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,因为:

  1. Provisioner 不会被记录在状态文件中,Terraform 无法跟踪其执行状态
  2. 如果 provisioner 失败,资源会被标记为"受损"(tainted)
  3. 大多数场景可以通过 Provider 原生功能、cloud-init、配置管理工具等更好地实现

local-exec provisioner 相对安全,常用于触发外部操作(如通知、记录等)。

when — 控制执行时机

when 参数控制 provisioner 在什么时候执行。默认值为创建时执行,设为 destroy 则在资源销毁时执行:

hcl
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 块。应分两步操作:

  1. 先设置 count = 0,执行 apply 销毁资源(此时销毁时预置器会正常执行)
  2. 再从配置中删除整个 resource

on_failure — 失败行为

默认情况下,provisioner 失败会导致 terraform apply 失败。可以通过 on_failure 参数改变行为:

hcl
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 资源使用:

hcl
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 块提供了这种能力:

hcl
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 块的工作方式:

  1. 标签"ingress")对应要生成的嵌套块类型
  2. for_each — 要遍历的集合
  3. content — 嵌套块的内容模板
  4. content 内通过 <标签>.value 访问当前元素(类似 each.value

可以通过 iterator 参数自定义迭代器变量名:

hcl
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 管理中移除资源:

  1. 删除 resource 块并执行 apply — Terraform 会销毁对应的实际资源
  2. terraform state rm — 仅从状态文件中移除记录,不销毁实际资源("解除管理")
  3. removed(Terraform v1.7+)— 在代码中声明某个资源不再受管理:
hcl
removed {
  from = aws_instance.old_server

  lifecycle {
    destroy = false   # 不销毁实际资源,只从状态中移除
  }
}

操作超时设置

部分资源类型支持 timeouts 嵌套块,用于设置创建、更新、删除操作的超时时间:

hcl
resource "aws_db_instance" "main" {
  # ...

  timeouts {
    create = "60m"
    delete = "2h"
  }
}

超时时间的格式是一个数字加单位后缀(s 秒、m 分钟、h 小时)。支持哪些操作取决于具体的资源类型。

🧪 动手实验

本节实验分为三个部分,每部分创建一个基于 LocalStack 的仿真应用:


数据源 (data)

资源(resource)是 Terraform 中的"托管资源"——Terraform 会对其进行增删改操作。而数据源(data)是一种特殊的只读资源,它只是查询已存在的数据或外部信息,不会创建或修改任何基础设施对象。

data 块

数据源通过 data 块声明。紧跟 data 关键字的第一个标签是数据源类型,第二个标签是本地名称

hcl
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.<类型>.<名称>.<属性> 引用数据源的输出属性:

hcl
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 代码管理(可能由另一个团队、另一份代码、或手动创建),但当前代码需要引用它的属性:

hcl
# 查询由其他团队管理的 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. 查询动态信息

hcl
# 获取当前 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 进程内部计算。这些"本地数据源"每次执行计划时都会重新计算:

hcl
# 读取本地文件内容
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 阶段——在依赖的资源创建完成后才执行
hcl
# 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只支持 preconditionpostcondition,不支持 prevent_destroyignore_changes 等参数——这很合理,因为数据源本身就不会创建或销毁任何东西。

hcl
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 块一致——紧跟关键字的第一个标签是资源类型,第二个标签是本地名称

hcl
ephemeral "类型" "名称" {
  # 属性赋值
}

例如,使用临时资源从 AWS Secrets Manager 获取数据库凭据:

hcl
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 的生命周期与 resourcedata 截然不同:

  1. 打开 (Open) — 当 Terraform 需要访问临时资源的结果时,它会"打开"该资源。例如,打开一个包含 Vault 机密的临时资源时,Vault Provider 会获取租约并返回机密。

  2. 续约 (Renew) — 如果 Terraform 需要使用临时资源的时间超过了远程系统设置的过期时间,Terraform 会要求 Provider 定期续约。例如,对 Vault 机密续约时,Provider 会调用租约续约 API 延长到期时间。

  3. 关闭 (Close) — 当 Terraform 不再需要临时资源时,将其关闭。这发生在所有依赖临时资源的操作完成之后。例如,关闭 Vault 机密意味着 Provider 明确吊销租约,令 Vault 立即撤销相关凭证。

这种"打开—续约—关闭"的生命周期意味着临时资源持有的数据只在当前 Terraform 运行期间存在,运行结束后自动消失。

为什么会有续约呢?因为这里所谓的“打开”,有时候并不是说我们打开一个盒子,读取里面存放的静态的数据,对于动态机密,所谓“打开”指的是创建了一个机密,例如一个数据库用户名和对应的密码,在关闭时会将它删除,那么这时如果我们临近了这个机密租约的时候我们必须续约,告知管理动态机密的系统延长其租约有效期,否则一旦到期,相关的机密真的会从系统中被删除。

引用临时资源

通过 ephemeral.<类型>.<名称>.<属性> 引用临时资源的属性。但临时资源的引用有严格的限制——只能在以下临时上下文中使用:

  • 另一个 ephemeral 资源
  • local 表达式(引用临时值后该 local 也隐式变为临时)
  • 临时输入变量(ephemeral = truevariable
  • 临时输出值(ephemeral = trueoutput
  • provider 块中的参数
  • provisionerconnection
  • preconditionpostcondition 中的 condition

如果尝试在普通 resource 的属性中直接引用临时资源,Terraform 会报错——因为普通资源的属性会被写入状态文件,而临时数据不允许被持久化。

hcl
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_stringsecret_string_wosecret_string_wo_version
passwordpassword_wopassword_wo_version
hcl
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
hcl
# 初始版本
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 的区别

对比resourcedataephemeral
操作类型增、删、改只读查询打开、续约、关闭
状态文件✅ 记录✅ 缓存❌ 不记录
计划文件✅ 记录✅ 记录❌ 不记录
引用限制仅限临时上下文
典型用途创建基础设施查询已有信息获取短期凭据/机密

何时使用 ephemeral

临时资源最适合以下场景:

1. 获取短期凭据

从 Vault、AWS Secrets Manager 等系统获取凭据,用于配置 Provider 或 Provisioner,不留痕迹:

hcl
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 的临时资源生成密码,确保密码不出现在状态文件中:

hcl
ephemeral "random_password" "secret" {
  length  = 24
  special = true
}

resource "random_password" 不同,ephemeral "random_password" 生成的密码不会保存到状态文件。每次 Terraform 运行都会重新生成——这正是临时凭据的期望行为。

3. 建立临时连接

Provider 可以实现临时资源来管理短暂的连接或会话:

hcl
ephemeral "vault_token" "deploy" {
  policies = ["deploy-policy"]
  ttl      = "1h"
}

Terraform 运行期间 token 有效,运行结束后自动吊销。

元参数

临时资源支持与 resourcedata 相同的元参数:depends_oncountfor_eachproviderlifecycle。它们的行为与前文资源一节中介绍的完全一致。

临时资源不支持 provisioner 元参数。

🧪 动手实验


重载文件


Checks