允许开发者快速开发新特性的原型设计、测试和迭代,对于 Facebook 的成功至关重要。要想有效地实现这一点,关键是要有一个稳定的基础设施,并且不会带来不必要的摩擦。如果相关的基础设施还必须扩大,以支持全球 30 多亿人口,利用日益增长的算力,以及应对一个极其庞大且不断增长的代码库,那么这一点显然更加具有挑战性。
解决这个问题的两种方式是更好的抽象和自动化测试。抽象包含了面向服务的基础设施,它允许业务逻辑结构变成独立编写、部署和扩展组件。尽管这对于快速迭代非常重要,但是也增加了测试的复杂性。在检查服务中的逻辑时,单元测试是有用的,但是无法测试服务间的依赖关系。集成测试可以起到拯救作用,但是,相对标准化的单元测试框架来说,没有现成的集成测试框架可供我们用于后端服务。所以我们设计并构建了这个。
Facebook 的基础设施俯瞰视图,强调了后端测试选项。
今天,我们将详细介绍在这个集成测试基础设施之上建立的一个新的自主测试扩展,并且也是对这一基础设施本身的幕后观察。这个扩展借鉴了模糊测试的理念,即一种自动化技术,它使用随机输入来发现 bug,并利用软件栈的同质性来提供无缝的开发体验,鼓励快速迭代。迄今为止,Facebook 的大多数自主测试集中在我们的前端,或者通过 Infer、Sapienz 和 Zoncolan 等工具进行的安全测试。在此,我们将讨论我们如何自主测试后端服务。
集成测试基础设施
集成测试基础设施需要鼓励工程师编写有效的测试,在需要时自动运行测试,并以直观的方式显示结果。这种方式通过提供代码框架、测试调度和执行能力,以及持续集成系统的适当钩子来实现这一点。代码框架封装了样板文件,并提供了常见的抽象和模式,以消除编写测试时使用片状结构等常见陷阱。本文讨论了编写测试的三个方面,着重于与集成测试相关的部分:定义测试环境、指定输入以及检查输出。
集成测试的组件。测试基础设施为工程师编写测试提供了基础,并为运行测试提供了执行平台。
定义测试环境
为提供确定的结果和避免副作用,测试通常不在生产环境中运行。这对于单元测试尤其适用,单元测试集中于一个小的代码单元,使用模拟或假象来替代外部依赖。尽管这样做可以避免副作用,但是它有不利的一面,那就是测试系统没有足够逼近。模拟程序本身仅实现了一些真正依赖关系中的行为。所以,有些 bug 可能无法检测到。维护模拟也需要相当大的工程努力。
集成测试较少依赖模拟。测试后端服务通常涉及一个或多个未修改的服务。无需修改服务进行测试,这有一些好处。第一,它避免了服务所有者的负担,但更重要的是,它使测试与在生产中运行的代码相同,从而使它们更具代表性。
这提出了两个必须解决的重要挑战。首先是创建测试环境,适合运行未修改的服务。其次,必须确定如何设置测试环境的边界以及如何处理这些边界之间的连接。
这些挑战要求采取务实的做法。我们的解决方案重用生产基础设施,尤其是用于构建测试环境的容器化和路由器系统。但是,我们在基础设施中为每一个测试创建单独的短暂实体。这样,测试环境就可以不接受生产请求,而不自动限制连接到生产系统。这使得一些测试可以与生产环境共享只读资产或 API。与此同时,我们使用附加的隔离层来限制其他测试到生产系统的连接。
通过基于希望检查的服务交互,我们授权服务所有者定义测试环境的边界。在相同环境中,与调用者运行的服务优先提供网络请求。如果环境中不存在适当的服务,请求可以转到模拟、转到生产系统的内存副本,或者被阻止,或者转发到生产环境(例如,只读请求)。
对于此设置,模拟是通过动态创建和启动一个服务来工作的,这个服务与原始服务具有相同的接口,但实现方式简单。模拟服务运行在测试工具所在的地址空间中。这样可以方便地进行交互。可以在运行时更改模拟实现,这与更改单元测试模拟的方式相似。我们把每个模拟方法处理程序包装成一个标准的 Python MagicMock 或 StrictMock。通过这样操作,可以轻松地检查它的调用次数以及它接收的参数。
对于常见的依赖关系,例如存储,内存复制非常有用。出这些选项外,测试基础设施还组织了测试环境中的连接。稍后,我们将在自主测试中详细讨论这个问题。
测试输入
通常来说,测试输入是以测试工具的方式提供的,它是一个在测试环境中与测试服务一起执行的程序。一个测试工具可以直接执行服务,也可以通过远程调用(RPC),或者在测试环境中间接执行更改。举例来说,它可以应用新的全局配置设置或者关闭测试服务的副本,尽管测试框架为这些操作提供了原语,但是构建测试由服务所有者负责。
在集成测试中,模板是另一种输入源。可将其配置为发送特定的响应,从根本上作为被测服务的输入。依赖失败代表输入的一种特殊情况,可以通过抛出模拟中的异常来模拟。
测试 Oracle
大多数测试 Oracle 都是针对服务行为的自定义断言。尽管这些断言原则上和单元测试断言类似,但是它们只能检查被测服务的外部可见行为,而非内部状态。它包括 RPC 响应、传递到模拟调用的参数、写入到短暂的测试数据库的数据以及其他示例。
测试基础设施还可以检测常见的错误,比如崩溃或由消毒器标记的 bug,以及被监控基础设施标记的服务健康问题。
可扩展性
集成测试基础设施的设计目标之一就是让团队能够在其之上构建扩展。对于这个扩展,我们主要有两种用途。首先要解决团队服务中心出现的常见模式,例如测试环境设置或者经常使用的自定义。它们还可以为特定类型的测试定义基础,例如灾难准备测试。在需要时,这些测试验证了我们能够从头启动最基本的基础设施服务。比如 ZooKeeper。
自主集成测试
上述框架为服务所有者编写集成测试提供了框架。但是,在很多情况下,测试基础设施可以通过提供合理的默认值甚至自动生成所有测试组件来做得更好。
要定义测试环境,这个基础设施将反映服务的生产环境。在 Facebook 的集群管理系统 Twine 上,它以一种标准的方式定义了所有的服务。但是,它也可以通过编程的方式对这些组件进行检查。在进行特定修改之前,测试可以检查环境,并检查它的合理性,然后将它返回到 Twine 进行实例化。当服务所有者在某些情况下需要干预时,比如一个服务需要特殊的硬件,而在默认的测试机池中不存在,则健全检查负责通知服务所有者。特定的修改测试包括将测试实例与生产系统隔离,减少服务的资源需求以节省容量,以及其他较小的修改。
隔离是自主测试中一个特别重要的元素。这是因为测试基础设施决定了使用哪些输入。不过,无论它的选择如何,测试一定不能产生副作用。举例来说,有一种情况是,一个测试中的 API 失败的数据到达了监控基础设施,它错误地认为故障是来自生产系统。结果,它产生了虚假的警报。
尽管从技术角度来看非常简单,但是将测试环境与基础设施的其他部分完全隔离常常会导致测试失败。正因为如此,我们必须采用更细粒度的方法:
我们可以通过已知的只读流量。我们让服务所有者可以为安全目标设置允许列表。我们将所有标准 PRC 流量都重新路由到通用模拟中,这样就可以模拟任何服务并返回虚假的值。我们禁止所有其他网络请求。
这样,我们就可以在一个安全的测试环境中运行三分之一的服务,而不需要人工干预。
在实施隔离时,我们结合了两种方法:一种是细粒度的应用级隔离,另一种是粗粒度的网络级隔离。我们对 RPC 调用了应用级隔离。这可以基于调用的 API 来阻止连接。在 IP: 端口的粒度级别,网络级隔离是工作的。一般情况下,我们使用它允许连接到诸如 DNS 等知名端口上的监听服务。除基于连接目的地作出决定外,隔离系统还可以根据启动连接的代码作出决定。这样做是有价值的,因为某些代码可以安全地使用可能不安全的 API,该隔离逻辑通过在运行时检查堆栈跟踪来识别调用者。
对于隔离层的构建,我们考虑了两种实现方案。BPF 和 LD_PRELOAD。最后,我们决定采用后者,因为它提供了更大的灵活性。预加载逻辑从我们的配置管理系统中检索特定于服务的隔离配置,并通过拦截对 libc connect、sendto、sendmsg 和 sendmmsg 函数的调用,从而相应地阻止连接。
测试输入
为深入探讨测试自动化,我们研究了现有的自动化技术。模糊测试是我们集成测试结构的自然匹配。它的动态性质非常适合经典的测试范式,而它的自动输入生成则补充了手动编写的测试。
模糊测试的核心是一种随机测试形式。不过,尽管它很简单,但是设置模糊测试仍然需要几个手工步骤:
将需要模糊测试的代码分割成一个独立的单元(测试目标)。与单元测试相同,典型的模糊测试是在一个相对较小的代码单元上进行的。编写一个模糊测试工具,负责将随机数据生成为模糊测试代码所需的类型,并在测试目标中调用正确的函数。保证随机数据符合测试目标的预期约束条件。
最后一点需要进一步澄清。假定需要对 strlen 函数进行模糊处理,该函数期望一个有效的指针指向一个以 NULL 结尾的字符串。在模糊处理生成输入时,它需要确保所有输入都是指向 NULL 结束的字符串的有效指针。否则可能会导致代码崩溃——并不是因为在代码中存在 bug,而是由于调用者参数与被调用者期望之间的不匹配。这些期望(也称为 API 契约)常常是隐式的,因此需要手工完成。
在结合了模糊测试和集成测试时,我们可以实现上述手工步骤的自动化:
我们根据前面描述的生产环境来创建环境。这使得我们无需手工划出代码,而这些代码必须进行测试。这要归功于 Facebook 的 Thrift RPC 框架,这个服务的 API 契约是显式的,可以通过编程的方式获得。Thrift 提供了一个接口定义语言,它具有反射特性,可以枚举 API 及其参数。而且,参数类型和其属性也可以像需求一样被递归地检查。基于这些信息生成相应的值之后,就可以动态地实例化每个参数。用两种方法来使用这个能力。第一,构建所测试服务的输入。其次,自动模拟服务的依赖关系,并将其返回默认值。这样就可以用正确的格式自动生成随机数据,自动创建模糊控制。最后,一个服务可能不期望接收到网络上的输入。这样可以消除由于输入值和服务预期不匹配而导致崩溃的所有机会,这意味着所有遇到的崩溃都会指向实际的 bug。
构建输入最简单的方法是为每个数据类型随机地选择合适的值。这种方法自动提供了一个测试基线,并且具有确定工程师在手工设计测试时可能忽略的极端情况的优势。对于每一个数据类型,像 MAX_INT 这样的极端情况值,可以增强这一过程。
随机(和模糊)测试的弱点是,当输入的有效性涉及复杂的约束时,例如校验和,它是无效的。单纯的模糊测试难以在这种情况下找到有效的输入。除了任何初始的输入验证外,它不会执行服务逻辑。
我们使用请求记录来克服这一问题和提高自主测试的效率。对于访问生产服务的一小部分请求,我们定期记录,对这些请求进行“清理”,并将它们提供给测试基础设施。在这种情况下,测试不会以完全随机的输入运行,而是记录的请求会有不同程度的变化。这种方法的基本原则是,所得到的的输入将保留足够的原始请求的有效结构,以执行“深层路径”,但是也有足够的随机性,可以锻炼这些路径的极端情况。
在纯粹的模糊处理的另一端,我们使用记录的请求,而不改变它。它证明,新版本的服务能够在没有异常行为的情况下处理上一版本的流量,就像经典的金丝雀测试一样。以记录和重放的方式解决了这个问题,好处是不需要独立的测试基础设施,也不会影响生产系统。
测试 Oracle
除崩溃和类似 ASAN 等“消毒器”外,我们还要查找未声明的异常,并检查日志中的可疑信息。MySQL ProgrammingError 异常是一个有趣的示例。这种异常通常由模糊器能够影响 SQL 查询的功能所触发。可以通过调用带有意外参数的 API 实现,这表示存在 SQL 注入漏洞。Python 语法错误(SyntaxError)异常也是相似的。本例中,模糊器可以修改传递给 eval 的字符串,并指向可能执行的任意代码。
部署与经验教训
自主集成测试的部署策略包括两个步骤。首先,我们开始在后台为尽可能多的服务运行测试,而不需要服务所有者的参与。这样,我们就可以知道有什么机会可以改善,以及报告问题的最佳方式。下一步,我们鼓励服务所有者选择在部署其新版本服务之前自动运行该测试。我们选择了 opt-in 模式,因为测试失败需要立即采取行动来解除服务的部署管道。我们现在从第一步进入第二步。
在第一步中,通过应用于模糊集成测试的隔离,我们可以安全、自动地对大约三分之一的 Facebook 的 Thrift 服务进行模糊测试。这个模糊测试发现了超过 1000 个 bug。对于每一个 bug,我们都给服务所有者分配了一份报告。余下的三分之二的服务都具有非标准的设置,或者具有严格的权限,使得我们不能重用他们的生产工件,或者由于我们执行的严格隔离而失败。在这一步中,我们只通过 bug 报告与服务所有者接触。
通过这一过程,我们学到了一些东西。首先,通过我们的测试,我们发现在隔离测试环境方面有许多机会可以改进。因此,我们支持使用更细粒度和可扩展的方法来标记只读 API。此外,我们还继续考虑如何为集成测试环境提供一个一流的抽象,并通过可组合性提供重复使用测试环境的能力。
第二,我们学到了,向服务所有者提供尽可能多的有关我们检测到的 bug 的信息是非常重要的。与单元测试相比,在集成测试失败的情况下,调试本来就很困难。尽管堆栈跟踪有助于了解崩溃,但是有效的调试还需要对崩溃的服务及其使用的库有很好的理解。我们注意到,从总体上看,违反 API 契约要比崩溃更容易调试。
第三,我们注意到,随机输入使得解释 bug 变得更加困难,工程师们也更难找出 bug 的根源。通过更广泛地使用记录的流量和提供大多数形式良好的输入,我们可以解决这一问题。有些情况下,工程师可能会认为,给定随机输入,服务可能会通过抛出未声明的异常或崩溃而破坏其 API 契约。建议反对这一做法,而应依靠全面的输入验证。
最后,了解模式测试的有效性也非常重要。迄今为止,我们仅将发现的 bug 作为有效性度量,现在我们开始测量整个服务覆盖范围。我们希望这能让服务所有者深入了解服务的哪些部分需要额外的测试。本文也指出了我们可以对集成模糊测试基础设施进行改变,以增加未来的整体覆盖。
作者简介:
Paul Marinescu,Facebook 研究科学家。
原文链接:
https://engineering.fb.com/2021/10/20/developer-tools/autonomous-testing/
评论 1 条评论