为什么Uber微服务架构使用多租户


Uber服务的高性能主要依赖于在当前平台上快速以及稳定的开发新特性能力,和对应服务使用什么技术栈无关。Uber平台最根本的能力是基于微服务架构,这是一种常用的结构化风格,也就是由各种互操作的服务组成的应用。

微服务架构可以提供很好的伸缩性,同时也能够支持稳定的部署和模块化。在Uber,不同的工程师团队都是基于互操作的服务来工作,所以确保我们的技术栈既要能够安全的发布新的变动,也要能够基于模块化方式可靠的使用架构的已有部分,这点非常重要。总而言之,这些功能能够提高开发者的开发速度以及快速的发布周转(turnaround)次数,另外还可以给我们提供基于独立调度(schedules)构建的灵活性,同时依然可以满足服务级别协议(SLAs)。

在一个微服务架构中允许多系统共存是利用微服务稳定性以及模块化最有效的方式之一,这种方式一般被称为多租户(multi-tenancy)。租户可以是测试,金丝雀发布影子系统(shadow systems),甚至服务层或者产品线,使用租户能够保证代码的隔离性并且能够基于流量租户做路由决策。对于传输中的数据(data-in-flight)(例如,消息队列中的请求或者消息)以及静态数据(data-at-rest)(例如,存储或者持久化缓存),租户都能够保证隔离性和公平性,以及基于租户的路由机会。多租户可以帮助我们在一个简单的微服务栈上实现多种功能,比如改进的集成测试框架,影子流量路由,记录和重放流量,为了实验功能做的实时流量的密封重放,容量规划,实际的性能测试,甚至一次运行多个产品线。

综上所述,多租户的好处是利用了一个更灵活和可伸缩的微服务架构,这个架构能够带来更大的生产效能以及改进的应用程序性能,这些会让工程师以及平台用户这类人受益。

微服务架构(landscape)

微服务架构能够让开发团队不依赖于其他服务就可以推出新的功能以及bug修复,这可以增加开发者的开发速度。例如,一个团队拥有四个服务(作为一个整体称为系统1),这些服务具有约定的SLA,这些约定的SLA定期与具有其自己的SLA的多个其他服务交互。

下面的图表1说明了这四个服务,A,B,C和D之间的交互。在这个图表中,服务A从系统2中接收请求。系统1通过连接到服务B来处理请求,而服务B去连接服务C和服务D,最终完成请求处理。
1.png

图表1。在系统1中,服务A通过连接到服务B来处理系统2的请求,而服务B去连接服务C和服务D,最终完成请求处理

在这个例子中,如果我们对服务B做出改变,我们需要确保它仍然能够和服务A,C,D正常交互。在微服务架构中,我们需要做这些集成测试场景,也就是测试和该系统中其他服务的交互。通常来说,微服务架构有两种基本的集成测试方式:并行测试和生产环境测试。

并行测试

并行测试需要一个和生产环境一样的过渡(staging)环境,并且只是用来处理测试流量。如下图表2所示,这个环境栈一直启动并且运行生产环境代码,但是和生产环境栈是隔离的,并且它的规模要比生产环境要小:
2.png

图表2。并行测试需要工程师创建一个过渡环境来处理测试流量并且考察生产环境栈最终是否满足SLA

在并行测试中,工程师团队首先完成生产服务的一次变动,然后将变动的代码部署到测试栈。这种方法可以在不影响生产环境的情况下让开发者稳定的测试服务,同时能够在发布前更容易的识别和控制bug。

并行测试需要确保测试流量不能够泄漏到生产环境栈,这个可以通过物理隔离方式给它们分配独立的网络以及确保测试工具仅仅在测试栈中操作。

