Facebook:微服务的模糊自动化测试

乔梁 | 2022-04-24

让开发人员能够快速开发原型、测试和迭代新功能对 Facebook 的成功至关重要。为了有效地做到这一点,关键是拥有一个不会造成不必要摩擦的稳定基础设施。当相关基础设施还必须扩大规模,以支持全球 30多亿用户,利用越来越多的计算能力,并处理极其庞大且不断增长的代码库时,这变得更具挑战性。

我们应对这一挑战的两种方式:他们是:

  1. 更好的抽象。
  2. 自动化测试。

抽象是指一个面向服务的基础设施,允许将我们的业务逻辑构建为独立编写、部署和扩展的组件。虽然这对快速迭代很重要,但它也增加了测试的复杂性:单元测试可用于检查服务中的逻辑,但无法测试服务之间的依赖关系。集成测试是它的补充,但与标准化良好的单元测试框架不同,我们没有可用于后端服务的现成集成测试框架。因此,我们设计并建造了一个。

从一万英尺高空看 Facebook 的测试基础设施,以及后端测试的相关选项。

今天,我们详细介绍我们在此集成测试基础设施之上构建的新的自主测试扩展,以及对基础设施本身的幕后观察。此扩展借用了模糊测试( Fuzzing Test )的想法。模糊测试是一种使用随机输入来查找 Bug 的自动化技术,并利用我们软件堆栈的同质性来提供无缝的开发人员体验并鼓励快速迭代。

迄今为止,Facebook 的大多数自主测试都通过 InferSapienzZoncolan 等工具专注于我们的前端或安全性。在这里,我们将讨论如何自主测试后端服务。

集成测试基础设施

集成测试基础设施要鼓励工程师编写有效的测试,并在需要时自动运行它们,并以直观的方式呈现结果。它通过提供代码框架、测试调度和执行功能,以及与持续集成系统的适当钩子来做到这一点。代码框架封装了模板,并提供常见的抽象和模式,以消除常见的编写陷阱,例如在编写测试时使用不稳定结构。我们将涵盖编写测试的三个方面,重点关注集成测试特有的部分:定义测试环境、指定输入和检查输出。

集成测试的组件。测试基础设施提供了工程师编写测试的基础,以及运行测试的执行平台。

定义测试环境

为了提供确定性结果并避免副作用,测试用例通常不会在生产环境中运行。对于单元测试尤其如此,单元测试专注于一小部分代码,并用 Mock 或 Stub 替换外部依赖项。虽然这避免了副作用,但它的缺点是低估了被测系统。mock 本质上只实现了其实际依赖项中的一些行为。因此,一些错误可能会未被发现,维护 Mock 对象 还需要大量的工程工作。

集成测试较少依赖于 Mock 对象。测试后端服务通常涉及一个或多个未修改的服务。不必修改服务就可以进行自动化测试有几个优点。首先,它避免了给服务所有者带来负担,但更重要的是,它使测试使用的代码与在生产环境中运行的代码相同,从而使它们更具代表性。

这提出了两个必须要解决的重要挑战。

  • 涉及创建适合运行未经修改的服务的测试环境。
  • 必须确定如何设置测试环境的边界以及如何处理跨越这些边界的连接。

这些挑战需要有务实的方法。我们的解决方案重用了生产环境基础设施,特别是容器化和路由系统,以构建测试环境。

然而,我们在基础设施中为每个测试创建了单独的临时实体。这使得测试环境不接收来自生产环境的请求,但可以自动连接到生产环境。这使得一些测试可以与生产环境共享那些只读性的资产或 API 。同时,我们使用额外的隔离层来限制其他测试连接到生产环境。

我们授权服务所有者根据他们想要检查的服务交互来定义其测试环境的边界。网络请求优先由与呼叫者在同一环境中运行的服务提供服务。如果环境中不存在适当的服务,请求就可以进入并访问生产环境的内存副本,写操作会被阻止,但只读请求会被转发到生产环境。

在此设置中,Mock 通过实时创建和启动与原始服务具有相同界面,但具有微不足道实现的服务来发挥作用。

