假设现在有一份 Terraform 配置,描述了一台云服务器和一个安全组。第一次执行时,Terraform 会创建它们;改一下实例名称,下一次就可能只更新名称。

同样是 terraform apply,它怎么知道这次应该创建,下一次应该修改?又怎么知道服务器应该绑定刚刚创建的那个安全组?

这两个问题把 Terraform 的主要概念连了起来:配置表达期望,state 记录资源身份,Provider 查询和操作远端对象,Core 根据这些信息组织执行计划。

Core 与 Provider 怎样分工?

Terraform Core 负责读取配置、处理表达式、组织资源依赖,以及协调计划和执行。具体怎样调用腾讯云的接口,交给腾讯云 Provider。

可以按下面这条路径理解一次操作:

1
2
3
4
5
6
7
8
9
.tf 配置与输入变量
↓
Terraform Core ←→ state
↓
腾讯云 Provider
↓
腾讯云 API
↓
真实的网络、安全组和服务器

Provider 定义了资源有哪些字段、怎样读取资源,以及哪些属性变化需要原地更新或替换。Core 和 Provider 会共同参与计划计算,不能仅凭 HCL 语法判断一次修改的后果。

这也解释了为什么换一个云平台时,CLI 命令可以保持一致,资源类型和参数却要重新学习。

先分清插件声明和插件配置

课件里的腾讯云示例使用这样的依赖声明:

1
2
3
4
5
6
7
8
9
10
11
12
terraform {
required_providers {
tencentcloud = {
source = "tencentcloudstack/tencentcloud"
version = "~> 1.81.111"
}
}
}

provider "tencentcloud" {
region = "ap-guangzhou"
}

required_providers 指定使用哪个插件及允许的版本范围。provider 块则配置插件怎样访问平台,这里选择了广州地域。访问凭证可以由环境变量提供。

版本号沿用课程示例,用来解释约束语法,不代表当前推荐版本。~> 1.81.111 允许从 1.81.111 开始的 1.81.x 版本,不允许升级到 1.82.0。如果写成 ~> 1.81,允许的范围就会扩展到小于 2.0.0 的版本。

首次初始化后,Terraform 会把选中的 Provider 版本与校验信息写入 .terraform.lock.hcl。后续正常执行 init 时,只要锁定版本仍满足约束,就优先复用它。需要主动升级时再执行 terraform init -upgrade,并检查锁文件及计划的变化。

因此需要同时保留版本约束和锁文件。前者表达允许范围,后者记录这份配置实际选择的版本。这个锁文件主要锁 Provider,不会替你锁住所有远程模块版本。

读懂 HCL 中的几个名字

HCL 是 HashiCorp Configuration Language。Terraform 使用它编写配置,常见组成包括配置块、参数、表达式和引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
variable "environment" {
type = string
default = "dev"
}

locals {
name_prefix = "${var.environment}-web"
}

resource "tencentcloud_security_group" "web" {
name = "${local.name_prefix}-sg"
description = "Security group for the web environment"
}

output "security_group_id" {
value = tencentcloud_security_group.web.id
}

variable 定义调用者可以传入的参数。locals 用来保存配置内部计算出来的值,例如多处会使用的名称前缀。resource 声明由 Terraform 管理的对象,output 则暴露执行结果或模块接口。

资源声明里的两个字符串尤其容易混淆:tencentcloud_security_group 是资源类型,web 是当前模块中的本地名称。云控制台里看到的名字由 name 参数决定。

tencentcloud_security_group.web.id 表示读取这个资源的 ID。创建之前,ID 可能还不知道,所以计划中会出现 (known after apply)。这种未知值通常是正常的,Terraform 会在执行获得结果后继续处理依赖它的资源。

字符串里混入其他内容时可以用 ${...},单纯引用一个值则直接写 var.environment 或资源属性即可。

查询已有信息,用 data

课件通过数据源查询公共镜像:

