SRE 可靠性测试:从传统测试到生产环境测试

站点可靠性工程师(SRE)的一项核心职责,是量化所维护系统的可靠性。可靠性测试是 SRE 提升系统稳定性、降低故障风险、优化 MTTR 与 MTBF 的重要手段。SRE 通常会把传统软件测试技术应用到大规模系统中,以完成这项工作。可靠性既可以从历史角度衡量,也可以从未来角度衡量:前者通过分析监控系统记录的历史行为数据获得,后者则基于既有系统行为数据进行预测。要使这类预测足够可靠,并能够用于实际决策,通常必须满足以下条件之一:

  1. 站点在一段时间内保持完全不变:没有软件发布,服务器集群也没有调整。这样,未来行为大体会与过去行为一致。
  2. 能够有把握地描述站点发生的所有变更,并在分析中纳入每项变更所带来的不确定性。

SRE 可靠性测试:从传统测试到生产环境测试

测试是一种机制,用于在系统发生变更时证明某些特定领域仍然保持等价。87 如果一项测试在变更前后都能通过,它就可以降低可靠性分析中需要考虑的不确定性。全面而有效的测试,能够帮助我们以足够精细的粒度预测某个站点未来的可靠性,从而满足实际应用需求。

所需测试量取决于系统的可靠性要求。随着测试覆盖代码库的比例提高,不确定性以及每次变更可能导致的可靠性下降都会降低。足够高的测试覆盖率意味着,在可靠性跌破可接受水平之前,系统可以承受更多变更。如果变更过多、过快,预测可靠性就会逼近可接受下限。此时,可能需要暂停变更,同时积累新的监控数据。这些数据可以补充测试覆盖率,用于验证修改后的执行路径是否仍然满足可靠性要求。假设服务请求来自随机分布的客户端 [Woo96],就可以通过监控指标中的抽样统计信息,推断总体流量是否已经使用了新的执行路径。这些统计信息还可以帮助识别需要加强测试或进行其他改造的区域。

可靠性测试与平均修复时间之间的关系

通过一项或一组测试,并不一定能够证明系统可靠;但测试失败通常表明系统存在可靠性问题。

监控系统可以发现缺陷,但发现速度取决于报告流程的响应速度。平均修复时间(MTTR)衡量的是运维团队修复缺陷所需的时间,修复方式可以是回滚,也可以是其他补救措施。

测试系统有可能识别出 MTTR 为零的缺陷。当系统级测试被应用于某个子系统,并且该测试检测到的问题与监控系统在生产环境中会检测到的问题完全相同时,就会出现 MTTR 为零的情况。这样的测试可以阻止代码推送,从而防止缺陷进入生产环境,尽管缺陷本身仍然需要在源代码中修复。通过阻止代码推送来处理这类缺陷,既快速又有效。发现的 MTTR 为零的缺陷越多,用户实际感受到的平均故障间隔时间(MTBF)就越长。

随着测试不断改进,MTBF 会随之延长,这也会鼓励开发者加快功能发布速度。当然,部分新功能难免会引入缺陷。一旦发现并修复这些缺陷,发布节奏也会随之调整。

关于软件测试,相关领域的作者在测试覆盖范围方面基本已有共识。多数分歧来自术语使用差异、对软件生命周期不同阶段测试影响的关注重点不同,以及被测试系统本身的具体特点不同。关于大型互联网场景下的测试实践,可参见 [Whi12]。以下各节将说明本章对软件测试相关术语的使用方式。

软件测试类型:传统测试与生产环境测试

软件测试大致可分为两类:传统测试和生产环境测试。传统测试在软件开发过程中更常见,用于离线评估软件的正确性。生产环境测试则在运行中的网络服务上执行,用于评估已部署的软件系统是否正常工作。

传统测试:单元测试、集成测试与系统测试

如图 17-1 所示,传统软件测试通常从单元测试开始。更复杂功能的测试,则建立在单元测试的基础之上。

SRE 可靠性测试:从传统测试到生产环境测试

图 17-1.传统测试的层级结构

单元测试

单元测试是最小、最简单的软件测试形式。它用于评估软件中某个可独立分离的单元,例如类或函数,是否正确运行,并使该单元独立于其所属的更大软件系统。单元测试也可以作为一种规范,用来确保函数或模块准确执行系统所要求的行为。单元测试通常也是引入测试驱动开发(TDD)理念的起点。

集成测试

通过单元测试的软件组件会被组装成更大的组件。随后,工程师会对这些组装后的组件运行集成测试,以验证其功能是否正常。依赖注入是一种非常强大的技术,可以为复杂依赖项创建模拟对象,从而让工程师能够清晰地测试组件。依赖注入的一个常见示例,是用具有确定行为的轻量级模拟对象替换有状态数据库。

系统测试

系统测试是工程师针对尚未部署的系统执行的最大规模测试。属于某个特定组件,例如已经通过集成测试的服务器,的所有模块都会被组装成完整系统。随后,工程师会测试系统的端到端功能。系统测试有多种类型。

冒烟测试

冒烟测试是最简单的系统测试类型之一。工程师通过这类测试验证系统的基本功能是否可用,其目的在于确认系统能够正常启动并执行基本操作。冒烟测试也称为健全性测试,其作用是避免在明显不可用的系统上继续执行成本更高的测试。