尽管并行测试是一种非常有效的集成测试方法,但是它也带来了一些可能影响微服务架构成功的挑战:
  • 额外的硬件成本:需要给测试提供整个栈,以及所有的数据存储,消息队列和其他基础组件,这意味着需要额外的硬件和维护成本。
  • 同步问题:测试栈只有在和相应的生产栈保持一致才有作用。当两个栈有偏差时,测试栈对于生产栈的反映(mirror)变得越来越困难,并且对于基础组件来说,要保持两个栈的同步也需要额外的负担。
  • 不可靠性测试:当团队把他们实验性的并且有潜在bug的代码部署到测试栈,这些服务可能无法正常的运行,这就会导致测试失败。例如,拥有服务A的团队执行了并行测试去查看他们的新代码是否正常,但事实由于服务B存在一个bug导致测试失败了。因为我们测试的构建和生产环境的完全不一样,这会让我们很难定位bug;此外,我们只有在测试通过整个流程之后才会知道对于服务A做的变动是否是安全的,这意味着我们需要等待拥有服务B的团队把他们干净的代码部署回测试栈。可以通过使用路由框架将流量路由到另一个沙盒环境(在该沙盒环境中待测试的服务已经启动)来缓解这一特殊缺点。
  • 不精确的负载容量(capacity)测试:为了评估整个栈或者子栈的负载容量,我们需要在测试栈上运行测试负载。如果要测试特定容量,则必须先增加测试栈的容量,然后才能将增量负载(即目标容量相对于当前生产环境负载的增加)施加到测试栈上。此增量负载可能无法使测试栈饱和,这会导致我们不清楚应向生产栈中增加多少容量以实现目标容量。


生产环境测试

微服务架构中的另一种集成测试方法是使生产栈支持多租户并且允许测试流量和生产流量流入。下图3就是这样的一个例子:
3.png

图表3。利用多租户生产栈,我们可以在运行生产服务的同时测试新增或者更新的微服务。

这种方法很有野心,因为这需要确保栈中的每一个服务要能够处理生产请求以及测试请求。

使用这种方法,我们可以把待测试的服务B在一个隔离的沙盒环境中启动,并且在沙盒环境下可以访问生产服务C和D。我们把测试流量路由到服务B,同时保持生产流量正常流入到生产服务。服务B仅仅处理测试流量而不处理生产流量。另外要确保生产流量不要被测试流量影响。

上面的例子只是一个简化的视角,但是却能够很好的解释多租户怎样解决集成测试的问题。生产中的测试提出了两个基本要求,它们也构成了多租户体系结构的基础:
  • 流量路由:能够基于流入栈中的流量类型做路由。
  • 隔离性:能够可靠的隔离测试和生产中的资源,这样可以保证对于关键业务微服务没有副作用。


这里的隔离要求特别广泛,因为我们希望隔离所有可能的静态数据,包括配置,日志,指标,存储(私有或公共)和消息队列。这种隔离要求不仅仅作用于待测试的服务,对于整个栈也是一样。

除了集成测试意外,多租户也为其他用例铺平了道路,例如分阶段部署以及流量重放。

金丝雀部署

当开发者对他们的服务做了变动,即使这个变动已经被严格的审查和测试,我们也不能够一次就把变动部署到所有运行的服务实例上。这是为了确保在变动有问题或者bug的情况下,整个用户群不会受到攻击。理想的做法是首先将变动应用到一小部分实例上,这被称为金丝雀。然后,我们使用反馈回路监视金丝雀,并逐步应用代码变动到所有服务实例上。

在多租户架构中金丝雀可以被视为另一种租户形式,并且可以把金丝雀作为请求中的一个属性来做路由选择。使用金丝雀的时候,资源在部署中也是隔离的。在任何给定时间,一个服务都有可能部署了金丝雀,并且所有金丝雀流量都会路由给已经部署的金丝雀。可以在靠近架构的边缘基于请求自身的属性来采样金丝雀请求,例如用户类型,产品类型,以及用户位置。

捕获/重放和影子流量

对于所做变动的安全性,一个行之有效的测量方法是在为实际的生产流量服务的同时,能够看到服务费用(service fare)发生怎样的变化。在一个封闭的安全环境中重放之前捕获的实时流量或者重放实时生产流量的一份影子拷贝是另一种多租户的用例。
4.png

