HCL 语法 — CodeRoadMapHCL 语法精讲
这一课你将能做到
- 熟练使用 HCL 的块、参数、引用、字符串插值与条件表达式
- 用
count 和 for_each 批量生成资源,并说出两者的关键区别
- 用
lifecycle 规则防止资源被意外替换或销毁
为什么要学
第 1 课的配置只有一个桶,手写没问题。可本站真实部署时,安全组规则可能有十几条、子网要在 3 个可用区各建一个、环境变量要按环境切换——逐条复制粘贴会让配置变成改一处漏三处的维护噩梦。HCL 提供了循环、条件、集合变换等一整套表达式,让你用几十行声明几百个资源。
另外,Terraform 最危险的动作不是新建而是「误删、误替换」:改一个 RDS 的参数,有时意味着删掉旧实例重建,数据全没。lifecycle 就是你给关键资源上的安全锁。这一课解决「写得少」和「删不掉(意外)」两个问题。
先建立图像
HCL 的结构可以类比 Java:
块 block ≈ 一个带类型的对象声明 resource "aws_s3_bucket" "web" { ... }
参数 argument ≈ 对象的字段赋值 bucket = "xxx"
表达式 expression ≈ 等号右边的值 var.env == "prod" ? 1 : 0
引用 reference ≈ 访问另一个对象的字段 aws_s3_bucket.web.id
批量生成的两种心智模型:
count = 一排编号座位:resource[0]、resource[1]、resource[2]
for_each = 一个 Map 查表:resource["a"]、resource["b"](键稳定,删中间不挪位)
跟着做
步骤 1:块、参数与引用
新建 hcl-demo/main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_s3_bucket" "assets" {
bucket = "coderoadmap-hcl-demo-2026"
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id # 引用:表达依赖,必须先有桶
versioning_configuration {
status = "Enabled"
}
}
引用格式永远是 <块类型>.<资源类型>.<名称>.<属性>,如 aws_s3_bucket.assets.id。引用即依赖,Terraform 据此决定创建顺序。
步骤 2:字符串、插值与条件
locals {
project = "coderoadmap"
env = "dev"
bucket_name = "${local.project}-${local.env}-assets" # 插值
is_prod = local.env == "prod"
# 条件表达式:条件 ? 真值 : 假值
instance_count = local.is_prod ? 3 : 1
# 集合
azs = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
port_map = { http = 80, https = 443 }
}
locals 块里定义的是「局部值」,相当于代码里的常量/中间变量,用 local.xxx(单数)引用。HCL 字符串支持转义和常见函数:lower("ABC")、format("%s-%d", "web", 1)、join("-", ["a", "b"])。
步骤 3:用 count 批量生成
为本站在 3 个可用区各建一个子网:
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
}
resource "aws_subnet" "main" {
count = length(local.azs) # count 只能是整数
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = local.azs[count.index]
tags = {
Name = "subnet-${count.index}"
}
}
count.index 从 0 开始;cidrsubnet("10.0.0.0/16", 8, 0) 会算出 10.0.0.0/24,传 1 得到 10.0.1.0/24。生成后用 aws_subnet.main[0].id、aws_subnet.main[1].id 访问;要整组引用写 aws_subnet.main[*].id(splat 表达式)。
步骤 4:用 for_each 按键生成
count 的致命伤:列表中间删掉一项,后面的资源会整体「挪位」并被替换。比如删掉第 1 个可用区,subnet[1] 的内容从 c 变成 d,Terraform 认为要重建。用 for_each 改写:
locals {
subnets = {
"a" = { az = "ap-northeast-1a", cidr = "10.0.0.0/24" }
"c" = { az = "ap-northeast-1c", cidr = "10.0.1.0/24" }
"d" = { az = "ap-northeast-1d", cidr = "10.0.2.0/24" }
}
}
resource "aws_subnet" "main" {
for_each = local.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = "subnet-${each.key}"
}
}
资源按键寻址:aws_subnet.main["a"]。删掉 "c" 这一项,只有 c 的子网被删,a、d 纹丝不动。规则:资源需要长期稳定身份、集合可能增删中间元素时,一律 for_each;只是简单按数量开关时用 count。
步骤 5:for 表达式、动态块与 lifecycle
locals {
# for 变换:从对象列表投影出名称列表
subnet_names = [for k, v in local.subnets : k]
# => ["a", "c", "d"]
common_tags = {
Project = "coderoadmap"
ManagedBy = "terraform"
}
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "本站 Web 层安全组"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = [{ port = 80 }, { port = 443 }]
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
tags = local.common_tags
}
resource "aws_db_instance" "main" {
identifier = "coderoadmap-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
db_name = "coderoadmap"
username = "road"
password = "replace-with-secret-manager"
skip_final_snapshot = true
deletion_protection = true # AWS 侧删除保护
lifecycle {
# 任何会导致销毁重建的变更,plan 阶段直接报错
prevent_destroy = true
# 忽略外部对 tags 的改动(如成本系统自动打标)
ignore_changes = [tags["CostCenter"]]
}
}
dynamic "ingress" 会在资源内部展开成多个 ingress {} 块,适合规则来自变量的场景。为节省练习费用,DB 这段可以只 terraform validate 不 apply,重点理解 lifecycle。
讲清楚要点
- 三种批量手段各司其职:
count 按整数索引、for_each 按稳定键值、dynamic 块在单个资源内部生成重复嵌套块,别混用。
- for_each 的键必须稳定且已知:只能用 string 集合或键为 string 的 map,且不能依赖 apply 后才知道的值(比如用资源自身的 id 做键)。
lifecycle.prevent_destroy 是 plan 期硬拦截:一旦设置,任何销毁动作(包括 terraform destroy)直接失败,要先删掉该规则;给 RDS、ElastiCache、生产 S3 桶加上它,代价低收益极大。
ignore_changes 用于「代码不管的字段」:如自动扩缩组的 desired_size、外部系统写入的标签,写 ignore_changes = all 要极其谨慎,等于放弃该资源的漂移治理。
create_before_destroy 解决替换断服:某些不可变资源(如 ELB listener 规则、Launch Template)替换时,加 create_before_destroy = true 让新资源先建好再删旧的。
你可能会懵的地方
-
count = 0 时为什么引用报错?
资源不存在,aws_subnet.main[0].id 取值失败。要用整体引用 aws_subnet.main[*].id,或用条件 length(aws_subnet.main) > 0 ? aws_subnet.main[0].id : null。
-
for_each 里 each.key 和 each.value 分别是什么?
for_each 一个 list 时,key 和 value 相同(key 是字符串化的索引,不推荐);for_each 一个 map 时,key 是键、value 是值。工程实践统一传 map。
-
locals 和 variable 有何区别?
variable 是外部传入的「入参」,调用方可以改;locals 是配置内部的计算结果,外部无法直接赋值。敏感且可配置的值走 variable,派生值走 locals。
-
字符串里怎么写 $ 不被当插值?
用 $$ 转义;插值语法只认 ${ ... },单独的 $ 普通出现不受影响。
-
HCL 能不能写循环打印日志、写文件?
不能,HCL 是描述「值」的语言,没有命令式语句。需要副作用逻辑请用 provisioner、外部脚本或 external data source。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 用 count 管理可能增删的命名资源 | 删中间元素导致后续资源全部替换 | 改用 for_each + 稳定键 |
| for_each 直接传 list | 键退化成 "0"/"1",失去稳定性 | toset(list) 或构造 map |
prevent_destroy 加到桶上后困惑 destroy 失败 | 这正是保护在生效 | 确实要删先移除该规则再 apply |
dynamic 块内用 ingress.key 但 for_each 是 list | list 无稳定键 | 传 map 或改用 ingress.value |
在资源里写 count 又写 for_each | 语法直接报错,二者互斥 | 二选一 |
卡点急救
The "for_each" value depends on resource attributes that cannot be determined until apply:for_each 的键不能来自尚未创建资源的属性,把键改成配置中已知的值(变量、locals、data 查询)。
- 批量资源报键重复
Duplicate object key:map 里有重复键或 set 里有重复字符串,terraform console 里打印该集合检查。
- 改了个小参数却显示资源要被替换(forces replacement):看 plan 中标
forces replacement 的字段(如 RDS 的 engine、S3 桶名),这类字段不可变;确认接受替换前先备份,并评估能否用 create_before_destroy。
面试常问
-
count 和 for_each 的区别?生产中怎么选?
答出索引寻址 vs 键寻址、删除元素时 count 导致后续资源位移重建、for_each 仅影响对应键;结论是「稳定身份的资源用 for_each,数量开关用 count」。
-
如何防止 Terraform 意外删掉生产数据库?
多层防护:资源上 lifecycle { prevent_destroy = true },云厂商侧 deletion_protection / 终止保护,CI 中拦截 plan 里的 destroy 动作(如 grep "Plan:.*destroy" 或策略即代码),最小时权限的 IAM role。
-
ignore_changes 一般用在什么场景?
资源属性由外部系统(autoscaling、运维脚本、成本标签系统)持续修改时,避免 Terraform 每次 plan 都报漂移回改。
自测三问
- 把一个 3 元素的安全组端口 list 从 count 改成 for_each,资源地址写法会发生什么变化?
prevent_destroy 和云厂商自带的删除保护有什么区别?为什么建议都开?
dynamic 块解决的是什么问题?它和 for_each 元参数作用层级有何不同?
小结一句话
HCL 用 count/for_each/dynamic 三类批量构造和 lifecycle 安全规则,让配置既能简洁地描述大量资源,又能在 plan 阶段挡住破坏性变更。
延伸