Chapter 06

数据源、条件与循环

掌握 Terraform 的动态配置能力,用数据源查询现有资源,用循环和条件构建灵活的基础设施

data 数据源

数据源 vs 资源

resource 创建和管理新资源;data source 查询现有资源的信息(只读)。数据源常用于:查询最新 AMI ID、获取已有 VPC 信息、读取 Secrets Manager 中的密钥。

# 查询最新的 Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# 引用数据源:data.类型.名称.属性
resource "aws_instance" "web" {
  ami = data.aws_ami.amazon_linux.id
}

# 从 Secrets Manager 读取密钥(不入 state)
data "aws_secretsmanager_secret_version" "db_pass" {
  secret_id = "prod/db/password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db_pass.secret_string
}

count vs for_each

count
基于整数创建 N 个相同类型的资源。通过 count.index 区分各实例。缺点:删除中间元素会触发后续资源重建。
for_each
基于 map 或 set 创建资源,每个 key 对应一个实例。通过稳定 key 识别资源,增删不影响其他实例。推荐方式。
# for_each 示例
resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "charlie"])
  name     = each.value
}

# map 形式(key 和 value 不同)
resource "aws_s3_bucket" "by_env" {
  for_each = {
    dev  = "us-east-1"
    prod = "us-west-2"
  }
  bucket = "my-${each.key}-${each.value}"
}
# 引用:aws_s3_bucket.by_env["prod"].id

# count 条件创建(开关模式)
resource "aws_cloudwatch_metric_alarm" "cpu" {
  count = var.enable_alarms ? 1 : 0
  alarm_name = "high-cpu"
  # ...
}

dynamic 块

variable "ingress_ports" {
  default = [
    { port = 80,  cidr = ["0.0.0.0/0"] },
    { port = 443, cidr = ["0.0.0.0/0"] },
  ]
}

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_ports
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr
    }
  }
}

for 表达式与常用函数

locals {
  # 列表转 map
  users_by_id = { for u in var.users : u.id => u.name }

  # 过滤列表
  active_users = [for u in var.users : u if u.active]

  # 常用函数
  all_ids   = flatten([module.a.ids, module.b.ids])
  merged    = merge(local.defaults, var.overrides)
  unique    = toset(var.regions)
  max_count = max(1, var.count)
}
null_resource 与 provisioner

null_resource 配合 local-exec provisioner 可以在 Terraform 中执行本地脚本,常用于无法用 Provider 资源表达的操作,如调用外部 API 或触发 CI 流水线。尽量少用,优先找专用 Provider。

数据源的深度用法

跨模块数据共享:terraform_remote_state

当基础设施拆分为多个 Terraform 项目时(如 VPC 和应用分别管理),可以用 terraform_remote_state 读取其他项目的 output 值,实现跨项目数据共享。

# 场景:应用层项目读取网络层项目的 VPC/Subnet 信息

# 在网络层项目中(terraform/network/outputs.tf)
output "vpc_id" {
  value = aws_vpc.main.id
}
output "private_subnet_ids" {
  value = [for s in aws_subnet.private : s.id]
}

# 在应用层项目中(terraform/app/main.tf)
data "terraform_remote_state" "network" {
  backend = "s3"

  config = {
    bucket = "my-company-terraform-state"
    key    = "network/terraform.tfstate"  # 网络层的 state 路径
    region = "us-east-1"
  }
}

# 读取网络层的输出值
resource "aws_ecs_service" "app" {
  network_configuration {
    # 直接引用网络层定义的子网
    subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
  }
}

resource "aws_security_group" "app" {
  # 直接引用网络层的 VPC ID
  vpc_id = data.terraform_remote_state.network.outputs.vpc_id
}
terraform_remote_state 的耦合风险

使用 terraform_remote_state 会创建隐式的项目间依赖:如果网络层修改了 output 的结构,应用层可能会受影响。替代方案:使用 AWS Parameter Store 或 SSM 存储共享值(更松耦合,不需要读取其他项目的 state 权限)。

常用数据源速查