模拟服务在与测试线束相同的地址空间中运行。这让两者轻松互动。模拟实现可以在运行时更改,就像我们如何更改单元测试模拟一样。我们将每个 Mock 方法处理程序包装成标准的 Python MagicMock 或 StrictMock 。这样做可以很容易地检查它接到的调用次数以及它的被调用点。

对于常见的依赖项(如存储),使用内存中的副本很有效。除了这个选项之外,测试基础设施还可以阻止从测试环境向生产环境的连接。我们稍后将在自主测试的背景下对此进行更详细的讨论。

测试的输入源

在最一般的形式中,测试的输入源是以命令式的方式由测试夹具提供,测试夹具是在测试环境中与被测试的服务一起执行的程序。该夹具可以直接(可能通过远程过程调用 RPC )执行服务,也可以通过在测试环境中进行变更,间接地执行服务。例如,它可以应用新的全局配置设置或关闭测试服务副本。虽然测试框架为这些操作提供了原语,但构建测试是服务所有者的责任。

模拟是集成测试涉及的另一个输入源。它们可以被配置成发送某些特定的响应,这些响应基本上充当被测服务的输入。由依赖引发的失败是一种特殊的输入情况,可以通过从 Mock 中抛出异常来模拟这种情况。

测试预言

大多数测试预言是关于服务行为的自定义断言。虽然这些断言原则上类似于单元测试断言,但它们只能检查被测试服务的外部可见行为,而不能检查其内部状态。这包括 RPC 响应、传递给 Mock 调用的参数,以及写入临时测试数据库的数据,等等。

测试基础设施还会检测一些常见故障,例如通过健康仪表盘发现并标记出来的崩溃与错误,或者由监控基础设施发现并标记的出来的服务健康问题。

可扩展性

集成测试基础设施的设计目标之一是允许团队在其上进行自己的扩展。这种扩展有两个主要用途。

第一个用途是解决团队服务中出现的常见模式,例如测试环境设置或经常使用的定制。这些扩展还可以定义特殊类型测试的基础,例如灾难准备测试。这些测试验证了,如果需要,我们可以从零开始引导基础设施中最基本的服务,如 ZooKeeper 。

自主集成测试

上述框架为服务所有者提供了编写集成测试的框架。然而,通过提供合理的默认值,甚至自动生成所有测试组件,测试基础设施在许多情况下可以做得更好。

为了定义测试环境,基础设施应该是对生产环境上的服务的影子。所以,我们以标准方式定义了测试环境,所以,所有的服务都可以被Facebook集群管理系统Twine所管理。但是,其他组件也可以通过编程方式对其进行检查。测试用例可以用来检查环境,并在进行特定修改之前对其进行健全性检查,然后将其传递回 Thine 进行实例化。健全性检查负责通知服务所有者,在某些情况下是否需要他们的干预,例如,当服务需要在一些在默认测试机池中无法提供的的特殊硬件时。特定于测试用例的修改包括将测试实例与生产系统隔离,降低服务的资源需求以节省容量,以及其他小的变更。

在自主测试( autonomous testing )中,「隔离性」是一个特别重要的因素。因为测试基础设施决定了使用哪些输入。然而,不管选择什么,测试用例都必须没有副作用。例如,在某种情况下,来自测试用例的有关 API 故障的数据被报告给了监控基础设施,而监控系统认为该故障源于生产系统,并发出了警报。这是一个虚假警报,这就是一个副作用的例子。

虽然从技术角度来看很简单,但将测试环境与基础设施的其它部分完全隔离通常会导致测试用例出现故障。这就是为什么我们必须采取更微妙的方法:

  1. 我们只允许那些已知的只读操作,有流量通过。
  2. 我们会让服务所有者能为安全目的而设置一个白名单。
  3. 我们将所有标准RPC流量重新路由到一个通用 Mock 服务,它可以模拟任何服务并返回假值。
  4. 我们能阻止所有其他网络请求。

