别再靠运气部署了:Terraform + Terratest 让基础设施也能自动化测试
一、基础设施代码,真的可以不测试吗?
不知道你有没有遇到过这样的情况。
运维人员写了一份 Terraform 配置,在本地执行 terraform plan,看了一眼,没有明显问题,于是直接执行 terraform apply。
几分钟后,云服务器创建成功,安全组也配置完成,看起来一切正常。
但真正的问题可能才刚刚开始。
- ECS 创建了,但是安全组意外开放了不该开放的端口。
- 数据库部署成功了,但是网络访问规则配置错误。
- Terraform 代码修改了一个变量,结果影响了其他环境。
- 模块本身没有问题,但是不同环境组合起来之后,出现了资源依赖冲突。
这些问题有一个共同点:Terraform 执行成功,不代表基础设施部署正确。
这其实和我们开发应用程序是一个道理。
写完 Python 代码,需要单元测试;开发完接口,需要接口测试;发布之前,还需要集成测试。
但很多团队在基础设施管理上,却经常采用另一套标准:
代码写完了,执行成功了,就认为没有问题。
我一直觉得,这是 IaC(Infrastructure as Code,基础设施即代码)落地过程中一个比较容易被忽略的问题。
Terraform 解决的是基础设施如何通过代码进行管理,而 Terratest 解决的是这些基础设施代码是否真正符合预期。
两者结合起来,才能让基础设施管理从简单的自动化部署,进一步走向自动化验证。
今天我们就从零开始,搭建一套 Terraform + Terratest 的基础设施测试工程。
二、先搞清楚:Terraform 和 Terratest 分别负责什么?
在正式动手之前,先把两个工具的职责分清楚。
Terraform:负责定义和创建基础设施。
比如我们需要创建一台云服务器、安全组、VPC 网络,可以使用 Terraform 描述资源,而不是每次都登录云平台手动操作。
一个简单的 Terraform 配置:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = "echo-wish-web"
Env = "test"
}
}
这段代码表达的意思很简单:创建一台 AWS EC2 实例,并设置实例类型、镜像以及资源标签。
Terraform 会根据配置文件生成执行计划,然后按照计划创建资源。
但这里有个问题。
Terraform 能够检查配置语法、资源参数和依赖关系,却不能仅凭一次成功的 apply 就证明业务层面的要求全部满足。
例如:
- 服务器是否真的处于运行状态?
- 安全组是否只开放了指定端口?
- 创建出来的资源是否符合团队的命名规范?
- 部署之后,应用端口是否真的能够访问?
这时候就需要 Terratest 了。
Terratest:负责验证基础设施是否符合预期。
它是一个使用 Go 语言编写的基础设施测试框架,可以通过代码调用 Terraform,部署真实资源,然后检查部署结果。
例如:
func TestTerraformEC2(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../terraform",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
instanceID := terraform.Output(
t,
terraformOptions,
"instance_id",
)
assert.NotEmpty(t, instanceID)
}
这段代码做了几件事:
- 指定 Terraform 项目目录。
- 初始化 Terraform。
- 执行部署。
- 获取实例 ID。
- 验证实例 ID 不为空。
- 测试结束后销毁资源。
这样一来,我们就不需要每次部署完成后,再手动登录云平台检查资源了。
不过需要注意:Terratest 并不是只测试 Terraform 文件是否能够执行,它更重要的价值在于验证最终创建出来的基础设施。
三、从零搭建项目:先把目录结构设计好
一个能够长期维护的 IaC 项目,不能所有配置和测试代码都堆在一起。
建议采用下面这种目录结构:
terraform-terratest/
│
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── versions.tf
│
├── test/
│ ├── terraform_test.go
│ └── go.mod
│
├── environments/
│ ├── dev.tfvars
│ └── prod.tfvars
│
└── README.md
各目录的职责:
| 目录 | 主要作用 |
|---|---|
| terraform | Terraform 基础设施定义 |
| test | Terratest 自动化测试代码 |
| environments | 不同环境的参数配置 |
| README.md | 项目说明和执行方法 |
这里有一个建议:
不要把开发环境和生产环境的配置完全复制两份。
例如,开发环境使用较小规格的实例,生产环境使用更高规格的实例,这种差异完全可以通过变量控制。
variables.tf:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "ami_id" {
description = "EC2 AMI ID"
type = string
}
开发环境:
dev.tfvars
instance_type = "t3.micro"
ami_id = "ami-xxxxxxxx"
生产环境:
prod.tfvars
instance_type = "t3.medium"
ami_id = "ami-xxxxxxxx"
这样做的好处是,基础设施逻辑保持一致,环境差异通过配置管理。
当然,实际项目还需要考虑不同环境的账号、网络、权限以及状态文件隔离,不能简单地认为换一个 tfvars 就完成了生产环境隔离。
四、编写 Terraform:让基础设施配置具备可验证性
接下来创建 Terraform 资源。
这里以 AWS EC2 为例。
main.tf:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [
aws_security_group.web.id
]
tags = {
Name = "echo-wish-web"
Env = "test"
}
}
resource "aws_security_group" "web" {
name = "echo-wish-web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
这里创建了一台 EC2,并且配置了一个安全组。
需要特别注意,入站规则只允许指定内网网段访问 80 端口。
这就是基础设施代码的一个重要价值:安全策略不再依赖运维人员手动配置,而是直接体现在代码里。
然后定义输出:
outputs.tf
output "instance_id" {
value = aws_instance.web.id
}
output "security_group_id" {
value = aws_security_group.web.id
}
output "instance_type" {
value = aws_instance.web.instance_type
}
为什么要定义这些输出?
因为 Terratest 后续需要读取 Terraform 部署结果。
如果没有合理设计输出,测试代码就很难方便地获取资源信息。
我的建议是:
Terraform 不仅要考虑如何创建资源,还要考虑后续如何验证资源。
这两件事情最好在项目设计阶段就一起考虑。
五、重点来了:使用 Terratest 编写自动化测试
前面的工作完成之后,就可以开始编写测试代码了。
首先初始化 Go 项目:
cd test
go mod init terraform-terratest
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert
接下来创建 terraform_test.go。
1. 验证基础设施是否成功创建
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestTerraformEC2(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../terraform",
Vars: map[string]interface{}{
"ami_id": "ami-xxxxxxxx",
"instance_type": "t3.micro",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
instanceID := terraform.Output(
t,
terraformOptions,
"instance_id",
)
assert.NotEmpty(t, instanceID)
}
运行测试:
go test -v
执行之后,Terratest 会自动完成初始化、部署和验证。
测试结束时,通过 defer 执行资源销毁。
这里有一个容易忽略的细节:
defer 只有在测试正常返回或者发生常规测试失败时才会按预期执行,进程被强制终止、机器断电等情况仍可能留下云资源。
所以正式项目还需要配合资源标签、成本监控以及定期清理机制。
2. 验证实例规格是否正确
资源创建成功只是第一步。
假设我们要求开发环境必须使用 t3.micro,那么就应该把这个要求写进测试代码。
instanceType := terraform.Output(
t,
terraformOptions,
"instance_type",
)
assert.Equal(
t,
"t3.micro",
instanceType,
)
这样一来,即使有人误将开发环境实例规格改成 t3.large,测试也能够发现问题。
这就是自动化测试的意义。
不是等到资源部署之后再发现配置错误,而是在发布流程中提前拦截。
3. 验证安全组配置
对于运维来说,安全组往往比服务器规格更加重要。
比如我们要求 Web 服务器只允许指定内网网段访问 80 端口。
如果有人误操作,将入站规则改成:
cidr_blocks = ["0.0.0.0/0"]
那么就可能造成服务暴露到公网。
我们可以使用 Terratest 获取安全组 ID,再调用 AWS API 验证入站规则。
示例:
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/stretchr/testify/assert"
)
func TestSecurityGroup(t *testing.T) {
options := &terraform.Options{
TerraformDir: "../terraform",
}
defer terraform.Destroy(t, options)
terraform.InitAndApply(t, options)
sgID := terraform.Output(
t,
options,
"security_group_id",
)
region := "ap-southeast-1"
rules := aws.GetSecurityGroupIngressRules(
t,
region,
sgID,
)
assert.NotEmpty(t, rules)
}
这里先获取安全组入站规则,再进行断言。
需要说明的是,这只是获取规则的基础示例,真正的安全测试还需要遍历规则,逐项检查协议、端口范围和 CIDR,不能只判断规则数量不为空。
例如,可以把以下要求作为测试标准:
- 80 端口只能允许指定网段访问。
- 不允许出现
0.0.0.0/0的管理端口规则。 - 不允许意外开放 SSH 或 RDP。
- 不允许出现超出业务需求的入站规则。
这一步非常重要。
因为很多基础设施故障,并不是资源没有创建成功,而是创建出来的资源不符合安全要求。
六、不要只测资源创建,还要测试真实运行结果
这里我想强调一个观点:
基础设施测试不能停留在 Terraform Output 不为空这个层面。
例如,EC2 实例创建成功,实例 ID 也正常返回,但这并不代表应用真的能够正常访问。
真正有价值的测试,应该尽可能接近真实业务场景。
假设我们在 EC2 上部署了一个 Web 服务,那么可以进一步验证 HTTP 接口是否正常。
Terratest 提供了 HTTP 请求相关模块。
示例:
import (
"testing"
"github.com/gruntwork-io/terratest/modules/http-helper"
)
func TestWebServer(t *testing.T) {
url := "http://10.0.1.10/health"
http_helper.HttpGetWithRetry(
t,
url,
nil,
200,
"OK",
10,
5,
)
}
这段代码会多次请求健康检查接口,直到满足预期或者重试次数耗尽。
实际项目中,URL 应该从 Terraform Output 或其他部署信息中动态获取,而不是像示例这样固定写死。
这样我们就形成了三个层次的测试:
| 测试层次 | 验证内容 |
|---|---|
| 配置验证 | Terraform 配置是否合法 |
| 资源验证 | 云资源是否按预期创建 |
| 业务验证 | 服务是否真正能够正常运行 |
这三个层次缺一不可。
七、把测试接入 CI/CD,让每次变更都经过验证
前面已经实现了 Terraform 和 Terratest 的基本测试。
但如果每次都需要运维人员手动执行 go test,自动化程度依然不够。
更好的方式是将测试集成到 CI/CD 流水线。
例如使用 GitHub Actions:
name: Terraform Test
on:
pull_request:
branches:
- main
jobs:
terraform-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download Dependencies
working-directory: test
run: go mod download
- name: Run Tests
working-directory: test
run: go test -v -timeout 30m
这样,当开发人员提交 Terraform 代码并创建 Pull Request 时,就可以自动触发测试。
不过,这里还需要注意几个生产实践问题:
- AWS 凭证应该通过 GitHub Secrets 或 OIDC 等安全方式提供,不要直接写进代码。
- 测试环境应该使用独立账号或者隔离的测试网络。
- 测试资源应该统一添加项目、环境、负责人等标签。
- 应当设置测试超时和资源清理策略。
- 对于生产环境,应该先执行 Plan、审查变更,再通过受控流程实施 Apply。
尤其是生产环境,不建议在普通 Pull Request 测试中直接创建或修改生产资源。
测试环境负责验证,生产环境负责受控发布,这个边界必须明确。
八、真正落地时,我建议建立四道防线
如果只是搭建一个 Demo,前面的内容已经足够入门。
但如果准备把 Terraform + Terratest 用到企业运维体系中,我认为还需要进一步完善。
可以将基础设施测试分成四道防线。
第一道:静态检查。
不需要真正创建云资源,就能提前发现很多问题。
例如:
terraform fmt -check
terraform validate
还可以增加 TFLint 和 Checkov,分别辅助检查 Terraform 配置问题以及基础设施安全策略。
第二道:Plan 检查。
terraform plan \
-var-file="dev.tfvars" \
-out=tfplan
重点关注资源新增、修改和销毁情况。
尤其要注意一些看似普通的代码修改,是否意外导致数据库、网络或者存储资源被替换。
第三道:Terratest 集成测试。
真正创建测试资源,验证:
- 资源规格是否正确。
- 网络和安全组是否符合要求。
- 资源之间的依赖关系是否正常。
- 关键输出是否符合预期。
第四道:业务可用性测试。
基础设施创建完成之后,再验证服务是否能够正常访问。
例如 HTTP 健康检查、数据库连接、端口连通性以及关键业务接口。
四道防线组合起来,才算形成比较完整的 IaC 质量保障体系。
当然,不是每次代码提交都必须执行全部测试。静态检查可以高频执行,真实云资源集成测试可以根据变更范围、环境和成本安排执行频率。
九、最后聊聊:基础设施代码化之后,为什么测试反而更重要?
写到这里,我想聊一个实际问题。
很多团队刚开始使用 Terraform,最大的兴奋点就是部署速度变快了。
以前创建一套测试环境,可能需要运维人员手动配置网络、安全组、服务器和数据库。
现在只需要执行几条命令,就能完成整个过程。
效率确实提高了。
但我们也应该看到另外一面:
以前手动操作,一个人可能只会影响一台服务器;现在一段 Terraform 代码,可能同时影响几十台服务器、多个网络环境,甚至整个云账号。
自动化提高了效率,也放大了错误的影响范围。
如果代码没有经过充分验证,那么部署越快,错误扩散得可能也越快。
这也是我为什么认为 Terratest 值得引入的原因。
它不是为了让运维人员多写几百行 Go 代码,也不是为了追求测试覆盖率这个数字。
它真正解决的是一个问题:
我们能不能在基础设施变更真正影响业务之前,就知道这次修改是否安全?
举个例子。
某位同事修改了 Terraform 安全组配置,本来只是想开放一个新的业务端口,结果不小心把 SSH 管理端口也开放给了所有公网 IP。
如果没有测试,这个问题可能要等到安全审计甚至事故发生之后才能发现。
如果提前编写了安全规则测试,那么代码提交时就能够发现异常,直接阻止变更进入后续发布流程。
这才是自动化测试真正的价值。
另外,我也不建议一开始就追求复杂的测试体系。
如果团队之前没有使用过 Terratest,可以从最简单的几个场景开始:
- 验证服务器是否正常创建。
- 验证实例规格是否符合环境要求。
- 验证安全组是否符合预期。
- 验证服务健康检查是否正常。
先把这些最容易出问题的地方覆盖起来,再逐步增加网络、数据库、负载均衡、自动扩缩容等测试场景。
不要为了测试而测试,更不要让测试体系本身变成新的维护负担。
Terraform 让基础设施变得可以复制,Terratest 让基础设施变得可以验证。
在我看来,真正成熟的 IaC 工程,不应该只是做到一键部署,而应该做到每一次变更都有依据、每一次部署都能验证、每一次失败都能追溯。
毕竟,运维工作的目标从来不是把资源创建出来就结束了。
让系统稳定运行,让变更风险可控,才是真正值得投入精力的地方。
作者:Echo_Wish
专注 Python、AI、DevOps、云原生与自动化运维实践。