性能测试

在通过冒烟测试确认系统基本正确之后,通常下一步会编写另一类系统测试,以确保系统在其整个生命周期中都能保持可接受的性能。由于依赖项响应时间或资源需求在开发过程中可能发生显著变化,因此需要对系统进行测试,确保其性能不会在用户察觉之前逐渐下降。例如,一个程序可能从原本只需要 8 GB 内存,变为需要 32 GB 内存;又或者 10 毫秒的响应时间可能逐渐变成 50 毫秒,再变成 100 毫秒。性能测试可以防止系统随着时间推移而性能下降,或运行成本变得过高。

回归测试

另一类系统测试用于防止缺陷重新潜入代码库。回归测试可以看作一个缺陷库,其中记录了历史上导致系统故障或产生错误结果的各种缺陷。通过将这些缺陷转化为系统级或集成级测试,负责重构代码库的工程师可以确保不会意外重新引入那些已经投入时间和精力修复过的问题。

需要注意的是,测试是有成本的,包括时间成本和计算资源成本。单元测试通常非常经济,往往只需几毫秒即可在笔记本电脑的可用资源上完成。相比之下,搭建一个包含所需依赖项或模拟等价物的完整服务器来运行相关测试,可能需要更长时间,从几分钟到几小时不等,并且可能需要专用计算资源。充分考虑这些成本,对于提高开发人员效率、有效利用测试资源至关重要。

生产环境测试:面向真实系统的可靠性验证

生产环境测试与真实生产系统交互,而不是与封闭测试环境中的系统交互。这类测试在许多方面类似于黑盒监控,相关内容可参见“分布式系统监控”,因此有时也称为黑盒测试。生产环境测试对于运行可靠的生产服务至关重要。

解开测试纠缠

人们常说,测试是在,或者应该在,一个封闭环境中进行的 [Nar12]。这种说法暗示生产环境并不是封闭的。事实也的确如此:生产环境通常并非封闭环境,因为发布流程会以小而可控的方式实时改变生产环境。

为了控制不确定性并尽量避免风险暴露给用户,变更并不一定会按照进入源代码控制系统的顺序推送到生产环境。部署通常分阶段进行,并使用某种机制逐步调整用户环境,同时在每个阶段进行监控,以确保新环境不会遇到意料之外的问题。因此,整个生产环境并不会有意地代表源代码控制系统中某个已提交二进制文件的特定版本。

源代码控制系统中可能同时存在多个二进制文件及其关联配置文件版本,等待部署到生产环境。这种情况会给针对生产环境的测试带来问题。例如,测试可能使用源代码控制系统中最新版本的配置文件,却搭配生产环境中已经部署的旧版本二进制文件。或者,测试可能使用旧版本配置文件,并发现一个实际上已经在新版本中修复的错误。

类似地,系统测试可以在运行测试之前使用配置文件组装其模块。如果测试通过,但其版本与配置测试,即下一节将讨论的内容,失败时使用的版本相同,那么该测试结果在理论上有效,在实际应用中却无效。这种情况非常棘手。

配置测试

在海外某些大型互联网公司的实践中,Web 服务的配置信息通常会记录在版本控制系统中的文件里。针对每个配置文件,都会有一个独立的配置测试,用于检查生产环境中特定二进制文件的实际配置情况,并报告与该配置文件不一致之处。这类测试本质上并非完全封闭,因为它们运行在测试基础设施沙箱之外。

配置测试基于某个已提交配置文件的特定版本来构建和执行。通过比较测试通过的版本与自动化系统的目标版本,可以间接反映实际生产环境与正在进行的工程工作之间存在多大差距。

这类非封闭式配置测试在分布式监控解决方案中尤为重要,因为生产环境中的通过或失败模式,能够识别服务栈中局部配置组合不合理的路径。监控系统的规则会尝试将真实用户请求路径,即来自跟踪日志的路径,与这些不理想路径进行匹配。一旦规则发现匹配项,就会触发警报,表明正在进行的发布或推送操作存在安全隐患,需要采取补救措施。

当生产环境部署使用真实文件内容,并提供实时查询接口来检索内容副本时,配置测试可以非常简单。在这种情况下,测试代码只需发出查询,并将响应与文件内容进行比较即可。若配置存在以下情况之一,测试就会变得更加复杂:

  1. 隐式包含二进制文件内置的默认值,这意味着测试版本需要单独划分。
  2. 通过 bash 等预处理器转换为命令行标志,使测试结果受扩展规则约束。
  3. 为共享运行时指定行为上下文,使测试依赖于该运行时的发布计划。

压力测试

为了安全地运行系统,SRE 需要了解系统及其组件的极限。在许多情况下,单个组件并不会在达到某个临界点后优雅降级,而是会发生灾难性故障。工程师使用压力测试来探查 Web 服务的边界。压力测试可以回答以下问题:

  1. 数据库最多能占用多少内存,超过后就会开始出现写入失败?
  2. 应用服务器每秒最多可以发送多少查询,超过后服务器就会过载并导致请求失败?

金丝雀测试

在这份生产环境测试清单中,金丝雀测试似乎明显缺席。“金丝雀”一词来自“煤矿里的金丝雀”这一说法,指过去矿工用活鸟检测有毒气体,以避免人类中毒的做法。

在金丝雀测试中,一部分服务器会被升级到新版本或新配置,然后进入一段观察期。如果没有出现意外情况,就继续发布,并逐步升级其余服务器。如果出现问题,已修改的服务器可以迅速回滚到已知良好的状态。我们通常将升级服务器后的观察期称为“二进制烘焙”。

严格来说,金丝雀测试并不是真正意义上的测试,而是一种结构化的用户验收过程。配置测试和压力测试旨在验证确定性软件在特定条件下是否满足要求,而金丝雀测试更具临场性。它只是将待测代码暴露在更难预测的真实生产环境中,因此并不完美,也并非总能发现新引入的缺陷。

为了更具体地说明金丝雀测试如何发挥作用,假设存在一个潜在故障,它对用户流量的影响相对较小,而部署过程正以指数级增长的方式逐步扩大。我们预期累计报告的异常数量 CU 会不断增长,其中 R 表示异常报告速率,U 表示故障阶数,K 表示流量增长 e 倍,即约 172%,所需的时间。90

为了避免对用户造成影响,一旦部署过程中出现不良偏差,就需要快速回滚到之前的配置。自动化系统需要短时间来监测偏差并作出响应,在此期间可能会额外生成若干报告。等系统准备就绪后,这些报告可用于估算累计偏差数量 C 和偏差率 R。

除以 K 并进行校正后,即可估计 U,也就是潜在故障的阶数。91 例如:

  1. U = 1:用户请求遇到了无法正常运行的代码。
  2. U = 2:该用户请求会随机破坏未来用户请求可能看到的数据。
  3. U = 3:被随机破坏的数据同时也是先前某个请求的有效标识符。

大多数缺陷都是一阶缺陷:它们与用户流量呈线性关系增长 [Per07]。通常,可以将所有响应异常的请求日志转化为新的回归测试,从而追踪这些缺陷。但这种策略对高阶缺陷无效;如果按顺序重放之前的所有请求,某个请求会反复失败,但如果省略某些请求,该请求又会突然通过。在发布过程中捕获这些高阶缺陷至关重要,否则运维工作量会迅速增加。

考虑到高阶错误和低阶错误的动态差异,在使用指数级发布策略时,并不需要刻意追求用户流量分配的绝对公平。只要每一种确定用户流量分配比例的方法都使用相同的 K 区间,即使无法确定究竟是哪种方法最终发现了故障,U 的估计值仍然有效。通过顺序使用多种方法并允许一定程度的重叠,可以保持较小的 K 值。这种策略既能最大限度减少用户可见的异常总数 C,也能尽早估计 U 值。当然,我们希望 U 值为 1。

创建测试和构建环境

在项目启动之初就考虑各种测试类型和故障场景当然是理想状态,但现实中,SRE 加入开发团队时,项目往往已经进入相当成熟的阶段:团队的项目可能已经验证了研究模型,库已经证明底层算法具备可扩展性,用户界面模型也基本符合要求。此时,代码库可能仍带有原型性质,而全面测试尚未设计或部署。

在这种情况下,测试工作应该从哪里开始?如果当前测试覆盖率很低,甚至完全没有测试,那么为每个关键函数和类编写单元测试会是一项极其艰巨的任务。因此,应该优先编写那些投入最小、收益最大的测试。

可以先提出以下问题:

  1. 能否对代码库进行优先级排序?借鉴功能开发和项目管理中的经验:如果每个任务都是高优先级任务,那么实际上就没有真正的高优先级任务。能否基于重要性指标,对被测系统的组件进行排序?
  2. 是否存在对任务或业务至关重要的特定功能或类?例如,涉及计费的代码通常属于业务关键代码。计费代码通常也比较容易与系统其他部分完全分离。
  3. 其他团队正在集成哪些 API?即使某种故障从未通过发布测试直接暴露给用户,只要它误导了其他开发团队,使他们为你的 API 编写出错误,甚至只是次优的客户端,也可能造成严重危害。

发布明显存在缺陷的软件,是开发人员最不可原谅的错误之一。事实上,只需投入少量精力,就可以为每次发布编写一组冒烟测试。这是一个低投入、高回报的起点,能够确保软件经过基本测试,从而提高可靠性。

建立强大测试文化的一种方式,是将所有已报告缺陷都记录为测试用例。如果每个缺陷都转化为一个测试用例,那么每个测试用例最初都应该失败,因为缺陷尚未修复。随着工程师修复缺陷,软件通过这些测试,团队就朝着构建全面回归测试套件的目标迈进了一步。

创建经过充分测试的软件,另一项关键任务是搭建测试基础设施。强大测试基础设施的基础,是版本化的源代码控制系统,它能够跟踪代码库的每一次变更。对于希望把需求、开发、测试、发布和知识沉淀串联起来的研发团队,也可以借助 PingCode 这类智能化研发管理工具,将测试结果、缺陷修复、发布记录和 Wiki 经验沉淀统一管理,让可靠性改进从单点动作变成可追踪、可复盘的研发流程。

一旦源代码控制系统到位,就可以添加持续构建系统,在每次代码提交时构建软件并运行测试。我们的经验是,如果构建系统能在软件项目出现问题时立即通知工程师,效果最好。虽然这听起来显而易见,但确保源代码控制系统中软件项目的最新版本始终处于可用状态至关重要。当构建系统通知工程师代码存在问题时,工程师应暂停其他任务,优先修复问题。认真对待缺陷是合理的,原因如下:

  1. 如果缺陷出现后代码库继续发生变化,修复损坏部分通常会更加困难。
  2. 软件故障会拖慢团队进度,因为团队成员必须想办法绕过或处理故障。
  3. 每日构建、每周构建等发布节奏会失去价值。
  4. 团队对紧急发布请求,例如安全漏洞披露,的响应会变得更加复杂和困难。

在 SRE 领域,稳定性和敏捷性这两个概念历来存在张力。最后一点提供了一个有趣例子,说明稳定性实际上如何促进敏捷性:当构建过程稳定可靠时,开发人员就能更快地迭代。

一些构建系统具备非常有价值的能力,可以更精确地控制测试。例如,某些构建系统会为软件项目创建依赖关系图。当某个文件发生变化时,它只会重新构建依赖于该文件的软件部分。这样的系统能够提供可复现的构建结果。测试不再需要在每次提交时运行全部测试,而是只针对受变更影响的代码运行。因此,测试执行成本更低,速度也更快。

有很多工具可以帮助量化和可视化所需的测试覆盖率 [Cra10]。应利用这些工具明确测试重点:把编写高测试覆盖率代码当作一个工程项目,而不是一种抽象的理念。与其反复强调“我们需要更多测试”,不如设定明确目标和截止日期。

还要记住,并非所有软件都一样。关乎生命安全或收入的关键系统,需要比生命周期短暂的非生产脚本具备更高的测试质量和测试覆盖率。

大规模可靠性测试

了解测试的基本概念之后,接下来看看 SRE 如何从系统视角开展测试,从而在大规模场景下提高可靠性。

一个小型单元测试可能只有少量依赖项:一个源文件、测试库、运行时库、编译器,以及运行测试的本地硬件。健全的测试环境要求每个依赖项都有自己的测试覆盖范围,并且这些测试必须专门覆盖环境中其他部分所预期的使用方式。如果某个单元测试的实现依赖运行时库中一条没有测试覆盖的代码路径,那么环境中的一个无关变更就可能导致该单元测试始终通过,无论被测代码本身是否存在缺陷。

相比之下,发布测试可能依赖大量组件,以至于它对代码库中的每个对象都存在传递依赖。如果测试依赖生产环境的干净副本,那么原则上,每个小补丁都需要执行一次完整的灾难恢复演练。实际测试环境会尝试在版本和合并之间选择分支点。这样可以用最少的迭代次数解决最大的依赖不确定性。当然,当某个不确定区域最终演变为故障时,就需要选择额外的分支点。

测试可扩展工具

作为软件,SRE 工具同样需要测试。95 SRE 开发的工具可能执行以下任务:

  1. 检索和传播数据库性能指标。
  2. 预测使用指标,以规划容量风险。
  3. 重构用户不可访问的服务副本中的数据。
  4. 更改服务器上的文件。

SRE 工具有两个共同特点:

  1. 它们的副作用仍然存在于经过测试的主流 API 之中。
  2. 它们通过既有验证和发布屏障,与面向用户的生产环境隔离。

面向高风险软件的屏障防御

绕过常规且经过充分测试 API 的软件,即使这样做有正当理由,也可能对运行中的服务造成严重破坏。例如,数据库引擎的实现可能允许管理员暂时关闭事务,以缩短维护窗口。如果批量更新软件使用了这种能力,而该工具意外地针对面向用户的副本启动,就可能破坏面向用户服务的隔离性。

可以通过精心设计来避免这类风险:

  1. 使用单独的工具在复制配置中设置屏障,使副本无法通过健康检查。这样,该副本就不会被提供给用户。
  2. 配置存在风险的软件,使其在启动时检查是否存在安全屏障。只允许此类高风险软件访问不健康的副本。
  3. 使用与黑盒监控相同的副本健康验证工具来移除屏障。

自动化工具也是软件。由于它们的风险影响看似与服务的其他层级无关,因此它们的测试需求也更加精细。自动化工具可能执行以下任务:

  1. 数据库索引选择。
  2. 数据中心之间的负载均衡。
  3. 重新排列中继日志以实现快速重放。

自动化工具有两个共同特点:

  1. 实际执行的操作会作用于一个强大、可预测且经过充分测试的 API。
  2. 该操作的目的,是产生一种副作用,而这种副作用对另一个 API 客户端而言表现为不可见的中断。

测试可以验证另一个服务层在变更前后是否仍然表现出预期行为。通常,可以测试通过 API 观察到的内部状态在整个操作过程中是否保持一致。例如,即使没有可用的合适索引,数据库也应努力返回正确结果。另一方面,某些已记录的 API 不变量,例如 DNS 缓存在 TTL 到期前应继续保留,可能在整个操作过程中不再成立。例如,如果运行级别变更将本地名称服务器替换为缓存代理,这两种方案都可以保证已完成的查询在数秒内仍然可用,但缓存状态不太可能在两者之间传递。