1
2
3
4
data "tencentcloud_images" "ubuntu" {
image_type = ["PUBLIC_IMAGE"]
os_name = "ubuntu"
}

这里读取平台上的镜像信息,不会因为查询就把这些镜像纳入 Terraform 的创建和销毁管理。

resource 和 data 的区别,可以用后续责任来判断:前者通常负责管理对象生命周期,后者负责获取信息。若要接管一个已经存在的资源,还需要配置与导入流程,写一个数据源不能完成接管。

查询结果也可能变化。课件中直接使用结果列表的第一项,便于演示;要复现一个确定环境,就应检查过滤条件和查询结果,必要时固定镜像 ID 与实例规格。

资源创建顺序从哪里来?

假设实例配置中存在这一行:

1
orderly_security_groups = [tencentcloud_security_group.web.id]

实例引用了安全组的 ID,Terraform 就能识别依赖:先获得安全组,才能继续创建并绑定实例。.tf 文件叫 01-network.tf 还是 99-network.tf,不会决定这两个资源的执行顺序。

同一个目录下的 Terraform 配置文件会组成一个模块。工具依据引用关系建立依赖图;没有依赖的操作可以并行,存在依赖的操作则按关系安排。

有些依赖仅靠属性引用看不出来。课件中的实例除了绑定安全组,还要求安全组规则先创建完成,因为后续步骤马上需要通过 SSH 连接。实例没有直接引用规则资源的某个属性,这时可以补充:

1
depends_on = [tencentcloud_security_group_lite_rule.web]

这是一段资源块内部的配置片段。depends_on 补的是执行依赖,不能代替应用健康检查。机器已经创建,SSH 仍可能暂时不可用;Kubernetes API 能连接,某个业务 Pod 也未必已经准备好。

用一轮命令看清完整过程

在一个已经写好配置的目录中,可以执行:

1
2
3
4
5
6
terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

init 准备工作目录、初始化 backend,并获取需要的 Provider 和模块。fmt 检查排版,validate 检查配置语法和内部一致性。即使它们都通过,也不能证明云账号有足够配额、所选机型有库存或实例一定能够启动。

plan 读取相关状态并生成变更计划,通常会访问云 API,但不会执行计划中的资源创建、修改和删除。计划中的几个常见符号是:

标记 含义 需要关注的内容
+ 新建 数量、规格、网络范围是否正确
~ 原地更新 修改哪些属性,会不会影响访问
- 删除 是否确实要移除这个对象
-/+ 删除后重建 为什么需要替换,数据和访问入口怎样处理
+/- 创建后删除 是否允许同时存在两个对象,配额是否足够

-out=tfplan 保存了这次计划。检查后执行 terraform apply tfplan,会直接应用已保存的计划,不再等待交互确认。若直接运行 terraform apply,Terraform 会重新生成计划并请求确认,两种用法要分清。

计划文件也可能包含敏感数据,不应提交到公开仓库。执行前若配置、state 或外部条件已经变化,应重新规划;旧计划并不保证永远可以成功应用。

state 不只是执行记录

state 把配置中的资源地址映射到真实对象,同时记录资源属性及管理所需的信息。它既可能保存在本地,也可以通过 backend 存储到远端。

如果丢失 state,仅有相同的配置,Terraform 也不一定能认出云上已经创建好的机器。再次执行可能尝试创建新资源,或者因为名称冲突而失败。这时需要恢复状态或导入资源,不能把“重新 apply 一次”当作通用修复。

几个常用命令的影响范围需要记清:

命令 作用
terraform state list 列出 state 中记录的资源地址
terraform state show ADDRESS 查看一个受管资源的已记录属性
terraform state pull 将当前 backend 中的 state 输出到标准输出
terraform state rm ADDRESS 移除管理映射,保留真实资源
terraform plan -refresh-only 预览怎样根据远端现状更新 state 和输出

