基础设施代码测试:Terratest、Kitchen 与 InSpec

基础设施即代码同样需要测试金字塔。本文把静态检查、plan 校验、Terratest 真实资源集成测试、Kitchen 配置收敛验证、InSpec 合规基线串成一条链路,讲清各自的边界、成本控制、隔离策略与 CI 集成方式,并给出可落地的分层测试方案与常见坑。

基础设施代码(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 当 PRCI 又慢又贵分层,PR 只跑便宜层

小结

基础设施代码测试的关键,是承认「真实资源测试又慢又贵」,因此必须分层:把 80% 的检查压在静态层(fmt、validate、tflint、checkov、plan 断言),用 Kitchen + InSpec 在容器里验证收敛与合规,只在必要时用 Terratest 创建真实资源做端到端验证。

落地记住四件事:静态层优先、真实资源测试必须有 defer Destroy 与超时、测试用独立账号与随机命名、合规基线纳入 CI 并给例外设到期日。做到这些,基础设施代码才会从「不敢改」变成「随便改」——因为每次改动都有测试兜底。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「devops」更多文章

  1. 值班工程与告警疲劳治理
  2. 发布列车与版本节奏治理
  3. 依赖升级自动化:Renovate 与 Dependabot 实践