既然自动化工具意味着要为其他二进制文件增加发布测试,以应对环境中的瞬时变化,那么应如何定义这些自动化工具本身的运行环境?毕竟,一个用于优化容器利用率的自动化工具如果也运行在容器中,就很可能也会尝试迁移自己。如果其内部算法的新版本迅速产生脏内存页,导致相关镜像的网络带宽不足,最终无法完成实时迁移,那将十分尴尬。即使存在一个要求二进制文件有意迁移自身的集成测试,该测试也未必会使用生产规模的容器集群模型。几乎可以肯定的是,它也不会允许使用稀缺且高延迟的洲际带宽来测试这类资源竞争。

更有趣的是,一个自动化工具可能会改变另一个自动化工具的运行环境。或者,两个工具可能同时改变彼此的运行环境。例如,集群升级工具在推送升级时可能消耗最多资源。因此,容器重平衡工具可能倾向于迁移该工具。反过来,容器重平衡工具自身也偶尔需要升级。如果相关 API 具备重启语义,并且有人记得为这些语义实现测试覆盖,同时检查点健康状况也能得到独立保证,那么这种循环依赖是可以接受的。

灾难恢复测试

许多灾难恢复工具都可以被精心设计为离线运行。这类工具可以执行以下操作:

  1. 计算一个检查点状态,使其等价于服务被干净停止后的状态。
  2. 将检查点状态推送到现有非灾难验证工具可以加载的位置。
  3. 支持常用的发布屏障工具,由这些工具触发干净启动流程。

在许多情况下,可以实现这些阶段,使测试易于理解且具备较高覆盖率。但如果必须打破任何约束,例如离线、检查点、可加载、屏障或干净启动,就很难保证相关工具能够在短时间内正确运行。

在线修复工具本质上独立于主流 API 运行,因此测试起来更具挑战性。在分布式系统中,一个难点是如何判断正常行为,即可能本质上具有最终一致性的行为,是否会与修复过程发生不良交互。例如,考虑一个可以用离线工具分析的竞态条件。离线工具通常期望系统具备即时一致性,而不是最终一致性,因为即时一致性更容易测试。情况会进一步复杂化,因为修复二进制文件通常与它所竞争的生产环境二进制文件是分开构建的。因此,可能需要构建一个统一的、经过插桩的二进制文件来运行这些测试,以便工具能够观察到事务过程。

使用统计测试

统计技术,例如模糊测试技术以及分布式状态测试技术,并不一定具有可重复性。仅仅在代码变更后重新运行这些测试,不能最终证明观察到的故障已经被修复。98 然而,这些技术仍然很有用:

  1. 它们可以记录某次运行中所有随机选择的操作。有时只需记录随机数生成器的种子即可。
  2. 如果这份日志会立即被重构为发布测试,那么在编写错误报告之前,先多运行几次通常很有帮助。重放测试不失败的次数,可以帮助判断之后要证明故障已修复会有多困难。
  3. 故障表现形式的差异,可以帮助更准确地定位代码中的可疑区域。
  4. 后续运行中可能出现比初始运行更严重的故障情形。因此,可能需要提高该缺陷的严重性和影响等级。

对测试速度的要求

对于代码库中的每个版本或补丁,每个已定义测试都会给出通过或失败的结果。即使运行多次且看似完全相同,该结果也可能发生变化。可以通过对多次运行结果取平均值,并计算该概率的统计不确定性,来估计测试实际通过或失败的概率。然而,对每个版本点上的每个测试都执行这样的计算,在计算上并不可行。

因此,必须针对众多感兴趣的场景提出假设,并对每个测试和版本执行适当次数的重复运行,从而得出合理推论。其中一些场景,从代码质量角度看,是良性的;另一些则需要采取措施。这些场景会以不同程度影响所有测试尝试,并且由于它们彼此相关,要可靠且快速地得到可操作的假设列表,也就是判断哪些组件确实存在问题,就必须同时评估所有场景。

使用测试基础设施的工程师希望知道,他们自己的代码,通常只占某次测试运行所依赖全部源代码的一小部分,是否存在问题。通常,如果他们的代码没有问题,就意味着任何观察到的故障都可以归因于其他人的代码。换句话说,工程师想知道,他们的代码是否存在意料之外的竞争条件,导致测试不稳定,或者比其他因素导致的测试本身的不稳定性更加严重。

测试截止时间

大多数测试都很简单:它们作为独立、封闭的二进制文件运行,只占用少量计算资源,运行时间仅几秒。这些测试能够为工程师提供交互式反馈,帮助他们在将注意力转移到下一个缺陷或任务之前发现错误。

需要跨多个二进制文件编排,或者需要大量容器组成集群的测试,其启动时间通常以秒为单位。这类测试通常无法提供交互式反馈,因此可以归类为批处理测试。它们失败时,并不是告诉工程师“不要关闭编辑器标签页”,而是在告诉代码审查者:“这段代码还没有准备好接受审查。”

非正式的测试截止时间,就是工程师下一次上下文切换的时间点。测试结果最好在工程师切换上下文之前交到他们手上,否则下一个上下文可能已经切换到别的事情上。

