持续测试基础设施
基础设施作为应用程序的支柱,为之提供关键的运行环境、网络连接和资源调度等支持。一旦基础设施出现故障,整个应用生态系统都可能面临严重的连锁反应,如性能降低、数据丢失乃至系统崩溃。因此,基础设施的稳定性和可靠性对于运行在其上的应用程序至关重要。
持续测试可以在基础设施的整个生命周期中进行检查,确保一切运行正常,尽早发现并解决潜在问题,减少影响扩散。此外,持续测试通过为团队提供即时的状态反馈,有助于提高基础设施的可维护性和可扩展性,进而支持业务持续增长和变化的需求。
因此,持续测试不仅是持续交付高质量软件的必要保障,对于基础设施而言,其价值和影响更为深远。
本文来分享一下我们团队是如何对基础设施进行测试的。
首先我们要识别出需要测什么。在 IaC(基础设施即代码)的实践中,我们以测试金字塔和敏捷测试四象限为指导原则,适用的测试方案包括:
单元测试:对实现中的特殊逻辑,比如环境差异、批量处理等进行部署前的验证。
组件测试:对部署的独立组件进行验证,部署结果与预期一致。比如 S3 bucket 部署配置。
功能测试:对多个需要串联合作使用才能达成实现一个功能的组件进行验证,保证组件间配置的正确性。比如通过公网域名可以访问到 app。
冒烟测试:在服务、组件部署完成之后进行端到端验证,确保服务基本可用和出入口配置的正确性。
安全性测试:验证各项安全配置是否已经启用。比如数据库、域名是否采取了 TLS 且无法在不加密情况下进行连接。其它的如权限控制、代码漏洞等。
可靠性测试:基础设施的容灾耐力、数据,混沌工程等。
部署测试:确保应用在发布的过程中,平台提供了正确可用的部署能力。
性能、可用性测试:服务的响应时间、吞吐量、并发用户数等指标。由于平台在服务间添加了一些基础设施级组件和服务,如 Service Mesh、Styra,所以也会受到影响。因此,平台团队可以构建一个覆盖了影响范围的简单应用,对其进行验证。
明确了测试方案,我们就需要识别测试优先级,在不同阶段开展相应的测试:
首先覆盖关键路径和高价值,如单元测试、组件测试、功能测试。这些代码变化而引发的测试在代码变化时都应该进行。
其次是覆盖代码变化之外由我们可控因素导致的问题,如证书到期、磁盘空间满、token 失效等,保证运行时环境相关组件和功能。如冒烟测试、部署测试、可用性测试等。可以在平台功能上了生产环境后,核心功能交付无压力时进行。
最后是在平台相较稳定后(即被测功能不会有大的变动时),以提高平台可靠性为目的的测试。用来验证在面对代码之外,不可控的因素导致的问题发生时我们的应对能力。如外部依赖变化、数据恢复能力、容灾重建能力等。通常在平台上的应用服务在生产环境已有真实用户投入使用后进行。
市面上有很多可以测试工具可以选择:
最基础和单一的是 Shell 脚本语言,典型如 Bash。
之后是应用开发语言的测试框架,如 Bash 的 bats、Ruby 的 RSpec 和 JavaScript 的 Jest。
最后是在语言提供的能力上对三方 cli 和 API 进行封装的测试库,如 Ruby 的 AWSpec,Go 的 Terratest 等。
比较来看,shell 优点是原生,直接调用服务方提供的 CLI,如 AWS CLI, Kubectl;缺点是面对复杂场景编写起来费心费力;
使用封装起来的测试库看起来很简单,但开发者日常就要使用 CLI/Curl 命令来进行基础验证,而用封装库进行开发就需要多学习一套知识;而且在被测服务发布新功能后,平台想跟进却发现测试库没能跟进,导致最后还得用原生方式来写。比如 AWSpec 支持 RDS,但是很长时间都没有支持 Aurora。如果已经写了很多测试,就只能在 Aurora 这里使用其它方式验证,最后导致各处验证方式不统一。
所以我推荐选择团队熟悉的应用开发语言的测试框架,优点如下:
可以直接通过系统命令调用 CLI,开发者平常工作怎么验证,测试代码就怎么写,拷贝过来能用。
相较 shell 来说,良好的测试框架支持。比如在多级 JSON 中验证部分内容,jq 验证起来就很麻烦。
各种验证场景统一实现,不用学习多框架或多语言。比如 Terratest 只适合验证 infra,如果需要想做冒烟测试,还要另起炉灶。
如果确实有必要集成测试库,也可以按需集成。
我的选择则是 Ruby/RSpec,因为 Ruby 简洁自然的语法和 RSpec 的强大验证器,让测试代码中很少出现语言自身导致的难懂和多余的代码。
组件测试加上人工验证是交付环境能够成功部署的主要信心来源,而在有逻辑分支的时候,单元测试可以用来成为对组件测试的补充:组件测试验证代码的主干,单元测试在部署前来验证分支,以实现对代码的测试全覆盖。
下面我们基于 Terraform 实现,以单元测试和组件测试为例进行测试。其它 IaC 实现和不依赖外部工具的测试都可以参考来实现。
注意这些由代码变化产生的测试都应在 Pipeline 的流水线中,而不是手动触发。任何不拦截在上线必经之路的测试,最终都将无人理睬。
单元测试
在 Terraform 中,通常需要人工来验证 terraform plan 的结果,但是它只能覆盖当前 state 和配置参数下的结果。当我们代码中包含逻辑时,我们就需要通过配置 local backend、不同配置和 state 文件来本地验证对应的 plan 结果。示例:
检查 plan 结果
在部署流水线中,通过 terraform plan 加人工验证。在测试环境中 apply 后,人工测试来保证正确性。验证完成后,对于后续环境来说在测试环境的 plan 结果就是其它环境的参考输入,由人工核对确认后进行 apply。
在资源生成后,我们便可以通过测试脚本调用 CLI/API 请求目标资源,来验证产生的结果与预期一致。比如服务可以被成功访问、数据库确实被创建出来并配有正确的参数,密钥管理器中被保存下来的数据库密钥我们可以成功连接到数据库等等。与应用测试一样,任何一条失败的测试都应让我们的 Pipeline 变红,向团队告警。并确保只有在前一个环境被验证通过后,我们才向下一个环境前进。
我们以 Ruby/RSpec 为例。在一个代码库中,以生成的目标资源上下文划分测试文件。
比如对于 RDS 数据库的创建,我们可以组织这三个文件:
rds_spec.rb: 用来验证 AWS RDS 生成的资源,如 cluster、db parameter。
db_spec.rb: 用来验证在 DB 中进行的设置,比如支持动态数据库凭证所在 DB 中创建的资源,DB 的 extension 被正确启用。
vault_spec.rb:用来验证 Vault 中创建的资源、比如 master 凭证的存储、支持动态数据库凭证所需的资源。
一个文件中的组织结构如下:
下面是一个验证 RDS 的 DB parameter 按预期被创建的例子:
可以看出测试代码非常的语义化,没有额外的数据结构定义和难懂的语法。看明白了这个测试,其它命令行相关的测试也就全都会写了。平台开发者们可以专注于业务验证,而不会因为测试框架带来额外的负担。
当然,只需要我们能在编写功能代码之前被测内容是什么。我们可以通过各种文档来识别出被测内容,比如 Kubectl、AWS、Vault 等 CLI,或各种服务的 API。如果我们无法识别出被测内容时,那就需要通过拆解步骤、手动部署资源等方式分析出来。像在其它语言进行测试驱动开发时一样,小步验证,红绿重构。
进行测试驱动在其它语言中带来的优点,在 IaC 也一样大部分适用:
促进模块化设计和提交
简化调试过程
更快地反馈循环
更好地设计决策
易于重构
减少过度工程
保障测试覆盖率:这点需要单独提一下,目前还没有什么好的方案可以检查 IaC 代码的测试覆盖率,所以在测试驱动中「只实现刚好可以通过测试的代码」对保障覆盖率很重要。
自动化测试是高代码质量和稳定开发效率的重要保障,应用服务开发如是,基础设施因为担负着更大的使命和责任更是如此。测试驱动能帮助开发者更好的设计和实现。在 IaC 开发过程也同样适用。在工具选型上,避免选择编写成本过高和太复杂的语言和工具,大部分 Ops 们更习惯编写动态语言的脚本,方便和顺手更重要。
希望本文能对你的工程实践带来启发,从下一个 IaC feature 开始测试驱动开发。
- 相关阅读 -