图表4。创建影子流量到测试服务涉及到把生产流量的拷贝路由出生产栈并且路由进一个安全的测试环境。

在这个案例中,我们对被测试实例所有出站调用都添加了响应。捕获和重放生产流量可以被视为集成测试的一个子类别,因为这些用例都属于测试和实验范围。

从技术上说,重放流量是测试流量并且可以是测试租户的一部分,允许和其他租户隔离。通过重放流量,我们可以灵活地分配单独的租户,用来进一步隔离其他测试流量。

因此,多租户体系架构的一个重要功能是其保护和隔离多个关键业务产品线或不同用户群的能力。

面向租户的架构

在面向租户的微服务体系架构中,租户被视为一等对象(first class object)。传输中的数据和静态数据都有租户的概念。做一个多租户微服务架构涉及到给入站请求绑定上下文(context),然后在请求的生命周期内把上线文传递下去,这样可以让用户基于上下文做路由。

租户上下文

由于微服务架构是在互连的网络上运行的一组完全不同的服务,因此我们需要能够将租户上下文附加到执行序列上。当请求进入边缘网关时,我们可以将包含了租户相关信息的上下文附加到请求上。我们希望该上下文的生命周期要和被附加上的请求相同,并能够传播到同一业务逻辑上下文中生成的任何新请求中,从而保留请求序列的租户信息。

下面是一个简单的租户上下文格式和一些案例:
{ “request-tenancy” : <product-code>/<tenancy-id>/<tenancy-tags>… }
Examples:
“request-tenancy” : “product-foo/production”
“request-tenancy” : “product-bar/production/canary”
“request-tenancy” : “product-bar/production/health-probe”
“request-tenancy” : “product-foo/testing/TID1234”
“request-tenancy” : “product-bar/testing/shadow/SID5678”

上下文传播

通常来说,当调用链中的任何一个服务接收到一个请求时,我们都希望能够获得租户上下文信息,因为这个服务可能会利用租户上下文信息作为业务逻辑的一部分。但是,这就要求服务在处理该请求时并且需要进一步发出其他请求时能够传播上下文。

大部分服务一般都不需要租户上下文信息,但是有一些需要访问请求中的上下文用来避开一些业务逻辑。例如,需要验证用户手机号的审计服务可能需要避开对测试流量的检测,因为测试请求中的用户都是测试用户。另外,当测试流量通过一个事务处理服务,这个服务和银行网关对接用来处理用户的转账业务,我们可以屏蔽这个银行网关或者和银行的测试网关通信(如果有用于测试的网关),这样可以避免真实的转账。租户上下文的传播可以通过开源工具来实现,比如OpenTracingJaeger,这些可以使用一种语言并且跨传输层方式来实现分布式上下文传播。

租户上下文也应该可以被传播到其他传输中的数据对象中,比如Kafka消息队列中的消息。 较新版本的Kafka支持添加标头,并且可以使用开源跟踪工具向消息添加上下文。

我们也希望租户上下文能够被传播到静态数据中,包括各个服务用来存储持久化数据的所有数据存储系统,比如MySQL,Apache Cassandra以及AWS。像Redis和Memcached这样的分布式缓存也可以被归类为静态数据。架构中使用的所有存储系统以及缓存都需要支持存储上下文以及一个合理的数据粒度,这样才能够基于租户上下文来对数据进行查询和存储。在一个更高的维度上,静态数据组件要求能够基于租户信息来对数据和流量进行隔离。

究竟如何隔离数据以及如何将租户上下文与数据一起存储,这些实现细节都和特定存储系统有关。

基于租户的路由

一旦我们能够给请求附上租户信息,我们就可以基于它的租户信息做路由。这种路由对于生产,记录/重放以及影子流量的测试很重要。当然,金丝雀部署也需要把金丝雀请求路由到运行在隔离环境下的特定服务实例。

在确定无缝运行且无开销的路由解决方案时,考虑部署和服务技术当栈非常重要。当选择一个通用的路由方案时,服务编写的语言以及他们之间互相通信的传输和编码也需要考虑。像Envoy或者Istio开源服务网格(Service Mesh)工具也非常适合提供基于租户的路由功能,并且该路由和服务语言以及所使用的传输或编码无关。