假设一位工程师正在开发一个包含超过 21,000 个简单测试的服务,并且偶尔会针对该服务代码库提交补丁。为了测试这个补丁,工程师需要比较补丁前后代码库的通过/失败结果向量。如果两个向量的比较结果良好,就可以初步判断该代码库是否满足发布条件。这个判断会促使工程师运行大量发布测试、集成测试,以及其他分布式二进制测试,用以检验系统的扩展性和复杂性:如果补丁显著增加了本地计算资源使用量,就需要关注扩展性;如果补丁在其他地方造成超线性工作负载,就需要关注复杂性。

误判环境不稳定性的概率有多大?如果每 10 个补丁中就有 1 个被错误拒绝,用户很可能会强烈抗议。但如果每 100 个完美补丁中只有 1 个被错误拒绝,可能就不会引发太大争议。

这意味着,真正关心的是 0.99,也就是补丁被接受比例,的第 42,000 个根。这里的 42,000 来自补丁前后每个已定义测试各有一个结果。计算如下:

[
\sqrt[42000]{0.99}
]

这意味着,这些单独测试必须在超过 99.9999% 的情况下正确运行。嗯。

推向生产环境

尽管生产环境的配置管理通常保存在源代码控制仓库中,但配置往往与开发人员的源代码分离。同样,软件测试基础设施通常也无法访问生产环境配置。即使两者位于同一个仓库中,配置管理变更也往往在不同分支或独立目录树中进行,而测试自动化历来会忽略这些分支或目录树。

在传统企业环境中,软件工程师开发二进制文件后,会将其交给管理员进行服务器更新。在这种情况下,测试基础设施与生产配置的分离,轻则令人烦恼,重则损害可靠性和敏捷性。这种分离还可能导致工具重复。在名义上一体化的运维环境中,这种分离会降低系统弹性,因为它会在两套工具的行为之间造成细微不一致。此外,由于版本控制系统之间存在提交竞争,这种分离还会限制项目进度。

在 SRE 模型中,将测试基础设施与生产配置分离的影响更加严重,因为它阻碍了将描述生产环境的模型与描述应用程序行为的模型关联起来。这种差异会影响希望在开发阶段发现预期结果统计差异的工程师。不过,这种分离与其说是减缓开发速度,不如说是阻碍系统架构变更,因为迁移风险无法被充分消除。

考虑一个统一版本控制和统一测试的场景,以便 SRE 方法论能够充分发挥作用。分布式架构迁移失败会造成什么影响?届时可能会运行相当多的测试。暂且假设软件工程师可以接受测试系统大约每 10 次测试中出现 1 次错误结果。如果已知测试可能会出现假阴性,而且情况很快可能变得棘手,那么团队愿意承担多大风险来推进迁移?显然,某些测试覆盖范围需要比其他范围更高的谨慎程度。这种区别可以概括为:某些测试失败比其他测试失败预示着更大的风险。

预期的测试失败

不久以前,一款软件产品可能一年才发布一次。它的二进制文件由编译器工具链耗时数小时甚至数天生成,大部分测试也由人工根据手写指令完成。这种发布流程效率低下,但在当时几乎没有自动化的必要。发布工作的主要成本集中在文档编写、数据迁移、用户培训以及其他事务上。无论进行了多少测试,这些版本的平均故障间隔时间也只有一年。每次发布都会包含大量变更,因此软件中必然隐藏着一些用户尚未察觉的缺陷。实际上,前一个版本的可靠性数据,对下一个版本几乎没有参考价值。

高效的 API/ABI 管理工具,以及能够扩展到大规模代码库的解释型语言,如今已经支持每隔几分钟构建并执行一个新的软件版本。原则上,一支足够庞大的测试团队,例如 100 人,可以使用前文描述的方法测试每个新版本,并确保每个增量版本都达到相同质量标准。即使最终对同一份代码应用同一组测试,每年发布的最终软件版本质量也会更高。这是因为,除了年度版本之外,代码的中间版本也经过了测试。通过测试中间版本,可以明确地将测试中发现的问题追溯到根本原因,并确保整个问题,而不仅仅是暴露出来的有限症状,得到修复。这种缩短反馈周期的原则,同样适用于自动化测试覆盖率。

如果允许用户在一年内试用更多软件版本,MTBF 会受到影响,因为用户可感知故障发生的机会会增加。然而,也可以借此发现哪些方面需要增加测试覆盖率。如果实施这些测试,每一项改进都能预防未来可能出现的故障。谨慎的可靠性管理会将测试覆盖率带来的不确定性限制,与用户可感知故障限制结合起来,从而调整发布节奏。这种组合能够最大限度利用来自运营和最终用户的知识。这些知识的积累会提高测试覆盖率,进而加快产品发布速度。

如果 SRE 修改的是配置文件,或调整自动化工具的策略,而不是实现用户功能,其工程工作也符合相同的概念模型。在基于可靠性定义发布节奏时,通常按功能,或者更方便地按团队,划分可靠性预算是合理的。在这种情况下,功能工程团队的目标是满足一定的不确定性限值,这会影响其目标发布节奏。SRE 团队拥有独立预算和自身的不确定性限制,因此它们的发布频率也存在上限。

