| 2022-05-30
让开发人员能够快速开发原型、测试和迭代新功能对 Facebook 的成功至关重要。
为了有效地做到这一点,关键是要有一个稳定基础设施,它不会造成非必要摩擦力(注意:是「非必要摩擦」,并不是「没有摩擦」)。
为了支持全球 30 多亿用户,这些软件基础设施必须要扩大规模,以便利用越来越多的计算能力,并处理极其庞大且不断膨胀的代码库时,这变得更具挑战性。
我们应对这一挑战的两种方式:它们是:
- 更好的抽象。
- 自动化测试。
「抽象」是指一个面向服务的基础设施,允许将我们的业务逻辑构建为独立编写、部署和扩展的组件。
虽然这对快速迭代很重要,但它也增加了测试的复杂性:
- 单元测试虽然可用于检查微服务内部的逻辑,但无法很好地验证服务之间的依赖关系。
- 集成测试是单元测试的补充。但是,与标准化度极好的单元测试框架不同,我们并没有可用于后端服务的现成集成测试框架。
因此,我们设计并开发了一个集成测试框架。
上面的图片是从一万英尺高空看 Facebook 的测试基础设施,以及后端测试的相关选项。
今天,本文详细介绍 Facebook 在这个集成测试基础设施之上所构建的一个新的自主测试扩展工具,以及对基础设施本身的幕后观察。
这一扩展工具借用了模糊测试( Fuzzing Test )的想法。模糊测试是一种使用随机输入来查找 Bug 的自动化技术,并利用 Facebook 软件栈的同质性来提供无缝的开发人员体验并鼓励快速迭代。
迄今为止,Facebook 的大多数自主测试都是通过 Infer
、Sapienz
和 Zoncolan
等工具专注于我们的前端或安全性。
本文将讨论如何对后端服务进行自主测试 ( Autonomous Testing )。
集成测试基础设施
集成测试基础设施要鼓励工程师编写有效的自动化测试用例,并且在需要时自动运行它们,然后以直观的方式呈现结果。
集成测试基础设施通过提供代码框架、测试调度和执行功能,以及与持续集成系统的适当钩子来做到这一点。
代码框架封装了模板,并提供了常见的抽象和模式,以消除编写测试时常见的「坑」,例如:在编写测试时使用不稳定的代码结构。
本文将涵盖编写自动化测试用例的三个方面,重点关注集成测试所特有的部分:定义测试环境、指定输入和检查输出。
上图为集成测试的组件。其中,测试基础设施提供了工程师编写测试的基础,以及运行测试的执行平台。
定义测试环境
为了提供确定性结果并避免副作用,自动化测试用例通常不会在生产环境中运行。
对于单元测试尤其如此,单元测试专注于一小部分代码,并使用 Mock 或 Stub 技术替换外部依赖项。虽然这避免了副作用,但它的缺点是低估了被测系统的复杂性。
Mock 本质上只实现了其所实际依赖项中的一部分行为。因此,有些错误可能不会未被发现,另外,维护 Mock 对象也还需要大量的工程工作。
相比而言,集成测试则较少依赖于 Mock 对象。对后端服务进行测试时,通常涉及会一个或多个服务。
假如 所用的服务不必被改动就可以进行自动化测试,其有几个优点。
首先,它避免了给服务所有者带来负担。其次,也是更重要的是,它让测试时所用的代码与在生产环境中运行的代码是一致的,从而让这些测试更具代表性。
这也提出了两个必须被解决的重要挑战。
- 创建适合于运行无须修改的服务的测试环境。
- 必须确定如何设置测试环境的边界,以及如何处理跨越这些边界的连接。
这些挑战需要有务实的方法。
Facebook 的解决方案是重用了在生产环境上的基础设施,特别是容器化和路由系统,以构建测试环境。
然而,我们在这些基础设施中,为每一个测试创建了单独的临时实体。
这样的话,测试环境并不接收来自生产环境的请求,但可以自动连接到生产环境。所以,一些测试就可以与生产环境共享那些具有「只读」属性的资产或 API 。同时,使用额外的隔离层来限制那些有「写」属性的测试用例连接到生产环境。
测试框架会授权服务所有者根据其自身想要检查的服务交互,来定义其测试环境的边界。网络请求优先由与调用者在同一环境中运行的那个服务实例提供服务。如果环境中不存在这样的服务实例,请求就可以进入并访问生产环境的内存副本,写操作会被阻止,但只读请求会被转发到生产环境。
在这种配置中,还是会使用到 Mock 服务。它其实是实时创建和启动与原始服务具有相同界面,但其实现代价很微不足道的模拟服务来发挥作用。
模拟服务在与测试在相同的地址空间中运行。这让两者可以轻松互动。模拟实现可以在运行时被更改,就像我们在单元测试中更改单元测试的 Mock 一样。我们将每个 Mock 方法处理程序包装成标准的 Python MagicMock 或 StrictMock 。这样做可以很容易地检查它接到的调用次数以及它的被调用点。这其实是一种「Spy」能力。
对于常见的依赖项(如存储),使用内存中的存储实例副本很有效。
除此之外,测试基础设施还可以阻止从测试环境向生产环境的连接。
我们稍后将在自主测试的背景下对此进行更详细的讨论。
测试的输入源
在最一般的形式中,测试的输入源是以命令式的方式由测试夹具( Test Fixture )提供,测试夹具是在测试环境中与被测试的服务一起执行的程序,用于固定被测物。
夹具可以直接(可能通过远程过程调用 RPC )执行服务,也可以通过在测试环境中进行变更,间接地执行服务。例如,它可以应用新的全局配置设置或关闭测试服务副本。虽然测试框架为这些操作提供了操作原语,但构建测试用例是服务所有者的责任。
Mock 是集成测试涉及的另一个输入源。它们可以被配置成发送某些特定的 Response ,这些 Response 基本上充当被测服务的输入。由依赖引发的失败是一种特殊的输入情况,可以通过从 Mock 中抛出异常来模拟这种情况。
测试断言
大多数测试断言是关于服务行为的自定义断言。虽然这些断言原则上类似于单元测试断言,但它们只能检查被测试服务的外部可见行为,而不能检查其内部状态。这包括 RPC 响应、传递给 Mock 调用的参数,以及写入临时测试数据库的数据,等等。
测试基础设施还会检测一些常见故障,例如通过健康仪表盘发现并标记出来的崩溃与错误,或者由监控基础设施发现并标记的出来的服务健康问题。
可扩展性
集成测试基础设施的设计目标之一是允许团队在其上进行自己的扩展。这种扩展有两个主要用途。
第一个用途是解决团队服务中出现的常见模式,例如测试环境设置或团队经常使用的一些定制化程序。这些扩展还可以定义特殊类型测试的基础,例如灾难准备测试。如果需要,我们可以从零开始引导基础设施中最基本的服务,如 ZooKeeper 。
自主集成测试
上述框架为服务所有者提供了编写集成测试的框架。然而,通过提供合理的默认值,甚至自动生成所有测试组件,测试基础设施在许多情况下可以做得更好。
为了定义测试环境,基础设施应该是对生产环境上的服务的影子。
所以,Facebook 以标准方式定义了测试环境。这样,所有的服务都可以被 Facebook 集群管理系统 Twine 所管理。当然,其他组件也可以通过编程方式对其进行检查。
测试用例自身可以检查环境,并在进行特定修改之前对其进行健全性检查,然后将其传递回 Thine 进行实例化。
健全性检查负责通知服务所有者,在某些情况下是否需要他们的干预。例如,当服务需要在一些在默认测试机池中无法提供的的特殊硬件时发出通知。针对具体测试用例的修改,可包括:(1)将测试实例与生产系统隔离;(2)降低服务的资源需求以节省容量;(2)以及其他小的变更。
在自主测试( autonomous testing )中,「隔离性」是一个特别重要的因素。因为测试基础设施决定了使用哪些输入。
然而,不管选择什么,测试用例都必须没有副作用。例如,在某种情况下,来自测试用例的有关 API 故障的数据被报告给了监控基础设施,而监控系统认为该故障源于生产系统,并发出了警报。这种类似的虚假警报就是一个副作用的例子。
虽然从技术角度来看很简单,但将测试环境与基础设施的其它部分完全隔离通常会导致测试用例出现故障。
这就是为什么我们必须采取更巧妙的方法:
- 只允许那些已知的只读操作,有流量通过。
- 让服务所有者能为安全目的而设置一个白名单。
- 将所有标准 RPC 流量重新路由到一个通用 Mock 服务,它可以模拟任何服务并返回假值。
- 能阻止所有其他网络请求。
通过这种方法,就可以在安全的测试环境中运行大约三分之一左右的服务,而无需人工干预。
在实现隔离时,可以结合两种方法:「细粒度的应用程序级隔离」和「粗粒度的网络级隔离」。
**对于 RPC 调用,可以采用应用程序级隔离。**也就是说,根据调用的 API 来阻断连接。
网络级别的隔离是在 IP:PORT
级别的粒度上隔离。
即允许连接到在已知端口(如 DNS )上侦听的服务。
除了根据连接的服务端点做出决策外,隔离系统还可以根据启动连接的代码做出决策。这很有价值,因为有些代码可以安全地使用潜在的不安全 API 。隔离逻辑通过在运行时检查堆栈跟踪来识别调用者。
在构建隔离层时,可以考虑了两种实现选项: BPF 和 LD_PRELOAD 。Facebook 采用 LD_PRELOAD ,因为它提供了更多的灵活性。预加载逻辑从配置管理系统中检索茜个具体服务的隔离配置,并通过截获对 libc connect、sendto、sendmsg 和sendmsg 函数的调用来相应地阻断连接。
测试输入
为了进一步探索测试自动化,我们研究了现有的自动化技术。模糊技术( Fuzzing )与我们的集成测试结构非常匹配。它的动态特性很好地符合经典测试范式,而它的自动输入生成补充了手动编写的测试。
模糊测试的本质是一种随机测试。尽管它基础原理非常简单,但是,模糊测试的设置需要几个手工步骤:
- 将要被模糊化的代码分割成一个独立的单元(测试目标)。典型的模糊测试在相对较小的代码单元上运行,与单元测试相当。
- 编写一个模糊测试工具,负责将随机数据塑造成模糊代码预期的类型,并在测试目标中调用正确的函数。
- 确保随机数据符合测试目标预期的约束条件。
而最后的这一点值得花一些笔墨,进一步澄清一下。
为了说清楚,让我们来举个例子。
假设我们想要对 strlen()
函数进行模糊( Fuzzing ),它需要一个指向以 NULL
结尾的字符串的有效指针。当使用模糊技术( Fuzzing )来生成输入时,需要确保它们都是指向以 NULL
结尾的字符串的有效指针。其他任何事情都会导致代码报错——不是因为代码中存在错误,而是因为调用方参数和被调用方期望之间存在不匹配。通常,这些期望(又名 API 契约)是隐含的,因此需要手工操作。
将模糊技术与集成测试相结合时,我们可以自动执行上述手动步骤:
- 如前面描述的那样,我们基于生产环境来创建这个模糊测试环境。这样就不需要手动切割代码,而手动切割代码是要经过测试才行的。
- 由于 Facebook 的 Thrift RPC 框架,服务的 API 契约是明确的,并且可以通过编程方式获得。Thrift 提供了一种具有反射功能的接口定义语言,支持API及其参数的枚举。此外,可以递归地检查参数类型及其属性,如必需属性( requiredness )。基于此信息生成适当的值后,可以动态实例化每个参数。我们以两种方式使用这种能力:第一,为被测服务构造输入。第二,自动 Mock 服务的依赖关系,并将默认值发送回服务。这样,我们就以正确的格式自动创建随机数据,从而自动创建模糊线束。
- 服务可能对其通过网络接收到的输入根本没有预期。这种情况,就避免了由于输入值和服务期望之间的不匹配而导致的所有错误的机会。这也就意味着,此时所有遇到的错误都指向实际的错误。
构造输入的最简单方法是为每种数据类型随机选择适当的值。这种方法自动地提供了一个测试基线,并具有一种可以识别在人工设计测试用例时可能忽略的边缘情况的优势。
通过为每种数据类型手工配置的“边缘”值(比如整数的 MAX_INT )就可以增强这个过程。
随机(和 模糊 )测试的缺点在于:当对输入的校验中涉及复杂的约束(例如 Checksum()
)时,它就无效了。
在这种情况下,有点儿天真且简单白痴的模糊技术就很难提取出有效的输入。
因此,除了任何初始输入验证之外,它不会执行服务逻辑。
为了克服这个问题,并提高自主测试的有效性Facebook 使用了「录制/回放」的方法。
Facebook 定期录制影响生产环境服务的一小部分请求,再对其进行一定的清理,使其可用于测试基础设施。
在这里,没有使用完全随机的输入数据来执行测试,录制下来的请求会发生不同程度的变异。这种方法背后的基本原理是:产生的输入将保留足够多的原始请求的有效结构,以执行「深度路径」,但也有足够的随机性,以执行这些路径上的边缘情况。
与纯模糊技术相反的是,Facebook 使用录制的请求,而不改变它们。这样的方式可以验证一个新版本的服务可以处理前一版本接收到的流量,并且不会出现异常行为,类似于经典的金丝雀测试。
通过记录和回放来解决问题的优点在于:它不需要单独的测试基础设施,也不影响生产系统。
测试断言
除了像 ASAN 这样的崩溃和或健康扫描工具之外,我们还要寻找那些未声明的异常,并检查日志中的可疑消息。
例如,一个有趣的例子是 MySQL Programming Error 异常。这种异常通常是由 模拟程序具备影响 SQL 查询的能力触发的。它通过调用带有未预期参数的 API 实现了这一点,这表明存在 SQL 注入的漏洞。
类似的情况还有 Python Syntax Error 异常。在这种情况下,Fuzzer 能够修改传递给 eval 的字符串,指向潜在的任意代码执行。
部署与经验总结
Facebook 的自主集成测试部署策略包括两个阶段。
首先,Facebook 最开始只是在后台运行它们,以便获得尽可能多的服务,但却不需要这些服务维护者的参与。这让框架提供者能够了解存在哪些改进机会,以及报告问题的最佳方式。
接下来,框架提供者会鼓励服务的所有者在部署新版本的服务之前选择自动运行这些测试。框架提供者选择了opt-in 模式(由各团队自主选择使用)。因为,如果这个模糊测试失败了,就需要服务所有者立即采取措施解除对服务部署流水线的阻塞。
2021年10月,Facebook 从第一步开始转到第二步。
在第一阶段的运行中,使用应用于模糊集成测试的隔离,框架提供者能够安全且自动地对 Facebook 大约三分之一的 Thrift 服务进行模糊测试。模糊测试发现了1000 多个bug。对于每个 bug ,我们都向服务所有者发送了一份报告。
剩下的三分之二的服务要么有非标准的设置,要么有严格的权限,阻止我们重用它们的生产制品,要么由于我们实施的严格隔离性而无法达成。
在这一阶段,框架提供者只是通过错误报告与服务所有者进行沟通。
在这个过程中,框架提供者学到了一些东西。
首先,通过模糊测试发现,在隔离测试环境方面存在很明显的改进点。通过改进,框架可以支持一种更细粒度和可扩展的方法来标记只读 API 。另外,框架提供者持续思考如何为集成测试环境提供第一级抽象,并通过可组合性提供重用测试环境的能力。
其次,向服务所有者提供尽可能多的关于检测到的漏洞的信息是至关重要的。与单元测试相比,当集成测试失败时,调试和诊断本身都很困难。虽然堆栈跟踪有助于理解错误和崩溃,但高效的调试还需要充分了解崩溃的服务及其所使用的库。
总的来说,API 契约冲突比崩溃更容易调试和诊断。
第三,随机输入使得对错误的解释变得更加困难,工程师们更难对错误进行推理,确定其根本原因。我们可以通过更广泛地使用录制的流量,并提供格式良好的输入来解决这个问题。在某些情况下,有的工程师认为,当给定随机输入时,服务可以通过抛出未声明的异常或崩溃来打破 API 契约。但框架提供者反对这种做法,而是依靠彻底的输入验证。
最后,了解模糊测试的有效性至关重要。
迄今为止,Facebook 只将发现的 bug 视为一种有效性指标,并且刚刚开始衡量整个服务的覆盖率。这可能会让服务所有者进一步掌握他负责的服务,到底哪些部分需要额外测试。
同时,可以对集成模糊测试基础设施进行的改进,以提高整体覆盖率。
参考文章: