前两篇《IaC和Terraform概述》和《Terraform概述》把配置、Provider 和 state 串了起来。开始操作之前,还需要弄清凭证放在哪里、创建失败后怎样继续,以及实验结束时怎么确认资源已经清理干净。

课程的 module_2 恰好提供了一条逐渐展开的路线:先创建腾讯云 CVM 和安全组,再引入远程状态,接着安装 K3s、部署 Argo CD,最后提取模块管理不同环境。

下面先整理一个可以独立操作的 CVM 实验,再分析后面的组合方式。示例基于课程代码调整,需要填入自己的云账号资源参数;这里没有提供一次真实云部署的成功记录。

先确定这次实验管理哪些资源

这次创建一台 CVM、一个安全组和对应规则,复用已有的 VPC、子网。这样第一次实验就可以集中观察资源创建、依赖和 state,清理时也不会涉及已有网络的生命周期。

准备时需要确认:

  • 已安装 Terraform,terraform version 能正常输出版本。
  • 云账号具备相关资源的查询和管理权限,并有可用额度。
  • 已有 VPC 和子网,子网所属地域、可用区与实例配置一致。
  • 已确认可用的 Ubuntu 镜像 ID、实例规格和系统盘类型。
  • 知道当前管理端的公网出口 IP,用于限制 SSH 的来源。

课件直接查询可用区、镜像和机型,并取结果列表的第一项。这种写法容易看懂,但几个独立列表的第一项未必能组成一套可用配置。这里把结果改为显式输入,先在同一可用区确认一组可购买的参数,再交给 Terraform。

实验资源会产生费用,包括实例和可能产生的网络费用。创建前检查计费方式,用完后检查销毁结果。

配置文件怎么写?

新建一个空目录,放入下面四个文件。它们共同组成一个根模块。

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

versions.tf:依赖与访问地域

Provider 版本范围沿用课程 demo_2,用于这份示例。升级版本时要重新查看计划,不能把这里的旧版本范围当作长期维护建议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
terraform {
required_version = ">= 1.5.0, < 2.0.0"

required_providers {
tencentcloud = {
source = "tencentcloudstack/tencentcloud"
version = "~> 1.81.111"
}
}
}

provider "tencentcloud" {
region = var.region
}

凭证通过 Provider 支持的环境变量读取,不写进配置。下面的 shell 命令会静默读取输入,避免把真实密钥直接写进命令文本:

1
2
3
4
5
6
# 在 Bash 中执行
read -r -s -p 'TencentCloud SecretId: ' TENCENTCLOUD_SECRET_ID
printf '\n'
read -r -s -p 'TencentCloud SecretKey: ' TENCENTCLOUD_SECRET_KEY
printf '\n'
export TENCENTCLOUD_SECRET_ID TENCENTCLOUD_SECRET_KEY

variables.tf:把环境选择留在输入侧

地域、可用区、镜像和实例规格没有通用的正确组合,所以这里不替它们提供默认值。管理端 CIDR 也需要自己填写,例如单个出口地址应使用 /32。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
variable "region" {
type = string
}

variable "availability_zone" {
type = string
}

variable "vpc_id" {
type = string
}

variable "subnet_id" {
type = string
}

variable "image_id" {
type = string
}

variable "instance_type" {
type = string
}

variable "system_disk_type" {
type = string
}

variable "admin_cidr" {
type = string
description = "Trusted management source CIDR"
}

variable "instance_name" {
type = string
default = "tf-learning-web"
}

variable "password" {
type = string
sensitive = true
description = "Instance login password meeting the cloud password policy"
}

创建自己的 terraform.tfvars,填写上述非敏感参数。可以使用下面这份模板,但所有占位值都要替换,地域和可用区也要按自己的环境确认:

1
2
3
4
5
6
7
8
region            = "ap-guangzhou"
availability_zone = "REPLACE_WITH_YOUR_ZONE"
vpc_id = "REPLACE_WITH_YOUR_VPC_ID"
subnet_id = "REPLACE_WITH_YOUR_SUBNET_ID"
image_id = "REPLACE_WITH_YOUR_UBUNTU_IMAGE_ID"
instance_type = "REPLACE_WITH_AVAILABLE_INSTANCE_TYPE"
system_disk_type = "REPLACE_WITH_SUPPORTED_DISK_TYPE"
admin_cidr = "REPLACE_WITH_YOUR_PUBLIC_IP/32"