为了保持可靠性,并避免支持服务所需的 SRE 人数随规模线性增长,生产环境必须在大多数时间无人值守地运行。要实现无人值守,环境必须能够抵御轻微故障。当发生需要 SRE 人工干预的重大事件时,SRE 使用的工具必须经过充分测试。否则,这种干预会降低历史数据对近期预测的适用性。这种适用性的降低,又需要等待监控数据分析结果来消除由此产生的不确定性。前文“测试可扩展工具”侧重讨论如何满足 SRE 工具的测试覆盖率要求;本节则说明测试如何决定该工具在生产环境中的使用频率。

配置文件之所以存在,通常是因为修改配置比重新构建工具更快。这种低延迟通常有助于降低 MTTR。然而,这些文件也经常因为一些并不需要低延迟的原因而被修改。从可靠性角度看:

  1. 如果某个配置文件旨在降低 MTTR,且只在发生故障时才会修改,那么它的发布频率低于 MTBF。因此,在不影响站点整体可靠性的前提下,手动修改是否真正达到最优状态,存在相当大的不确定性。
  2. 如果某个配置文件在每次面向用户的应用程序版本发布期间多次更改,例如因为它保存了版本状态,而这些更改没有得到与应用程序版本发布同等程度的对待,就可能构成重大风险。如果该配置文件的测试和监控覆盖率没有显著优于用户应用程序,那么该文件会对站点可靠性产生负面影响。

处理配置文件的一种方法,是确保每个配置文件都只属于上述两类之一,并以某种方式强制执行这一规则。如果采用后一种策略,应确保以下几点:

  1. 每个配置文件都具备足够测试覆盖率,以支持常规编辑。
  2. 在正式发布之前,文件编辑会因等待发布测试而产生延迟。
  3. 提供一种紧急机制,允许在测试完成前将文件发布到生产环境。由于紧急发布会降低可靠性,通常最好让用户能够注意到这一点,例如提交一个缺陷报告,要求下次采用更可靠的解决方案。

应急机制与测试

可以实现一种紧急机制,用于禁用发布测试。但这意味着,在监控报告真实用户影响之前,任何匆忙进行手动编辑的人都不会收到错误提示。更好的做法是保持测试运行,将提前推送事件与待处理测试事件关联起来,并尽快用任何失败的测试对推送进行反向标注。这样,一次有缺陷的手动推送,可以很快被另一次,希望缺陷更少的,手动推送取代。理想情况下,这种紧急机制会自动提高这些发布测试的优先级,使其优先于测试基础设施正在处理的常规增量验证和覆盖率工作负载。

整合

除了对配置文件进行单元测试以降低其可靠性风险之外,对配置文件进行集成测试也至关重要。出于测试目的,配置文件的内容对读取配置的解释器而言可能构成潜在威胁。Python 等解释型语言常用于配置文件,因为它们的解释器可以嵌入配置加载流程,并且一些简单的沙箱机制可以防止非恶意编码错误造成严重后果。

使用解释型语言编写配置文件风险较高,因为这种做法容易带来难以彻底消除的潜在故障。由于加载内容实际上就是执行程序,因此加载操作的低效程度没有理论上限。除其他测试外,还应该对所有集成测试方法设置严格的截止时间检查,将未能在合理时间内完成的测试标记为失败。

如果配置信息以自定义语法的文本形式编写,那么每一类测试都需要从头单独编写。使用现有语法,例如 YAML,并结合经过充分测试的解析器,例如 Python 的 safe_load,可以减轻配置文件带来的一部分工作量。精心选择语法和解析器,可以确保加载操作耗时有严格上限。然而,实现者仍需要处理模式错误,而大多数简单处理策略都无法限制运行时间。更糟的是,这些策略往往缺乏完善的单元测试。

使用预定义模式的结构化序列化格式,其好处是模式预先定义,并且会在加载时自动检查,从而进一步减少工作量,同时仍然提供有界运行时间。

SRE 的职责通常包括编写系统工程工具,如果其他人尚未编写这些工具的话,并通过测试覆盖率增强验证能力。所有工具都可能因为测试未发现的缺陷而出现异常行为,因此建议采用纵深防御策略。当某个工具出现异常行为时,工程师需要尽可能确保其他大多数工具仍能正常运行,从而缓解或解决该异常行为带来的负面影响。确保站点可靠性的关键,在于发现每一种预期异常行为,并确保某些测试,或其他工具的测试输入验证器,能够报告这些异常行为。发现问题的工具可能无法修复甚至阻止问题,但至少应该在发生灾难性故障之前报告问题。

例如,考虑一个配置好的用户列表,例如非联网类 Unix 机器上的 /etc/passwd 文件。假设一次意外编辑导致解析器在只解析了文件一半后就停止。由于新创建的用户尚未加载,机器很可能继续正常运行,许多用户也可能不会注意到错误。维护用户主目录的工具可以轻易发现实际存在的目录与部分用户列表所隐含目录之间的不匹配,并立即报告这种差异。该工具的价值在于报告问题,而应避免自行尝试修复,例如删除大量用户数据。

生产探针