笔记中提到了 terraform refresh。现在更适合先使用 refresh-only 计划检查变化,再决定是否应用,让状态更新也有一次审查机会。

state rm 尤其不能当作“删除资源失败时的清理按钮”。它移除映射后,云服务器仍可能存在并计费;如果配置声明仍然保留,下次计划还可能尝试再建一个。

多人协作时,需要共享同一份环境状态,并根据 backend 的能力配置锁、访问权限和版本保护。支持状态锁的 backend 可以阻止冲突写入,但不同环境仍应使用不同的状态路径。远程存储解决不了环境混用和权限配置错误。

变量、文件和模块怎样组织?

单文件适合起步,配置增多后可以按职责拆开:

1
2
3
4
5
6
terraform-demo/
├── versions.tf
├── variables.tf
├── cvm.tf
├── outputs.tf
└── terraform.tfvars

versions.tf 写依赖约束,variables.tf 写输入声明,cvm.tf 写资源,outputs.tf 写输出。文件名是组织约定,Terraform 不会因为名字不同而分阶段执行它们,也不会自动把子目录里的资源一并加载。

变量值可以从默认值、环境变量和变量文件等位置提供。在常规本地 CLI 工作流中,覆盖优先级从低到高为:变量默认值、TF_VAR_ 环境变量、terraform.tfvars、terraform.tfvars.json、按文件名字典序加载的 *.auto.tfvars / *.auto.tfvars.json,最后是命令行中的 -var 与 -var-file。命令行赋值按出现顺序处理。

这也是“明明改了默认值,运行结果却没变”的一个排查方向:检查是否有优先级更高的赋值。

需要复用一组资源时,再提取 module。子模块声明输入,管理内部资源,通过 output 暴露结果;调用它的根模块决定使用哪组环境参数。下一篇的 CVM 与 K3s 就适合用这种接口连接。

count 与 for_each 解决什么问题?

count 按数量创建实例,资源地址使用数字索引,例如 web[0]。相同配置的多个副本,用它比较直接。

如果实例对应一个会增删成员的名称列表,就要考虑索引变化。删除列表中间的元素,后续元素会前移,可能触发意料之外的更新或替换。for_each 使用 map 的键或字符串 set 的元素作为身份,例如 web["api"],更适合需要稳定名称的对象。

这里可以区分三个容易混在一起的概念:for 表达式转换集合值;for_each 创建多个资源或模块实例;dynamic 块生成重复的嵌套配置块。它们解决的问题不同,不能互相替换。

生命周期规则能帮到哪一步?

lifecycle 可以调整资源变更行为,几个常见选项包括:

  • create_before_destroy:资源替换时先尝试创建新对象,再删除旧对象。它需要额外配额,也可能受到唯一名称限制。业务零停机还要配合流量切换和就绪检查。
  • prevent_destroy:配置保留该规则时,阻止会销毁对应资源的计划。如果整个资源块被移除,规则也随之消失,不能把它当作平台级的永久删除保护。
  • ignore_changes:在规划更新时忽略指定属性的变化,适合确实由其他系统维护的属性。范围过大,会把应该检查的漂移也隐藏掉。
  • replace_triggered_by:根据指定受管资源或其属性的变更触发替换。

我会在能明确说出“默认行为哪里不合适”时再加这些规则。否则配置虽然更长,维护者却更难看清哪些差异会被忽略、哪些对象会被替换。

最后还有 provisioner,可以在本地或远程执行命令、传输文件。课件用它下载 K3s 的 kubeconfig,很适合理解系统之间如何衔接。不过脚本内部的副作用并不会自动变成 Terraform 可完整管理的资源。日常软件配置仍可以考虑镜像预置、cloud-init 或 Ansible。

下一篇《Terraform实践》会把这些概念落到一台腾讯云服务器上:先生成计划,创建并检查资源,再做一次小范围修改,最后验证清理结果。

参考资料