实例密码单独输入:

1
2
3
4
# 在 Bash 中执行
read -r -s -p 'Instance password: ' TF_VAR_password
printf '\n'
export TF_VAR_password

sensitive = true 会隐藏常规输出中的明文显示,但相关值仍可能进入 state 和保存的计划文件。它解决显示问题,存储保护还需要依靠文件权限和 backend 配置。

main.tf:资源与依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
resource "tencentcloud_security_group" "web" {
name = "${var.instance_name}-sg"
description = "Security group for Terraform learning"
}

resource "tencentcloud_security_group_lite_rule" "web" {
security_group_id = tencentcloud_security_group.web.id

ingress = [
"ACCEPT#${var.admin_cidr}#22#TCP",
]

egress = [
"ACCEPT#0.0.0.0/0#ALL#ALL",
]
}

resource "tencentcloud_instance" "web" {
depends_on = [tencentcloud_security_group_lite_rule.web]

instance_name = var.instance_name
availability_zone = var.availability_zone
image_id = var.image_id
instance_type = var.instance_type
instance_charge_type = "POSTPAID_BY_HOUR"
system_disk_type = var.system_disk_type
system_disk_size = 50
vpc_id = var.vpc_id
subnet_id = var.subnet_id
allocate_public_ip = true
internet_max_bandwidth_out = 5
orderly_security_groups = [tencentcloud_security_group.web.id]
password = var.password
}

实例对安全组 ID 的引用会形成隐式依赖。这里另外等待规则资源完成,是为了让创建结束后的 SSH 检查有明确前提。

课件部分示例采用竞价实例 SPOTPAID,并开放 SSH 和 K3s API。这个起步实验改为按量计费,只开放来自管理端的 SSH,先减少竞价回收和集群安装带来的变量。按量实例同样需要检查账户余额、库存与资源配额。

outputs.tf:输出后续需要的信息

1
2
3
4
5
6
7
8
9
10
11
output "instance_id" {
value = tencentcloud_instance.web.id
}

output "public_ip" {
value = tencentcloud_instance.web.public_ip
}

output "private_ip" {
value = tencentcloud_instance.web.private_ip
}

后续检查资源需要 ID,连接主机需要 IP。密码已经由输入侧提供,不必再输出一次。

第一次执行,怎么判断每一步是否完成?

先在实验目录运行:

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

在一个没有既有 state 的新目录里,按这份配置生成的计划应包含安全组、规则和实例三个受管资源的创建。数据和环境条件不同,输出细节可能不同,但不应该出现对已有 VPC、子网的删除。

检查实例规格、磁盘、计费方式、网络和入站来源后,再执行:

1
2
3
terraform apply tfplan
terraform output
terraform state list

应用保存的计划不会再次询问 yes,前一步的审查就是确认动作。执行成功后,state list 应包含这三个地址:

1
2
3
tencentcloud_instance.web
tencentcloud_security_group.web
tencentcloud_security_group_lite_rule.web

这只能证明 Terraform 已经记录了资源。还要到云控制台核对实例状态、网络和安全组,再尝试 SSH:

1
ssh ubuntu@<terraform-output-public-ip>

这里的 ubuntu 需要与实际镜像的登录用户一致。如果连不上,先检查管理端出口 IP 是否变化、22 端口规则是否正确、实例是否完成启动,以及镜像是否允许所配置的认证方式。

不要因为 SSH 不通就立刻重新创建机器。若问题是来源地址不匹配,重建不会解决它。

再改一次,观察 Terraform 怎样处理变化

在 terraform.tfvars 中增加或修改:

1
instance_name = "tf-learning-web-v2"

然后重新规划:

1
2
3
4
terraform plan -out=rename.tfplan
terraform show rename.tfplan
terraform apply rename.tfplan
terraform plan -detailed-exitcode

由于安全组名称也引用了 instance_name,计划可能同时包含实例和安全组的名称更新。以实际计划为准;如果出现替换,先检查原因,再决定是否执行。

最后一个命令的退出码为 0 时表示没有差异,2 表示计划中有变化,1 表示错误。在输入和远端资源都没有继续变化的前提下,期望看到无变更。

