黄挺,蚂蚁金服高级技术专家,蚂蚁金服分布式架构 SOFA 的开源负责人。目前在蚂蚁金服中间件团队负责应用框架与服务化相关的工作。
本文根据黄挺在 CNUTCon 全球运维大会的主题分享整理,完整的分享 PPT 获取方式见文章底部。
大家好,我是来自于蚂蚁金服的黄挺,花名鲁直,目前在蚂蚁金服负责微服务团队,也是 SOFA 开源的负责人。
来到这个场子的朋友们肯定都知道,Service Mesh 在过去一两年之中迅速成长为社区中非常热门的话题,几乎所有的大会中,都多多少少有一些关于 Service Mesh 的话题。
在一个月之前,我的同事敖小剑老师在上海的 QCon 中也分享了蚂蚁金服在 Service Mesh 上的探索,包括在前面的场次中,来自华为的巨震老师也分享了华为在 Service Mesh 上的一些思考。
在今天的分享中,我不会去花太多时间介绍什么是 Service Mesh,更多地聚焦在蚂蚁金服将 Service Mesh 用在解决多语言的问题上的一些实践,希望在场的各位可以从这些实践中有所收获。
这个是我今天介绍的主要的内容:
首先,我会大家简单介绍一下多语言在蚂蚁金服发展的一些情况,铺垫一下背景,交代各个语言在蚂蚁金服的使用情况,并且之前在多语言通信上面遇到了哪些问题。
然后,我会给大家简单介绍下 SOFAMesh,SOFAMesh 是蚂蚁金服产出的 Service Mesh 的解决方案。
接着我会介绍我们在 SOFAMesh 之上架构的多语言通信的方案以及在这个方案的实施过程中遇到的一些技术要点。
蚂蚁金服多语言发展
不知道在场的同学有没有听说过 SOFA,SOFA 是蚂蚁金服大约 10 年前开始研发的一套分布式中间件,包括了微服务体系,分布式事务,消息中间件,数据访问代理等等组件,这套组件一直以来都是完全用 Java 来构建的,因此基于 SOFA 构建的 SOFA 应用也都是用 Java 写的,在蚂蚁金服,目前大概有接近 2000 个 SOFA 应用,顺带提一下,这套 SOFA 中间件目前已经部分开源在 Github 上面。从这个数据我们也可以显而易见地得出以下的结论,Java 在蚂蚁金服,至少在在线的应用上,占据了绝对主导的地位。
随着无线技术的发展以及 NodeJS 技术的兴起,在 2013 年,蚂蚁金服开始引入了 NodeJS,研发了 EggJS,目前也已经在 Github 上开源,在蚂蚁金服,我们主要将 EggJS 作为服务于无线以及 PC 的 BFF 层来使用,后端的所有的微服务还都是用基于 Java 的 SOFA 来研发,EggJS 要调用后端的 SOFA 服务,并且对 PC 和无线端提供接口,必然就要遵守 Java 世界的 SOFA 之前定下的种种“规矩”,事实上,蚂蚁金服的 NodeJS 团队完全用 EggJS 适配了所有的 SOFA 中间件的客户端,保证在 EggJS 上,也可以使用所有的 SOFA 中间件,可以和之前基于 Java 研发的 SOFA 应用进行通信。但是,由于 Java 在蚂蚁中间件上的主导地位,导致 SOFA 中间件的某些特性的实现,完全依赖于 Java 特有的语言特性,因此,NodeJS 团队在追赶 SOFA 中间件的过程中,也非常的痛苦,在后面的例子中,我会有一些具体的例子,大家看了之后肯定会感同身受。
再到最近几年,随着 AI 的兴起,在蚂蚁金服也越来越多地出现 CPP,Python 等系统,而由于 CPP 和 Python 等等语言,在蚂蚁金服并没有一个独立的基础设施团队去研发对应的中间件,因此,他们和基于 Java 的 SOFA 应用的互通就降级成了直接采用 HTTP 来通信,这种方式虽然也可以 Work,但是在通信基础之上的服务调用的能力却完全没有,和原本的 SOFA 的基础设施也完全没法连接在一起。
基于以上的一些现状,可以看到我们在发展过程中的主要的两个问题,一个是基础设施上的重复投入的消耗,很多 SOFA 中间件的特性,除了用 Java 写了一遍之外,还得用 NodeJS 再写一遍。另一个是以 Java 为中心,以 Java 为中心其实在只有 Java 作为开发语言的时候并没有什么问题,但是当其他的语言需要和你进行通信的时候,就会出现巨大的问题,事实上,很多框架上的特性的研发同学在不经意之间,就直接就用了 Java 的语言特有的特性去进行研发,这种惯性和隐性的思维会对其他语言造成巨大的壁垒。
基于以上的问题,我们希望能够产出一个方案,一方面,可以尽量做到一次实现,到处可用。另一方面,需要能够保证语言的中立性,最好是能够天然地就可以让框架或者中间件的研发的同学去在做架构设计以及编码的时候,考虑到需要支持多语言。
SOFAMesh
其实在这之前,我们已经尝试在数据访问层去解决类似的多语言适配的问题,蚂蚁金服有一个 OceanBase 的数据库,当各个语言需要访问 OceanBase 数据库的时候,采用的就是一个本地的 Proxy,这个 Proxy 会负责 Fail Over,容灾等等场景,而对各个语言只要保证 SQL 上的兼容就可以了,这让我们意识到,Proxy 的模式可能是解决多语言的一个方式,然后,在业界就出现了 Service Mesh,如果只是从技术上讲,Service Mesh 的 Sidecar 本质上也就是一个 Proxy,只是每一个服务实例都加上一个 Sidecar,这些 Sidecar 组成了一个网络,在加上一个控制平面,大家把他叫做 Service Mesh。通过 Service Mesh,我们可以将大量原来需要在语言库中实现的特性下沉到 Sidecar 中,从而达到一次实现,到处可用的效果;另外,因为 Sidecar 本身不以 Library 的形式集成到特定语言实现的服务中,因此也就不会说某些关键特性采用特定语言的特性来实现,可以保证良好的中立性。
看起来 Service Mesh 似乎是一个非常完美的解决方案,但是如果我们探寻一下 Service Mesh 的本质的话,就会发现 Service Mesh 并非完美解决方案,这种不完美主要是体现在 Service Mesh 本质上是一种抽象,它抽象了什么东西,它把原来的服务调用中的一些高可用的能力全部抽象到了基础设施层。在这张 PPT 中,我放了三张图片,都是一棵树,从左到右,越来越抽象,从图中也可以非常直观地看出来,从右到左,细节越来越丰富。不管是什么东西,抽象就意味着细节的丢失,丢失了细节,就意味着在能力上会有所欠缺,所以,在 Service Mesh 的方案下,虽然看起来我们可以通过将能力下层到基础设施层,但是一旦下层下去,某些方面的能力就会受损。
因此,我们希望能够演化出这样一套多语言通信的方案,它能够以 Service Mesh 为基础,但是我们也会做适当地妥协去弥补因为上了 Service Mesh 之后的一些能力的缺失。首先我们希望有一个语言中立的高效的通信协议,每个语言都能够非常简单地理解这个协议,这个是在一个跨语言的 RPC 通信中避免不了的,无论是否采用 Service Mesh。然后,我们希望将大部分的能力都下沉到 Sidecar 里面去,包括服务发现,蓝绿发布,灰度发布,限流熔断,服务鉴权等等能力,然后通过统一的控制平面去控制 Sidecar。然后,因为 Service Mesh 化之后的一些能力缺失,再通过一些轻量化的客户端去实现,这些能力包括序列化,链路追踪,限流,Metrics 等等。
在 Service Mesh 的选型上,我们是基于 Istio 来做,但是用自己研发的基于 Golang 的 Sidecar 来替换掉 Envoy,一方面这个是因为 Golang 是一个云原生领域的语言,另一方面,也因为 Envoy 在协议的扩展设计上并不好。目前我们的 Sidecar SOFAMesh 和 SOFAMosn 都已经在 Github 上面开源。
前面分析了我们在多语言上走过的一些路,以及我们期望 Service Mesh 能够为我们去解决的一些问题,也简单讲了一下某些能力是无法完全通过 Service Mesh 去解决的。
SOFAMesh 解决多语言问题中的技术要点
在了解了我们要通过 SOFAMesh 去解决的问题以及解决的方式之后,我们来看下蚂蚁金服在具体实施这套方式的时候遇到了什么样的问题。刚才我们也讲到了,我们用 SOFAMesh 解决多语言通信的问题的方案中,首先需要一个语言中立的高效地通信协议,所以,我们就先来讲讲通信协议。通信协议我对它的定位是整个服务调用中的灵魂,如果它没有良好的扩展性和语言中立性,我们就没法解决好多语言调用的问题,在整个 SOFAMesh 的方案中,通信协议除了需要能够被各个语言的客户端 JAR 包理解之外,还需要能够被 Sidecar 很好地理解。
1、通信协议
我们可以先看看一下早期的 SOFARPC 的通信协议的设计,我们的通信协议包含三个部分,一个是协议头,这个协议头只包含了一些简单的信息,比如协议的 Magic Number 之类的,然后是协议的元信息,这些元信息包含需要调用的接口名,需要调用的方法名之类的,接着是协议体的部分,包含了通信中需要携带的数据,在请求中,这部分携带的数据是经过了序列化之后的方法参数,在响应中,这部分携带的数据是经过了序列化之后的方法的返回值,并且在 SOFARPC 的这个版本的通信协议的设计的时候,第二个部分和第三个部分是放在了一起做的 Hessian 的序列化。抛开协议体里面的数据的序列化之外,我们可以非常清楚地看出,如果让另外一个语言去理解这个通信协议,是非常困难的,因为让这个语言的客户端包需要将协议的头的元信息取出来的时候,它必须将第二和第三个部分作为一个整体来进行反序列化,必然是非常耗时的,另外,因为在 Service Mesh 的方案里面,作为 Sidecar,也需要一些通信过程中的元数据的信息来完成一些功能,因此 Sidecar 也需要对第二部分加上第三部分进行反序列化,这个对于 Sidecar 的性能来说,也是一个非常非常耗时的操作,当你需要增加一些服务的元数据到协议里面去的时候,也非常困难,需要修改整个 SOFARequest 这个 Java 对象。从这个协议也可以看出,早期的 SOFARPC 的设计是非常以 Java 为中心的。
我们再来看下 Dubbo 的协议设计,在 Dubbo 的协议设计里面,也基本上分成了三个部分,一个部分是协议版本,一个部分是协议的服务元数据信息,然后是协议体部分,Dubbo 的通信协议设计比早期的 SOFARPC 的通信协议的设计好的地方在于,Dubbo 的通信协议的第二个部分和第三个部分是分开来的,因此,当你需要读取第二个部分的服务的元数据信息的时候,不需要同时地去读取第三个部分,这样,无论是多语言客户端包还是 Sidecar,都会比较容易处理,并且性能上会比较好;但是 Dubbo 的协议设计一个败笔在于把 Hessian 作为了超一等公民来对待的,它的整个协议就是构建在 Hessian 的基础上的,它用 Hessian 把协议头给序列化了,它用 Hessian 把协议的服务元数据信息也给序列化了,它还用 Hessian 来序列化协议体。这样,当其他的语言需要去理解这个 Dubbo 协议的时候,必须要先理解 Hessian,而 Hessian 的多语言的支持做地并不是非常好,比如 Golang,就没有一个比较好的 Hessian 的库,给其他语言去理解 Dubbo 协议设置了障碍。
在最近 SOFARPC 的版本中,我们重新设计整个通信协议,说是重新设计,不如说是简化,其实最主要的变化就是在原来的设计中,我们的第二部分的服务元数据信息以及第三部分的协议体是放在一个对象中,然后进行 Hessian 的序列化的;而现在是单独拿出来了,并且使用类似于 URL 的 Query String 这样简单的 KV 结构来序列化,这样当其他的语言需要读取服务元数据的信息的时候,非常简单地就可以将服务元数据的信息给提取出来,当需要增加服务元数据的字段的时候,也只需要在 KV 结构里面增加即可,扩展性上也非常方便。并且,Sidecar 也可以非常容易地将这些元数据信息提取出来,用来完成服务发现,限流,熔断等等之类的事情。
所以对于一个通信协议来说,要做到比较好地适配不用语言通信的场景,适配 Service Mesh 的场景,在协议的设计上,协议头的信息必须做到容易提取,容易扩展,否则,无论是在 Sidecar 里面,还是在多语言客户端里面,处理起来都会非常困难。
前面我们已经说了通信协议的设计的重要性,但是除了通信协议,对于序列化协议的选择也非常关键,为了能够保证的语言的中立,必须避免序列化协议和特定的语言绑定在一起,另外,在一家公司中,一旦选定了一个序列化协议,想要替换掉,是非常困难的,在蚂蚁金服,一直以来序列化协议都是采用了 Hessian,虽然 Hessian 号称也是多语言的,但是实际上语言的支持有限,并且 Hessian 最近这几年的发展也比较慢,另外,Hessian 也有一些特性是专门针对 Java 语言做的,比如一些父子类的字段的覆盖关系的处理等等,这些特性在其他的语言中并不存在,会导致不同语言之间的兼容性问题。因此,我们在做多语言的时候,让 SOFARPC 支持了 PB 协议,在 Bolt 的通信协议中,协议体里面的数据可以采用 PB 做序列化,PB 相对于 Hessian 来说在多语言的支持上要好上非常多。在这块,我们的处理的办法也是比较温和的,对于用 Java 研发的 SOFA 系统来说,它可以即暴露提供 PB 协议的接口,又提供 Hessian 协议的接口,这样,一些原来用 Hessian 做序列化调用这个系统的系统就不用做任何修改。
2、服务发现
前面讲了在通信协议的设计的重要性,接下来我们就来讲一讲服务发现上的一些问题,假设一个 RPC 没有服务发现的能力,基本上它就算是一个玩具,之所以一个 RPC 框架能够满足大规模分布式场景下的要求,服务发现的能力是非常基本的。
我们可以先看下左边的这张图,左边的这张图是 SOFARPC 当前的服务发现的模型,和大家看到的国内的一些开源的 RPC 框架基本上类似,因为最早 SOFARPC 就是为了方便 Java 而设计的,所以也是设计成尽量让服务调用和本地调用一样,而服务发现的粒度也是接口的维度,也就是说当一个应用要调用另一个应用发布的服务的时候,它是按照服务的接口信息从服务注册中心上去寻找服务的提供方的地址的,并且服务的提供方也是按照接口来将服务注册到服务注册中心下的。在蚂蚁金服里面,大部分的情况下,同一个应用的不同的实例提供了的服务的是一模一样的,但是也有一些情况下,同一个应用的不同的实例提供了不同的服务。
这样在社区的服务发现的方案里面就会存在问题,在 Istio 里面,服务的注册是直接注册成一个 K8s 的 Service,虽然在 K8s 里面叫做 Service,但是实际上就是一个应用,当服务的调用方需要去调用这个服务的时候,是直接获取对应的应用的 Service 的里面的地址,本质上,这种服务发现的方式和基于 DNS 的服务发现的方式非常类似,当同一个应用的不同的实例发布的服务是不对等的时候,客户端就可能寻址到一台没有对应的服务的机器,从而造成问题。
我们在解决这个问题中,考虑了两个方案,一种方式是基于应用提供出来的 Actuator 的信息,定时地抓取应用发布的服务的信息,并且根据这些服务生成 DNS 记录,服务的调用方去访问这些服务的时候,根据服务的元信息拼装出对应的 DNS 的地址,然后去调用。
这种方式的问题是依赖于应用提供的 Actuator 的能力的,但是并不是所有的应用都有 Actuator,如果没有的话,还是需要一定程度的改造,另一个是 DNS 的记录的时效性的问题,大家知道,在容器时代,容器都是朝生夕灭的,意味着 DNS 的记录会被频繁的修改,而如果服务发现的信息更新地不及时地话,调用就非常容易出问题。
另一个方式就是我们通过 Sidecar 来代理将服务注册到服务注册中心上面去,当一个 Python 或者 CPP 的系统启动的时候,它需要主动告诉对应的 Sidecar,它需要发布哪些服务,Sidecar 会将服务注册到对应的服务注册中心,当一个系统启动的时候,他需要主动告诉对应的 Sidecar,它需要订阅哪些服务,Sidecar 会从 Pilot 中将对应的服务的地址订阅过来。
这种方式可以避免 DNS 记录更新不及时的问题,但是同样,有一定对应用的侵入性,但是我认为这种侵入在这种基于接口的服务发现的模型下是不可避免的,除非是基于应用的服务发现模型,这也是 Service Mesh 的抽象而引发出来的一些问题。但是我们可以让 Sidecar 尽量简单的 API 提供给应用来调用,因为这个注册的行为是一次性的,不像真的服务发现那样,需要维持长链接来保证客户端得到及时的地址更新,所以我们在 Sidecar 中提供了 HTTP 接口应用来进行注册服务和订阅服务,而实际的注册和订阅的行为是通过 Sidecar 去做,Sidecar 会和 Pilot 维持长链接,保证服务发现的及时性。
3、轻量化客户端
刚才说了服务发现,现在我们来看下另外的两个 Case,可以更加说明轻量化客户端的必要性。在蚂蚁金服,因为单元化的架构,SOFARPC 需要有能力去提供基于用户的 ID 的路由方式,也就是说,需要能够根据用户的 ID 的不同,将请求路由到不同的机房里面去。但是大家知道,作为一个 RPC 框架,它是一个纯技术的框架,没法说直接理解什么是用户 ID,也没法从 RPC 请求的参数里面去识别出用户 ID。
因此我们提供了注解的方式让业务系统可以根据自己的情况去编写用户 ID 的提取规则,这样,RPC 框架只要在在寻址的时候,回调这个注解类,就可以拿到对应的用户 ID,再和路由规则做对比,找到对应的机房。
但是这样的方式,在多语言的实现的时候遇到了很大的问题,大家可以设想一下,你怎么用 NodeJS 实现和 Java 的注解一样的能力?你怎么用 Golang 实现和 Java 注解一样的能力?
但是这样的能力又不能去掉,所以我们提供了一种折衷的办法,通过 Velocity 的脚本来让业务来编写路由的规则,然后将 Velocity 的脚本翻译成各个语言的版本,因为 Velocity 的脚本相对来说语法比较简单,可以非常容易地就翻译成各个语言,这些各个语言的版本会直接集成到对应的语言里面去,通过这样的方式来达到一次编写,到处使用的目的。
除了一些涉及到业务逻辑的路由之外,还有一些能力是在 Service Mesh 中无法完全提供的,比如 Tracing 的能力,大家知道 Tracing 其实是一个很特殊的东西,一般上作为一个分布式链路追踪的框架,至少需要三个数据需要在系统间传递,TraceId,SpanId 和 BaggageItems,当一个系统接收到上游系统传过来的 TraceId,SpanId 和 BaggageItem 的时候,它必须从请求中将数据反序列化出来,塞到线程上下文中,当从当前系统中发出请求的时候,又需要将线程上下文中的 TraceId,SpanId 和 BaggageItem 读出来,序列化到请求中。因此 Service Mesh 能够为 Tracing 提供的能力是根据协议获取一些服务的元数据,并且能够知道服务调用成功还是失败,知道服务往哪里调用了等等,但是还需要各个语言的系统来实现数据的传递动作。
4、总结
总结一下的话,做到多语言网络通信这件事情,保持语言的中立特别重要,从研发同学的思维,到架构的设计,到代码的实现,都得想着这个事情。另外,Service Mesh 虽然看起来很好,但是落地的时候,请准备好妥协的准备,并且也需要你知道 Service Mesh 的能与不能,能的地方是否对你有足够大的吸引力,不能的地方你是否又有办法补上。
原文发布时间为:2018-11-22
本文作者:SOFA 黄挺