# 当前 AWS 账号信息
data "aws_caller_identity" "current" {}
# 使用:data.aws_caller_identity.current.account_id

# 当前区域信息
data "aws_region" "current" {}
# 使用:data.aws_region.current.name  →  "us-east-1"

# 查询已有 VPC(不是 Terraform 管理的也可以)
data "aws_vpc" "existing" {
  filter {
    name   = "tag:Name"
    values = ["production-vpc"]
  }
}

# 查询所有可用区(自动适应不同区域)
data "aws_availability_zones" "available" {
  state = "available"
}
# 使用:data.aws_availability_zones.available.names
# → ["us-east-1a", "us-east-1b", "us-east-1c"]

# 查询 ACM 证书
data "aws_acm_certificate" "api" {
  domain   = "api.example.com"
  statuses = ["ISSUED"]
}

# 查询 Route53 Zone ID
data "aws_route53_zone" "main" {
  name         = "example.com"
  private_zone = false
}

count 与 for_each 的陷阱

count 的删除重建问题

这是 Terraform 初学者最容易踩的坑之一:

# 危险!使用 count 创建 3 个子网
resource "aws_subnet" "private" {
  count             = 3
  cidr_block        = cidr.subnet("10.0.0.0/16", 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]
}
# State 中:aws_subnet.private[0], [1], [2]
# 如果删除 AZ "a"(count 变为 2):
# Terraform 会删除 [2](而不是 [0]!),因为它按索引管理
# 这可能删错 subnet,导致服务中断

# ✅ 正确:使用 for_each + 可用区名称作为 key
resource "aws_subnet" "private" {
  for_each = toset(data.aws_availability_zones.available.names)

  cidr_block        = # ... 根据 AZ 名称计算 CIDR
  availability_zone = each.key
}
# State 中:aws_subnet.private["us-east-1a"], ["us-east-1b"], ["us-east-1c"]
# 删除 "us-east-1a" 只删除 ["us-east-1a"],其他不受影响

for_each 的值必须在 plan 时已知

# ❌ 错误:for_each 的值依赖于另一个资源的运行时输出
# 因为 Terraform 在 plan 阶段不知道 aws_instance.ids 的值
resource "aws_eip" "web" {
  for_each   = aws_instance.web   # ← 如果 web 是用 for_each 创建的,这是可以的
  instance   = each.value.id     # ← 但如果依赖动态数量的资源则可能报错
}

# ✅ 正确:for_each 的 key 来自静态定义的 map/set
locals {
  servers = {
    web1 = { type = "t3.medium", az = "us-east-1a" }
    web2 = { type = "t3.medium", az = "us-east-1b" }
  }
}

resource "aws_instance" "web" {
  for_each          = local.servers
  instance_type     = each.value.type
  availability_zone = each.value.az
}

条件表达式与条件资源

三元条件表达式

HCL 的条件表达式语法:condition ? true_val : false_val,与大多数编程语言相同。

# 条件表达式的各种用法
locals {
  # 根据环境选择实例类型
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

  # 多层嵌套条件(类似 if-elseif-else)
  db_class = (
    var.environment == "prod"    ? "db.r6g.xlarge" :
    var.environment == "staging" ? "db.t3.medium"  :
                                    "db.t3.micro"
  )

  # 可选属性(当条件为 false 时使用 null)
  # Terraform 会自动忽略 null 的属性
  kms_key = var.enable_encryption ? aws_kms_key.main.arn : null
}

# 条件资源(用 count = 0/1 作为开关)
resource "aws_cloudwatch_log_group" "app" {
  # 只在 dev 和 prod 创建,staging 用共享 log group
  count = var.environment != "staging" ? 1 : 0

  name              = "/app/${var.environment}"
  retention_in_days = var.environment == "prod" ? 90 : 7
}

# 引用可选资源(要用 one() 函数)
locals {
  # one() 将 list(0 or 1 elements) 转为单个值或 null
  log_group_name = one(aws_cloudwatch_log_group.app[*].name)
}

Terraform 内置函数完全参考