第一次 apply 若中途失败,成功创建的资源通常已经进入 state。先定位失败原因,保留状态,再生成新计划,查看剩余工作。清空 state 会让问题变成“云上有资源,Terraform 却不再认识它”。

从本地 state 迁移到 COS

个人练习可以先用本地状态。换电脑或者交给另一个人执行时,就需要避免各自拿着不同的状态记录工作。

课程 demo_3 展示了 COS backend。目标 Bucket 必须预先存在,并且执行者具有访问权限。学习阶段可以通过独立配置或控制台准备 Bucket,避免让同一套短期实验同时负责保存和销毁自己的状态存储。

在实验根模块新增 backend.tf:

1
2
3
4
5
6
7
8
terraform {
backend "cos" {
region = "ap-guangzhou"
bucket = "YOUR-STATE-BUCKET-APPID"
prefix = "terraform/learning/cvm"
encrypt = true
}
}

Bucket 名称要填写完整名称,backend 的地域对应 Bucket 所在地域。这里使用示例前缀,其他环境应使用独立路径。backend 配置不能直接使用普通的 var.* 引用。

迁移前先备份当前状态,再初始化迁移:

1
2
3
4
5
umask 077
terraform state pull > state-before-migration.json
terraform init -migrate-state
terraform state list
terraform plan

核对迁移提示中的来源和目标。完成后,应能在 COS 中看到状态对象,资源地址保持一致,计划也不应因为迁移而突然要求重建环境。

encrypt = true 用于启用相应的服务端加密,Bucket 的私有访问、权限、版本保护仍需单独配置。备份文件也包含状态信息,不要提交到 Git。

迁移 backend 时保持受管资源配置不变。若为了迁移先把资源声明注释掉,后续 plan 可能把它理解为要求删除资源。terraform init -migrate-state 处理的是状态存储位置,资源删除是另一件事。

继续接上 K3s 和 Argo CD,会遇到什么?

demo_4 把流程拆成了 cvm.tf、k3s.tf 和 helm.tf。文件拆开之后,依赖仍然存在:

1
2
3
4
5
6
7
8
9
安全组和规则
↓
CVM 创建完成,SSH 可用
↓
安装 K3s
↓
取得 kubeconfig,确认 Kubernetes API 可访问
↓
Helm 安装 Argo CD

课程中的 K3s 模块引用服务器的公网和私网 IP,再通过 SSH 安装。后面使用 remote-exec 在服务器上准备 kubeconfig,使用 local-exec 将它下载到执行 Terraform 的机器。

这段代码很适合解释 provisioner 的位置:remote-exec 在远端执行,local-exec 在本地执行。换到 CI runner 后,本地路径、SSH 密钥和工具是否存在,都要重新考虑。provisioner 通常伴随资源创建等生命周期事件执行,远端文件丢失也不会自动触发一次通用修复。

如果沿这条路线继续做,需要允许管理端访问 K3s API,并检查服务就绪和 kubeconfig。课程的具体 K3s 版本与模块来源属于当时的示例,复现前应核对兼容性和模块版本。

为什么加了 depends_on,Helm 仍可能失败?

课程的 Helm Provider 读取本地 config.yaml,这个文件却是在同一轮执行中稍后下载的。即使给 helm_release 加上资源依赖,Provider 在规划、刷新或初始化相关操作时,也可能已经需要读取文件或连接集群。

因此,把文件生成与 Provider 初始化挤在同一次执行里,容易产生先后矛盾。我更倾向于在练习里划分两个执行目录:

1
2
infra/    # CVM、K3s,以及取得 kubeconfig
apps/ # 在集群已可访问后,通过 Helm 安装应用

先完成 infra,确认 kubeconfig 可用,再初始化 apps。下面是应用层的独立配置示例,使用课程中的 Helm 2.x Provider 语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = "~> 2.14"
}
}
}

variable "kubeconfig_path" {
type = string
}

variable "argocd_chart_version" {
type = string
}

provider "helm" {
kubernetes {
config_path = var.kubeconfig_path
}
}

resource "helm_release" "argo_cd" {
name = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
version = var.argocd_chart_version
namespace = "argocd"
create_namespace = true
}