通过这种方法,我们可以在安全的测试环境中运行三分之一的服务,而无需人工干预。

在实现隔离时,我们结合了两种方法:「细粒度应用程序级隔离」和「粗粒度的网络级隔离」。

对于 RPC 调用,我们采用应用程序级隔离。也就是说,我们可能会根据调用的 API 阻止连接。网络级别的隔离是在 IP :端口级别的粒度上隔离。我们通常使用它来允许连接到在已知端口(如 DNS )上侦听的服务。除了根据连接的服务端点做出决策外,隔离系统还可以根据启动连接的代码做出决策。这很有价值,因为有些代码可以安全地使用潜在的不安全 API 。隔离逻辑通过在运行时检查堆栈跟踪来识别调用者。

在构建隔离层时,我们考虑了两种实现选项: BPF 和 LD_PRELOAD 。最终,我们决定采用 LD_PRELOAD ,因为它提供了更多的灵活性。预加载逻辑从配置管理系统中检索特定于服务的隔离配置,并通过截获对 libc connect、sendto、sendmsg 和sendmsg 函数的调用来相应地阻塞连接。

测试输入

为了进一步探索测试自动化,我们研究了现有的自动化技术。模糊技术( Fuzzing )与我们的集成测试结构非常匹配。它的动态特性很好地符合经典测试范式,而它的自动输入生成补充了手动编写的测试。

模糊测试的核心是一种随机测试。尽管它非常基础简单,但设置模糊测试需要几个手动步骤:

  1. 将要被模糊化的代码分割成一个独立的单元(测试目标)。典型的模糊测试在相对较小的代码单元上运行,与单元测试相当。
  2. 编写一个模糊测试工具,负责将随机数据塑造成模糊代码预期的类型,并在测试目标中调用正确的函数。
  3. 确保随机数据符合测试目标预期的约束条件。

最后一点值得进一步澄清。为了说清楚,我们举个例子。假设我们想要对 strlen() 函数进行模糊,它需要一个指向以 NULL 结尾的字符串的有效指针。当使用模糊技术( Fuzzing )来生成输入时,需要确保它们都是指向以 NULL 结尾的字符串的有效指针。其他任何事情都会导致代码崩溃——不是因为代码中存在错误,而是因为调用方参数和被调用方期望之间存在不匹配。通常,这些期望(又名 API 契约)是隐含的,因此需要手动操作。

将模糊技术与集成测试相结合时,我们可以自动执行上述手动步骤:

  1. 下如前面描述的那样,我们基于生产环境来创建这个环境。这样就不需要手动切割代码,而手动切割代码是必须经过测试的。
  2. 由于 Facebook 的 Thrift RPC 框架,服务的 API 契约是明确的,并且可以通过编程方式获得。Thrift 提供了一种具有反射功能的接口定义语言,支持API及其参数的枚举。此外,可以递归地检查参数类型及其属性,如必需属性( requiredness )。基于此信息生成适当的值后,可以动态实例化每个参数。我们以两种方式使用这种能力:第一,为被测服务构造输入。第二,自动 Mock 服务的依赖关系,并将默认值发送回服务。这允许以正确的格式自动创建随机数据,从而自动创建模糊线束。
  3. 最后,服务可能对其通过网络接收到的输入根本没有预期。这种情况,就免除了由于输入值和服务期望之间的不匹配而导致的所有崩溃的机会,也就意味着,此时所有遇到的崩溃都指向实际的错误。

构造输入的最简单方法是为每种数据类型随机选择适当的值。这种方法自动地提供了一个测试基线,并具有识别工程师在手工设计测试时可能忽略的边缘情况的优势。通过为每种数据类型手工配置的“边缘”值(比如整数的 MAX_INT )就可以增强这个过程。

随机(和 Fuzz )测试的缺点在于:当对输入的校验中涉及复杂的约束(例如 Checksum() )时,它就无效了。在这种情况下,天真而简单的模糊技术很难提取出有效的输入。因此,除了任何初始输入验证之外,它不会执行服务逻辑。