字符串函数

locals {
  # format:格式化字符串(类似 printf)
  bucket_name = format("my-company-%s-%s", var.environment, var.region)

  # join/split:字符串连接和分割
  tags_string = join(",", ["env=prod", "team=platform"])
  parts       = split(".", "example.com")   # → ["example", "com"]

  # replace:字符串替换
  safe_name = replace(var.name, "/", "-")  # 替换斜杠为连字符

  # trim/trimspace:去除空格
  clean = trimspace("  hello  ")  # → "hello"

  # lower/upper:大小写转换
  env_lower = lower(var.environment)   # 统一小写
}

集合函数

locals {
  # flatten:展开嵌套列表
  all_subnet_ids = flatten([
    module.vpc_a.subnet_ids,
    module.vpc_b.subnet_ids,
  ])

  # concat:合并多个列表
  all_tags = concat(var.common_tags, var.app_tags)

  # merge:合并多个 map(后者覆盖前者)
  final_config = merge(local.defaults, var.overrides)

  # distinct:去重
  unique_regions = distinct(var.regions)

  # length:列表/map 长度
  subnet_count = length(var.subnet_ids)

  # contains:检查元素是否存在
  is_prod = contains(["prod", "production"], var.environment)

  # keys/values:从 map 提取键或值
  env_names = keys(var.environments)

  # zipmap:用两个列表创建 map
  user_map = zipmap(var.user_names, var.user_roles)

  # lookup:安全地从 map 取值(不存在时返回默认值)
  instance_type = lookup(var.instance_sizes, var.environment, "t3.micro")
}

网络/编码函数

locals {
  # cidrsubnet:从 VPC CIDR 计算子网 CIDR
  # cidrsubnet(prefix, newbits, netnum)
  # newbits:在原前缀长度基础上增加的位数
  subnet_a = cidrsubnet("10.0.0.0/16", 8, 0)  # → "10.0.0.0/24"
  subnet_b = cidrsubnet("10.0.0.0/16", 8, 1)  # → "10.0.1.0/24"
  subnet_c = cidrsubnet("10.0.0.0/16", 8, 2)  # → "10.0.2.0/24"

  # cidrhost:计算 CIDR 块内的特定主机 IP
  gateway_ip = cidrhost("10.0.0.0/24", 1)  # → "10.0.0.1"(第一个主机)

  # base64encode/decode:Base64 编解码
  encoded_data = base64encode("Hello, Terraform!")

  # jsonencode/jsondecode:JSON 序列化
  policy_json = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject"]
      Resource = "*"
    }]
  })
}

null_resource 与 terraform_data

null_resource
一个"空"资源,本身不创建任何云资源,用于执行任意本地或远程命令(provisioner)。常用于在 Terraform apply 期间运行脚本,如数据库初始化、证书生成、通知发送等。需要 hashicorp/null provider。
terraform_data(Terraform 1.4+)
Terraform 内置的 null_resource 替代品,无需额外 provider。可存储任意值并在其变化时触发依赖它的资源重建。是 null_resource 的现代推荐替代方案。
provisioner
在资源创建/销毁时执行的命令块。主要类型:local-exec(在运行 Terraform 的机器上执行)和 remote-exec(通过 SSH/WinRM 在目标机器上执行)。Terraform 官方不推荐在生产中使用 provisioner,应优先使用 user_data、cloud-init 或配置管理工具(Ansible)。
triggers
null_resource / terraform_data 的触发条件 map。当 triggers 中任意值发生变化时,Terraform 会销毁并重建该 null_resource,从而重新执行其 provisioner。

null_resource 实战案例

# 引入 null provider(Terraform 1.4 之前必须)
terraform {
  required_providers {
    null = {
      source  = "hashicorp/null"
      version = "~> 3.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# 案例1:EC2 创建后执行初始化脚本
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  key_name      = aws_key_pair.deploy.key_name

  # user_data 推荐用于实例启动初始化(比 remote-exec 更可靠)
  user_data = base64encode(<<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl enable nginx
    systemctl start nginx
  EOF)
}

# 案例2:等待 EC2 可 SSH 后执行配置
resource "null_resource" "configure_web" {
  # triggers 决定何时重新执行:instance_id 不变则不重新运行
  triggers = {
    instance_id = aws_instance.web.id
    # 脚本内容变化时也重新运行
    script_hash = filemd5("${path.module}/scripts/configure.sh")
  }

  # SSH 连接配置
  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/deploy_key")
    host        = aws_instance.web.public_ip
  }

  # 上传文件到远程机器
  provisioner "file" {
    source      = "scripts/configure.sh"
    destination = "/tmp/configure.sh"
  }

  # 在远程机器执行命令
  provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/configure.sh",
      "sudo /tmp/configure.sh",
    ]
  }

  # 依赖:确保 EC2 创建完成后才执行
  depends_on = [aws_instance.web]
}

# 案例3:apply 完成后发送通知(local-exec)
resource "null_resource" "notify_deploy" {
  triggers = {
    # 每次 apply 都触发(timestamp 每次都变)
    always_run = timestamp()
  }

  provisioner "local-exec" {
    # 在本地运行:调用 Slack Webhook 发通知
    command = <<-EOF
      curl -X POST -H 'Content-type: application/json' \
        --data '{"text":"✅ 部署完成!EC2 IP: ${aws_instance.web.public_ip}"}' \
        ${var.slack_webhook_url}
    EOF
  }

  # 销毁时也发通知
  provisioner "local-exec" {
    when    = destroy
    command = "echo '⚠️ 资源即将销毁'"
  }
}

terraform_data 替代 null_resource(推荐)

# Terraform 1.4+ 内置,无需 null provider
resource "terraform_data" "bootstrap_db" {
  # input 存储任意数据,变化时触发重建
  input = {
    db_endpoint = aws_db_instance.main.endpoint
    schema_hash = filemd5("${path.module}/sql/schema.sql")
  }

  provisioner "local-exec" {
    # self.input 引用上面的 input 值
    command = <<-EOF
      psql "postgresql://admin:${var.db_password}@${self.input.db_endpoint}/mydb" \
        -f ${path.module}/sql/schema.sql
    EOF
  }
}

# terraform_data 还可以作为触发器
# 当 ami_id 变化时强制重建依赖它的资源
resource "terraform_data" "ami_version" {
  input = data.aws_ami.ubuntu.id  # 存储当前 AMI ID
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  lifecycle {
    # 当 AMI 版本数据变化时,触发实例替换
    replace_triggered_by = [terraform_data.ami_version]
  }
}
provisioner 的局限性与替代方案

time_sleep 与外部数据源

time provider:等待资源就绪

terraform {
  required_providers {
    time = {
      source  = "hashicorp/time"
      version = "~> 0.9"
    }
  }
}

# 在 EKS 集群创建后等待 30 秒再安装 add-ons
# (某些 AWS 服务需要 "传播时间")
resource "time_sleep" "wait_for_eks" {
  depends_on      = [aws_eks_cluster.main]
  create_duration = "30s"
}

resource "aws_eks_addon" "coredns" {
  cluster_name  = aws_eks_cluster.main.name
  addon_name    = "coredns"
  # 明确依赖等待资源,确保 add-on 在等待后安装
  depends_on = [time_sleep.wait_for_eks]
}

# time_rotating:生成周期性变化的时间戳(如强制定期轮换密钥)
resource "time_rotating" "api_key_rotation" {
  rotation_days = 30  # 每 30 天"到期",触发依赖重建
}

resource "aws_api_gateway_api_key" "main" {
  name = "my-api-key"
  lifecycle {
    replace_triggered_by = [time_rotating.api_key_rotation]
  }
}

external data source:调用任意外部程序

terraform {
  required_providers {
    external = {
      source  = "hashicorp/external"
      version = "~> 2.0"
    }
  }
}