通常,可以在服务的出口或入口实现基于租户的路由。在出口处,服务发现层可以根据请求的租户信息决定和什么服务通信。另一种方法是在入口处做路由判断,然后请求被重新路由到正确的实例,如下图5所示:
5.png

图5。我们可以在入口处做路由判断,上面的例子是从生产环境服务Ap发送测试流量到测试实例A1。

在图5例子中,可以使用一个边车(sidecar)来转发一个测试请求到测试实例上。边车可以是一个代理进程,它负责代理所有进入该服务的流量,并且和服务部署在一起。流量先是被服务边车接收到,然后边车检测请求的租户上下文,接着基于上下文做出路由决策。

基于我们想要的用例,我们可以在租户上下文中增加额外的元数据。例如,对于生产中的测试,我们想把测试流量重定向到一个服务的测试实例上。我们可以在上下文中增加额外的信息,这个可以允许以下的行为发生:
{
“request-tenancy” : <product-code>/<tenancy-id>/<tenancy-tags>…
“services_under_test” : [
   “foo” : {
       “redirect” : <test instance Id>,
       },
       …
  ]


当做路由决策时,我们可以检测请求的租户信息是否是测试的以及请求的接收者是否是处于测试下。如果这些请求都满足,我们可以路由该请求到这个<测试实例ID>。

数据隔离

我们想去构建一个架构,在这个架构中每一个基础组件都能够理解租户信息,并且能够基于租户路由隔离流量,同时在我们的平台中允许对运行不同的微服务有更多的控制,比如指标和日志。在微服务架构中典型的基础组件是日志,指标,存储,消息队列,缓存以及配置。基于租户信息隔离数据需要分别处理基础组件。例如,我们可以生成租户信息,然后将其作为服务生成的所有日志和指标的一部分。这个可以帮助开发者基于租户信息做过滤,同时也可能有助于避免错误警报或防止启发式或训练数据出现偏差。

同样的,当考虑存储服务时,底层的存储架构需要考虑能够有效的在租户之间创建隔离。一些存储架构很容易实现多租户。两种高级方法是将租户概念(notion)显式地嵌入到数据旁边,并将不同的租户信息和数据共同放置,或者根据租户信息显式地分离数据,如下图6所示:
6.png

图6。使用租户信息路由数据和消息队列测试流量到处于测试下的不同组件,这样可以隔离该测试流量,因此不会干扰生产系统。

后一种方法提供了更好的隔离保证,而前一种方法通常需要较少的操作开销。对于像Kafka这种消息队列系统,我们可以给租户推出一个新的主题或者分配一个单独的Kafka集群。

对于数据隔离,上下文需要能够被传播到基础组件。确保服务在数据隔离方面的开销最小,这一点很重要。理想的情况下,我们希望服务不需要显示的处理租户信息。另外,我们也希望将隔离逻辑放置在所有数据流经的中央扼流点。网关就是其中一种可以实现隔离逻辑的扼流点,它是我们的首选方法。客户端库可以是实现基于租户隔离的另一种方法,尽管编码语言的多样性使在所有特定语言的客户端库之间保持逻辑同步变得有点困难。

同样的,对于配置隔离,我们希望对于特定租户下的服务配置是互相独立的,因此确保对于一个租户配置的变动不会影响到其他租户的配置。

展望未来

基于微服务的体系结构仍在发展,并已成为开发人员和整个组织的敏捷性工具促进者(facilitators)。 精心计划的多租户架构可以提高开发人员的生产力并支持不断发展的业务线。

Uber的多租户实施带来了各种好处,例如使代码和配置的自动发布更安全,从而提高了开发人员的速度。 多租户架构的隔离保证使Uber可以出于各种目的(包括测试流量)重新利用同一微服务栈。

原文链接:Why We Leverage Multi-tenancy in Uber’s Microservice Architecture(翻译:王欢)

0 个评论

要回复文章请先登录注册