为了克服这个问题,并提高自主测试的有效性,我们使用了「录制/回放」的方法。我们定期录制影响生产服务的一小部分请求,再对其进行一定的清理,使其可用于测试基础设施。在这里,没有使用完全随机的输入数据来执行测试,录制下来的请求会发生不同程度的变异。这种方法背后的基本原理是,产生的输入将保留足够多的原始请求的有效结构,以执行「深度路径」,但也有足够的随机性,以执行这些路径上的边缘情况。

与纯模糊技术相反的是,我们使用录制的请求,而不改变它们。这验证了一个新版本的服务可以处理前一版本接收到的流量,并且不会出现异常行为,类似于经典的金丝雀测试。

通过记录和回放来解决问题的优点在于:它不需要单独的测试基础设施,也不影响生产系统。

测试预言

除了像 ASAN 这样的崩溃和或健康扫描工具之外,我们还要寻找那些未声明的异常,并检查日志中的可疑消息。一个有趣的例子涉及 MySQL Programming Error 异常。这种异常通常是由 Fuzzer 程序具备影响 SQL 查询的能力触发的。它通过调用带有未预期参数的 API 来实现这一点,这表明存在 SQL 注入的漏洞。类似的情况涉及 Python Syntax Error 异常。在这种情况下,fuzzer 能够修改传递给 eval 的字符串,指向潜在的任意代码执行。

部署与经验总结

我们的自主集成测试部署策略包括两个阶段。首先,我们最开始只是在后台运行它们,以便获得尽可能多的服务,但却不用这些服务维护者的参与。这使我们能够了解存在哪些改进机会,以及报告问题的最佳方式。

接下来,我们会鼓励服务的所有者在部署新版本的服务之前选择自动运行这些测试。我们选择了opt-in 模式(由各团队自主选择使用)。因为,如果这个模糊测试失败了,就需要立即采取措施解除对服务部署流水线的阻塞。

我们现在(2021年10月)正从第一步转到第二步。

在第一阶段的运行中,使用应用于模糊集成测试的隔离,我们能够安全、自动地模糊测试 Facebook 大约三分之一的 Thrift 服务。Fuzzing 发现了1000 多个bug。对于每个 bug ,我们都向服务所有者发送了一份报告。剩下的三分之二的服务要么有非标准的设置,要么有严格的权限,阻止我们重用它们的生产制品,要么由于我们实施的严格隔离性而无法达成。

在这一阶段,我们只是通过错误报告与服务所有者进行沟通。

在这个过程中,我们学到了一些东西。首先,通过我们的 Fuzz 测试,我们发现,在隔离测试环境方面存在显著的改进机会。这使我们支持一种更细粒度和可扩展的方法来标记只读 API 。另外,我们持续思考如何为集成测试环境提供第一级抽象,并通过可组合性提供重用测试环境的能力。

其次,我们了解到,向服务所有者提供尽可能多的关于我们检测到的漏洞的信息是至关重要的。与单元测试相比,在集成测试失败的情况下,调试和诊断本身就很困难。虽然堆栈跟踪有助于理解崩溃,但高效的调试还需要充分了解崩溃的服务及其使用的库。我们注意到,总的来说,API 契约冲突比崩溃更容易调试和诊断。

第三,我们注意到,随机输入使解释错误变得更加困难,工程师们更难对错误进行推理,并确定其根本原因。我们可以通过更广泛地使用录制的流量,并提供格式良好的输入来解决这个问题。在某些情况下,工程师还可能认为,当给定随机输入时,服务可以通过抛出未声明的异常或崩溃来打破 API 契约。我们反对这种做法,而是依靠彻底的输入验证。

最后,了解 Fuzz 测试的有效性至关重要。迄今为止,我们只将发现的 bug 视为有效性的指标。我们才刚刚开始衡量整个服务的覆盖率。我们预计,这将让服务所有者进一步掌握他负责的服务,到底哪些部分需要额外测试。它还指出了我们可以对集成 Fuzz 测试基础设施进行的改进,以提高整体覆盖率。

参考文章:

Facebook Autonomous testing