# external data source 执行一个外部程序并读取其 JSON 输出
# 程序必须从 stdin 读取 JSON 输入,向 stdout 输出 JSON 对象
data "external" "instance_types" {
  program = ["python3", "${path.module}/scripts/get_instance_types.py"]

  query = {
    region      = var.aws_region
    min_vcpus   = "4"
    min_memory  = "8192"
  }
}

# 引用外部程序输出的 JSON 字段
output "best_instance_type" {
  value = data.external.instance_types.result.instance_type
}
external data source 的注意事项

外部程序必须是幂等的(多次调用返回相同结果),因为 Terraform 可能在 plan 和 apply 阶段各调用一次。外部程序不能有副作用(不能写文件、不能调用 API 创建资源)。如果需要有副作用的操作,应使用 null_resource + local-execterraform_data

depends_on 与依赖管理

隐式依赖(Implicit Dependency)
当资源 A 的属性引用了资源 B 的属性(如 resource_b.name),Terraform 自动推断 A 依赖 B,无需手动声明。这是 Terraform 依赖管理的主要方式。
显式依赖(Explicit Dependency)
当依赖关系无法通过属性引用体现时,用 depends_on 手动声明。例如:IAM 策略附加后需要等待权限传播,才能创建使用该权限的资源;数据库实例创建完成后才能运行初始化脚本。
依赖图(Dependency Graph)
Terraform 将所有资源和数据源的依赖关系构建成有向无环图(DAG),并行执行没有依赖关系的资源,串行执行有依赖关系的资源。terraform graph 命令可输出 DOT 格式的依赖图,用 Graphviz 可视化。
# 隐式依赖示例:subnet 引用了 vpc 的属性
# Terraform 自动知道先创建 vpc,再创建 subnet
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id          # 隐式依赖:引用了 vpc 的 id
  cidr_block = "10.0.1.0/24"
}

# 显式依赖示例:IAM 权限传播问题
resource "aws_iam_role_policy_attachment" "s3_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

resource "aws_lambda_function" "processor" {
  function_name = "data-processor"
  role          = aws_iam_role.lambda_role.arn

  # 即使 role ARN 已知,AWS IAM 权限传播需要时间
  # 显式声明依赖,确保策略附加完成后再创建 Lambda
  depends_on = [aws_iam_role_policy_attachment.s3_policy]
}

# 可视化依赖图(需要安装 graphviz)
# terraform graph | dot -Tpng > graph.png
depends_on 的副作用

data source 使用 depends_on 会强制它在 apply 阶段才执行查询(而非 plan 阶段),导致某些属性在 plan 阶段显示为 (known after apply),降低 plan 的预测性。如非必要,应避免对 data source 使用 depends_on

terraform console:交互式调试

terraform console 提供了一个交互式 REPL 环境,可以在不执行 apply 的情况下测试表达式、函数和 State 数据,是调试 HCL 的利器。

# 启动 terraform console(需要先 init)
terraform console

# 在 console 中测试函数
> cidrsubnet("10.0.0.0/16", 8, 0)
"10.0.0.0/24"

> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"

# 测试字符串操作
> format("%-10s = %s", "region", "us-east-1")
"region     = us-east-1"

> join(", ", ["web", "api", "db"])
"web, api, db"

> split(",", "web,api,db")
tolist([
  "web",
  "api",
  "db",
])

# 测试 for 表达式
> [for s in ["a", "b", "c"] : upper(s)]
tolist([
  "A",
  "B",
  "C",
])

# 测试 jsonencode
> jsonencode({a = 1, b = ["x", "y"]})
"{\"a\":1,\"b\":[\"x\",\"y\"]}"

# 读取当前 State 中的资源属性(需要先 apply)
> aws_vpc.main.id
"vpc-0123456789abcdef0"

> aws_vpc.main.cidr_block
"10.0.0.0/16"

# 测试局部变量(如果在 .tf 文件中定义了 locals)
> local.common_tags
{
  "Environment" = "dev"
  "ManagedBy" = "Terraform"
}

# 退出 console
> exit
# 或按 Ctrl+D
console 的实用场景

本章小结

本章核心要点