kubeconfig_path 传入已经存在的文件路径,Chart 版本选择与集群兼容的版本。上面没有包含 K3s 安装器,完整安装过程仍需结合 demo_4 检查,不能将这个应用层片段直接追加到前面的 CVM 实验后就认为集群已经存在。

应用部署后可以检查:

1
2
kubectl --kubeconfig /path/to/config.yaml get nodes
kubectl --kubeconfig /path/to/config.yaml -n argocd get pods

把基础设施层和应用层拆开,也意味着两个目录需要独立 state。两层的接口是集群访问配置,执行顺序则由人工或流水线明确安排。

dev 和 testing 怎样复用?

复制整个目录能快速得到第二套环境,但公共逻辑改动后就要修改多份代码。课件 demo_5 用模块解决这部分重复:

1
2
3
4
5
6
environments/
├── modules/
│ ├── cvm/
│ └── k3s/
├── dev/
└── testing/

cvm 模块输出公网和私网 IP,k3s 模块接收这些输出。下面是模块接口的连接片段,前提是模块已经声明对应输入和输出:

1
2
3
4
5
6
7
8
9
10
11
12
module "cvm" {
source = "../modules/cvm"
region = var.region
password = var.password
}

module "k3s" {
source = "../modules/k3s"
public_ip = module.cvm.public_ip
private_ip = module.cvm.private_ip
password = var.password
}

这里有两个值得检查的细节。课件中根模块声明了 region,但调用 CVM 模块时没有传下去;K3s 模块也有密码输入,调用处却依赖默认值。只修改根模块的地域或密码后,模块内部未必会跟着改变。

所以检查模块化代码时,我会沿着“根变量、模块参数、模块内部引用”逐段核对。把文件搬进 modules 目录,并不能保证输入已经正确接通。

上面省略了课程中的凭证参数,按前面的方式交给 Provider 从环境变量读取。整理成通用模块时,还可以在根模块配置 Provider,子模块声明所需 Provider 并继承调用者的配置,减少账号和地域配置散落在内部的问题。

dev 和 testing 各自配置输入和 backend 路径,不能只换一个实例名称却共用同一份状态。模块代码更新后,也需要分别进入各环境查看计划、决定何时应用;模块复用不会自动更新已经运行的环境。

如果已有资源从根模块迁入子模块,还要处理资源地址变化。应使用 moved 块或合适的状态迁移方式保持映射,先确认 plan 不会把“整理代码”解释成“删旧建新”。

实验结束,确认资源真的被清理

对于前面独立的 CVM 实验,可以先生成销毁计划:

1
2
3
4
terraform plan -destroy -out=destroy.tfplan
terraform show destroy.tfplan
terraform apply destroy.tfplan
terraform state list

检查计划只包含这个目录管理的实验资源。已有 VPC、子网是输入参数,不在销毁范围内;独立准备的 COS Bucket 也应继续保留。

如果已经拆成 infra 和 apps,先在 apps 销毁应用,再到 infra 销毁集群和服务器。先删集群,Helm Provider 就可能无法访问 Kubernetes API,无法正常完成应用清理。

课件 README 中先用 state rm 移除 K3s 和 Argo CD 的记录,再销毁服务器。这会跳过相应对象的正常管理流程,不适合作为默认清理步骤。若发现销毁失败,先恢复必要的访问条件、检查依赖和远端对象,再决定如何处理状态。

最后到云控制台核对实验资源是否仍然存在,以及是否还残留需要付费的关联资源。state 为空只是一次状态检查,不能替代对云上环境的核对。

哪些文件放进 Git?

通常提交 .tf、必要的模块代码和 .terraform.lock.hcl。本地目录可以加入以下忽略规则:

1
2
3
4
5
6
7
8
9
10
11
12
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
tfplan
terraform.tfvars
*.auto.tfvars
state-before-migration.json
config.yaml
sshkey/
crash.log
crash.*.log

这份规则按文中的文件名编写。若换了计划文件或 kubeconfig 名称,要同步调整;用于分享的变量示例应使用占位值。真实 state、密码、私钥和可访问集群的配置都不属于公开博客的附件。

完成这一轮后,可以保留配置和一次经过脱敏的计划检查笔记:创建了哪些资源,改名影响了哪些字段,哪些对象复用已有资源,销毁时又检查了什么。下次增加 K3s 或新的模块时,就有一套可以对照的执行过程。

参考资料