基础设施代码(IaC)常常是仓库里「最不敢改」的部分:改了不放心、不改又还债。原因很简单——它几乎没有测试。应用代码有单元测试、集成测试兜底,而一份 Terraform 模块的「验证」往往就是「apply 一下看看」。
基础设施代码测试的难点在于:它操作的是真实资源,慢、贵、有副作用。因此不能照搬应用测试的做法,而要按「成本从低到高」分层,把绝大多数检查压在不需要创建真实资源的层。本文把静态检查、Terratest、Kitchen、InSpec 串成一条可落地的链路。
1. 为什么基础设施代码也要测试
1.1 基础设施缺陷的代价
基础设施缺陷的典型后果:
- 误删资源:一条 terraform apply 删掉生产数据库
- 配置漂移:手动改动与代码不一致,下次 apply 覆盖或冲突
- 安全缺口:安全组开放 0.0.0.0/0,长期无人发现
- 不可复现:环境靠「记得当初怎么点的」,无法重建
这些缺陷不会在「apply 成功」时暴露,只会在故障或审计时暴露。
1.2 与应用测试的差异
| 维度 | 应用测试 | 基础设施测试 |
|---|---|---|
| 执行成本 | 低(毫秒~秒) | 高(分钟~小时,有云账单) |
| 副作用 | 无(内存/临时库) | 有(创建真实资源) |
| 幂等性 | 天然 | 需要显式设计 |
| 隔离方式 | 进程/容器 | 独立账号/项目/命名空间 |
| 断言对象 | 函数返回值 | 资源属性 + 实际可达性 |
1.3 测试金字塔的映射
/\ 真实环境端到端(少量、慢、贵)
/ \ Terratest 全链路 / 端到端验证
/----\
/ \ 集成测试(创建真实资源)
/ \ Terratest 单模块 / Kitchen 收敛
/----------\
/ \ 契约与合规(InSpec 基线、策略校验)
/--------------\
/ \ 静态检查(语法、lint、plan 校验、安全扫描)
/------------------\
底座最宽、最快、最便宜,应该覆盖 80% 的问题
把尽可能多的问题压到底座(静态检查),是控制 IaC 测试成本的核心策略。
2. 静态检查层
2.1 语法与格式
# Terraform 格式化与语法校验
terraform fmt -check -recursive
terraform validate
# 模块输入输出检查
terraform-docs --output-check .
# TFLint:静态分析(未使用变量、废弃语法、provider 特定规则)
tflint --recursive
# .tflint.hcl
plugin "aws" {
enabled = true
version = "0.32.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_unused_declarations" { enabled = true }
rule "terraform_documented_variables" { enabled = true }
rule "aws_instance_invalid_type" { enabled = true }
2.2 plan 校验
terraform plan 是最有价值的静态检查:它在不创建资源的前提下,算出「这次改动会造成什么差异」。
terraform init -backend=false
terraform plan -out=tfplan -detailed-exitcode
# 退出码:0=无变更 1=错误 2=有变更
# 把 plan 转成可读、可断言的 JSON
terraform show -json tfplan > plan.json
# 断言:不允许 plan 中出现「销毁」生产数据库
jq -e '
[.resource_changes[]?
| select(.change.actions | index("delete"))
| select(.address | test("aws_db_instance|aws_rds_cluster"))]
| length == 0
' plan.json || { echo "计划中包含数据库销毁,已阻断"; exit 1; }
2.3 安全与合规扫描
# Checkov:策略扫描,覆盖 CIS、PCI-DSS 等基线
checkov -d . --framework terraform --compact
# tfsec:Terraform 专用安全扫描
tfsec . --minimum-severity HIGH
# Trivy:同时覆盖 IaC 与镜像
trivy config .
# .checkov.yaml:只阻断高危,其余告警
soft-fail-on:
- CKV_AWS_18 # S3 访问日志
hard-fail-on:
- CKV_AWS_20 # S3 公开读
- CKV_AWS_16 # RDS 未加密
策略即代码的完整治理思路(策略引擎、准入控制、例外管理)可参考 策略即代码与治理 。
3. Terratest:真实资源的集成测试
3.1 为什么需要真实资源
静态检查无法回答的问题:
- 这个模块 apply 后,实例真的能启动吗?
- 安全组规则真的允许我从跳板机连上数据库吗?
- 负载均衡的健康检查真的能让流量进来吗?
- 销毁时资源真的被清理干净了吗(无残留、无计费)?
Terratest 用 Go 写测试,直接调用 Terraform 创建真实资源,然后对「实际行为」做断言,最后销毁。
3.2 一个完整的测试
// test/network_test.go
package test
import (
"testing"
"fmt"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/stretchr/testify/assert"
"time"
)
func TestVpcNetworkModule(t *testing.T) {
t.Parallel() // 多模块并行,但要注意云配额
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/network",
Vars: map[string]interface{}{
"name": "test-network",
"region": "ap-southeast-1",
"cidr": "10.42.0.0/16",
},
EnvVars: map[string]string{
"TF_IN_AUTOMATION": "true",
},
})
// 无论断言是否失败,最后都要销毁,避免资源泄漏与计费
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.Regexp(t, `^vpc-[0-9a-f]+$`, vpcID)
subnets := terraform.OutputList(t, terraformOptions, "subnet_ids")
assert.Len(t, subnets, 3, "默认应创建 3 个子网(多可用区)")
// 真实可达性断言:等待实例起来后 HTTP 探活
url := fmt.Sprintf("http://%s", terraform.Output(t, terraformOptions, "endpoint"))
http_helper.HttpGetWithRetry(t, url, nil, 200, "ok", 30, 5*time.Second)
}
3.3 成本与隔离控制
Terratest 的成本控制要点:
1. defer Destroy:断言失败也必须销毁(用 defer 而非 t.Cleanup 之外的手工调用)
2. 独立账号/项目:测试用独立云账号,避免误伤生产
3. 命名空间隔离:资源名带随机后缀,避免并行冲突
4. 最小规格:测试用最小实例类型,够验证即可
5. 超时保护:给 apply/destroy 设超时,避免卡死持续计费
6. 定时清理:扫「测试前缀 + 超过 N 小时」的残留资源并清理
// 随机后缀,保证并行测试不冲突
uniqueID := random.UniqueId()
name := fmt.Sprintf("test-%s", uniqueID)
# 定期清理残留(兜底):找出测试前缀且超过 3 小时的资源
aws ec2 describe-instances \
--filters "Name=tag:Purpose,Values=terratest" \
--query 'Reservations[].Instances[?LaunchTime<=`'"$(date -u -v-3H +%Y-%m-%dT%H:%M:%SZ)"'`].[InstanceId]' \
--output text | xargs -r aws ec2 terminate-instances --instance-ids
Terratest 的完整用法与模块化测试组织可参考 Terraform 测试与 Terratest 。
4. Kitchen:配置收敛验证
4.1 验证「收敛」而不只是「语法」
配置管理(Ansible、Chef、Puppet)的测试重点不是「playbook 语法对不对」,而是「执行后系统是否真的达到期望状态」——这叫收敛(convergence)。
收敛测试要回答:
- 第一次执行后,服务是否启动并监听端口?
- 再执行一次(幂等),是否不产生任何变更?
- 在干净的系统上执行,是否成功?
4.2 Kitchen 配置
# kitchen.yml — 用容器/VM 验证 Ansible 收敛
driver:
name: docker
platforms:
- name: ubuntu-22.04
driver_config:
image: ubuntu:22.04
privileged: true
provisioner:
name: ansible_playbook
playbook: playbooks/site.yml
require_ansible_repo: false
ansible_verbose: true
ansible_verbosity: 1
verifier:
name: inspec
suites:
- name: web
verifier:
inspec_tests:
- test/integration/web
kitchen create # 创建平台实例
kitchen converge # 执行配置(收敛)
kitchen verify # 运行验证
kitchen destroy # 销毁
kitchen test # create → converge → verify → destroy 一条龙
4.3 幂等性断言
# 第二次 converge 应该「无变更」,这是幂等性的核心断言
kitchen converge
kitchen converge 2>&1 | tee /tmp/second.log
if grep -qE "changed=[1-9]" /tmp/second.log; then
echo "配置不幂等:第二次执行仍有变更"; exit 1
fi
Ansible 生态下的配置管理与测试组织可参考 配置管理实践 。
5. InSpec:合规与基线验证
5.1 从「能跑」到「合规」
Kitchen 验证的是「服务起来了」,InSpec 验证的是「它符合安全与合规基线」:
InSpec 检查的典型项:
- 文件权限(如 /etc/shadow 必须 0640 root:root)
- 服务状态(如 sshd 必须运行,且禁止 root 直登)
- 端口暴露(不该监听的端口不能监听)
- 内核参数(如 net.ipv4.ip_forward 的值)
- 补丁与包版本(关键包不低于某版本)
5.2 编写与执行
# test/integration/web/default_test.rb
control 'sshd-hardening' do
impact 1.0
title 'SSH 必须禁用 root 直登与密码认证'
describe sshd_config do
its('PermitRootLogin') { should cmp 'no' }
its('PasswordAuthentication') { should cmp 'no' }
end
end
control 'app-service' do
impact 1.0
title '应用服务必须运行并监听 8080'
describe service('myapp') do
it { should be_running }
it { should be_enabled }
end
describe port(8080) do
it { should be_listening }
its('protocols') { should include 'tcp' }
end
end
control 'file-permissions' do
impact 0.7
title '配置目录不可全局可写'
describe file('/etc/myapp') do
it { should exist }
it { should be_directory }
its('mode') { should cmp '0750' }
end
end
# 本地对容器执行
inspec exec test/integration/web -t docker://container_id
# 对远程主机执行(走 SSH)
inspec exec test/integration/web -t ssh://user@host
# 对云账号执行(AWS 基线)
inspec exec https://github.com/inspec/inspec-aws -t aws://ap-southeast-1
5.3 合规基线的维护
基线维护要点:
- 用 profile 组织检查项,按「平台/角色」分层(如 ssh 基线、web 基线、db 基线)
- 每个 control 标注 impact(0~1)与 title,便于分级报告
- 例外要显式登记(waiver),并带到期日
- 基线纳入 CI:每次改配置都跑一遍,防止合规回退
6. 分层测试在 CI 中的组织
6.1 分层与触发
层一(每次 PR,秒~分钟):fmt / validate / tflint / checkov / plan 断言
层二(每次 PR,分钟):Kitchen 容器收敛 + InSpec(无云资源)
层三(合并到 main,分钟~小时):Terratest 单模块(真实资源)
层四(定时/nightly):Terratest 全链路端到端 + 云基线 InSpec
# .github/workflows/iac-ci.yml
name: iac-ci
on:
pull_request:
paths: ["**.tf", "modules/**", "playbooks/**"]
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
- run: tflint --recursive
- run: checkov -d . --framework terraform --compact
6.2 成本与并发控制
CI 上的资源隔离:
- Terratest 用独立云账号 + OIDC 临时凭据(不用长期 AK)
- 限制并发测试数(避免触发云配额)
- 给每个测试加超时,防止卡死持续计费
- nightly 才跑全链路,PR 只跑单模块
基础设施即代码的整体组织(模块化、状态管理、环境划分)可参考 基础设施即代码 。
7. 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 只测语法 | apply 后才暴露问题 | 补 plan 断言与安全扫描 |
| 无 defer Destroy | 断言失败资源泄漏 | 一律 defer terraform.Destroy |
| 测试用生产账号 | 误删生产资源 | 独立账号 + 最小权限 |
| 无命名随机后缀 | 并行测试冲突 | 资源名带随机 ID |
| 无超时 | 卡死持续计费 | apply/destroy 设超时 |
| 只跑一次 | 幂等性未验证 | 二次 converge 断言无变更 |
| 合规靠人工 | 基线回退无人知 | InSpec 纳入 CI |
| 例外无期限 | 豁免永久化 | waiver 登记到期日 |
| 全量 nightly 当 PR | CI 又慢又贵 | 分层,PR 只跑便宜层 |
小结
基础设施代码测试的关键,是承认「真实资源测试又慢又贵」,因此必须分层:把 80% 的检查压在静态层(fmt、validate、tflint、checkov、plan 断言),用 Kitchen + InSpec 在容器里验证收敛与合规,只在必要时用 Terratest 创建真实资源做端到端验证。
落地记住四件事:静态层优先、真实资源测试必须有 defer Destroy 与超时、测试用独立账号与随机命名、合规基线纳入 CI 并给例外设到期日。做到这些,基础设施代码才会从「不敢改」变成「随便改」——因为每次改动都有测试兜底。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。