测试旨在确定系统在已知数据下是否表现出可接受行为,而监控旨在确认系统在未知用户数据下是否表现出可接受行为。因此,测试与监控结合起来,似乎可以覆盖主要风险来源,包括已知风险和未知风险。然而,实际风险远比这更复杂。

已知良好的请求应该能够正常工作,已知不良的请求则应该报错。在集成测试中同时覆盖这两类请求,通常是一个好主意。可以像发布测试一样重放同一组测试请求。将已知良好的请求进一步划分为可以在生产环境中重放的请求和不能在生产环境中重放的请求,就可以得到三组请求:

  1. 已知不良请求。
  2. 已知良好且可以针对生产环境重放的请求。
  3. 已知良好但无法在生产环境中重放的请求。

每组测试用例都可以用作集成测试和发布测试。其中多数测试用例还可以作为监控探针使用。

部署这类监控似乎是多余的,原则上也似乎没有意义,因为相同请求已经通过其他两种方式尝试过了。然而,这几种方式之间存在差异,原因如下:

  1. 发布测试很可能是在集成服务器基础上,增加一个前端和一个模拟后端。
  2. 探针测试可能覆盖的是发布后的二进制文件,其中包括负载均衡前端,以及独立、可扩展的持久化后端。
  3. 前端和后端可能拥有独立发布周期。由于它们的发布节奏具有自适应性,这些周期的进度很可能不同。

因此,在生产环境中运行的监控探针,实际上是一种此前未被测试过的配置。

这些探针操作不应该失败;但如果失败了,意味着什么?要么是来自负载均衡器的前端 API,要么是通往持久化存储的后端 API,在生产环境与发布环境之间存在差异。除非已经知道生产环境和发布环境存在差异的原因,否则站点很可能已经发生故障。

同一个生产更新程序在逐步替换应用程序的同时,也会逐步替换探针,从而持续生成四种新旧组合:旧探针到旧应用程序、旧探针到新应用程序、新探针到旧应用程序,以及新探针到新应用程序。该更新程序可以检测这四种组合中哪一种发生错误,并回滚到上一个已知良好状态。通常,更新程序预期每个新启动的应用程序实例在准备接收大量用户流量前,都会在短时间内处于不健康状态。如果探针已经被纳入就绪检查,那么更新可以安全地无限期失败,而不会将任何用户流量路由到新版本。更新会保持暂停,直到工程师有时间和意愿诊断故障情况,并促使生产更新程序干净回滚。

这种基于探针的生产环境测试确实能够保护站点,并为工程师提供清晰反馈。反馈越早提供给工程师,就越有用。此外,最好将测试自动化,以便能够大规模向工程师发出警告。

假设每个组件都有一个正在被替换的旧版本软件,以及一个正在部署或即将部署的新版本。新版本可能正在与旧版本对等组件通信,这会迫使它使用已弃用的 API。或者,旧版本可能正在与对等组件的新版本通信,并使用旧版本发布时尚未完全兼容的 API。但现在它确实能够正常工作。此时,最好确保那些用于未来兼容性的测试,作为监控探针运行时,对 API 的覆盖范围足够全面。

虚拟后端版本

在实施发布测试时,模拟后端通常由对等服务的工程团队维护,并且仅作为构建依赖项被引用。测试基础设施执行的封闭式测试,始终会将模拟后端和测试前端合并到版本控制历史记录中的同一构建点。

该构建依赖项可能提供一个可运行的封闭式二进制文件。理想情况下,维护该依赖项的工程团队会在发布主后端应用程序及其探针程序的同时,发布这个虚拟后端二进制文件。如果后端版本可用,那么将封闭式前端发布测试,不包含虚拟后端二进制文件,纳入前端发布包中,可能是有价值的。

监控系统应该能够感知两个对等节点之间某个服务接口两端的所有版本。这种设置可以确保,在检索两个版本的所有组合并判断测试是否仍然通过时,不需要太多额外配置。这类监控不必持续运行;只需运行由任一团队发布新版本所产生的新组合即可。此类问题不一定要阻止新版本本身的发布。

另一方面,理想情况下,部署自动化流程应该阻止相关生产环境部署,直到问题组合不再可能出现。同样,对等团队的自动化流程也可以考虑排空并升级那些尚未从问题组合中迁移出来的副本。

结论:可靠性测试是提升系统稳定性的长期工程

可靠性测试是工程师提升产品可靠性最有效的投资之一。测试并不是项目生命周期中只做一两次的活动,而是一个持续过程。编写高质量测试用例需要大量投入,构建和维护能够促进强大测试文化的基础设施同样如此。

只有理解问题,才能解决问题;而在工程领域,只有通过测量才能真正理解问题。本章介绍的方法和技术,为测量软件系统中的缺陷和不确定性奠定了坚实基础,并帮助工程师在软件编写和发布过程中,更好地理解其可靠性。对于 SRE 团队而言,持续完善可靠性测试体系,是提升系统稳定性、控制发布风险、降低 MTTR 并延长 MTBF 的关键路径。

文章包含AI辅助创作,作者:liu,如若转载,请注明出处:https://docs.pingcode.com/baike/5244691

(0)
liuliu
免费注册
电话联系

4008001024

微信咨询
微信咨询
返回顶部