干货 | 携程 SOA 的 Service Mesh 架构落地

2022-12-27 00:00:00 代码 请求 服务 配置 序列化


一、背景

携程的 SOA 系统经历了 ESB、微服务等架构的演变,正处于一个较平稳的阶段。但当前的微服务架构却遇到了各种业内经常遇到的问题,例如:

1)无法支撑多语言战略,团队没有精力维护除了 Java 以外其他语言的 SDK;

2)客户端 SDK 版本升级推进困难,特别是遇到 Bug 的时候,彻底下线一个版本可能会花上几个月的时间,给业务带来了隐患;

在 Service Mesh 架构出现时,我们就注意到了它。一边探索一边实践,尝试着用 Service Mesh 来解决我们的痛点。

二、技术方案

携程主营业务在国内,并且在国际上也有着不小的业务量。基于这个特点,国内使用自建机房、国外使用公有云的模式是非常合适的。

正因为技术栈需要支持跨机房部署,所以将云原生架构作为演进的目标。

Istio 作为云原生架构中重要的一位成员,和云原生架构中的其他成员相辅相成。

除此以外,携程当前 SOA 以 HTTP 协议为主,Istio 对 HTTP、HTTPS、gRPC 这几个传输协议有着全面的支持。

自然地,基于 Istio 做二次开发成为了我们内部推行 Service Mesh 的技术实现方案。

虽然开源项目开箱即用,但在调研和方案设计的过程中,我们也遇到了很多问题,例如:

  1. 1)如何实现不改造业务代码即可接入 Service Mesh,做到无感知迁移

  2. 2)Istio 无法覆盖所有携程需要的功能

  3. 3)Istio 没有配置按需下发方案

  4. 4)Istio 性能无法支撑携程规模

  5. 5)Istio 对高可用方面的支持不够

接下来,本文会着重介绍 1-3 的解决思路,4-5 可以参考我们另外两篇文章:

  • • 携程Service Mesh性能优化实践

  • • 携程Service Mesh可用性实践


三、控制平面

控制平面想要实现无感知迁移,那么重要的就是要实现两套系统的互通,其中主要包括:统一配置管理、服务注册与发现、功能对齐和 SDK 兼容。

3.1 统一配置管理

我们现有的 SOA 系统已经有了一套包括管理后台、实时推送等功能模块的系统,并不需要再造一套。

但从云原生架构的角度看,这样的设计就不够云原生。云原生的架构下更推崇声明式 API 和 GitOps 工作流。我们现有的系统显得有点落伍,对将来上公有云也不太友好。

所以这里就出现了两个方案:

  • • 方案一:用户依然在现有系统中操作,通过 Operator 转换成 Istio 的配置;

  • • 方案二:上线后一次性将现有配置全部迁移到 Istio 配置,编写适配器给原系统使用者调用,用户可以用 GitOps 工作流也可以用原有的管理后台操作;

团队内部对这两个方案也争论了很久,两者各有利弊。

终我们还是采取了先用当前配置为权威的方案,将来再慢慢将 Istio 配置作为权威并引入 GitOps 工作流。

这样选择主要的原因就是,做现有系统配置和 Istio 配置的映射并不是一个确定的转换规则,随着我们对 Istio 的改造和理解更深入,转换逻辑会得到优化,终的结果也会改变。另外想要实现方案二,不仅要实现一套老配置到新配置的适配,还要实现一套新配置到老的管理后台的适配,工作量非常大。

3.2 服务注册与发现

想要让服务注册与发现互通,主要方案也会有两个:

  • • 方案一:按照 Istio 的标准用法,Service Mesh 应用部署在独立的集群中,所有进出集群的流量都走 Gateway;

  • • 方案二:无论是原有的 SOA 集群还是新的 Service Mesh 集群,网络打通,两套服务注册与发现系统做实例双向同步;

由于历史原因,当前携程内部的应用有部署在 BM、VM、Kubernetes 等环境中,之前并未给不同集群做网络隔离,相互之间都是互通的。因此在实现 Service Mesh 的时候也采用了相同的方案,我们要做的就是一套实例双向同步系统。

对于在 Service Mesh 环境中部署的应用,我们的 Operator 会读取系统中应用和服务之间的绑定关系,通过监听 Kubernetes API 感知到 Pods Ready 后帮助 Pods 注册到老的注册中心,应用的 SDK 无需做任何操作。也就是说在 Service Mesh 环境部署了一个其他语言编写的应用,只要它在系统中绑定了对应的服务并在标准端口暴露了服务,就可以被 Service Mesh 中的应用访问到,也可以被原 SOA 系统中的应用访问到。

对于在原 SOA 系统中部署的应用,它的 SDK 会先将自己上报到注册中心。Operator 会监听注册中心的实例变化并转换成 Istio 的配置。

在 Istio 的模型中,不仅可以将 Kubernetes 中的 Services 和 Pods 转换成 Envoy 的 Clusters 和 Instances,还可以定义 ServiceEntry 和 WorkloadEntry,将外部的地址转换成 Envoy 的 Clusters 和 Instances。我们正是利用了这个功能将原注册中心的服务和实例同步到了 Service Mesh 集群中。

3.3 功能对齐

接入 Service Mesh 之后,原来 SOA SDK 支持的一些功能,我们也需要进行支持,比如预热、熔断、限流等。

预热

基于 JVM 的应用在刚启动时,由于热点代码还没有进行 JIT 编译等原因,如果这时就接入和平时一样的请求流量,会导致这部分请求的响应时间增加。预热的基本思想就是让刚启动的机器逐步接入流量,目前 SOA SDK 中已经实现了一些预热算法,客户端会根据服务端的配置来控制流量以达到预热的效果,可配置的参数有:预热算法(例如控制流量直线型增长或指数型增长等)、预热时间等。

而 Envoy 在新版中才提供了类似的功能:Slow start mode

那如何在现有的版本中实现这个功能?由于这里的核心目标就是让刚启动的服务实例逐步接入流量,小连接数的负载均衡算法就可以达到这个目标,并且负载均衡方式通过 Istio 的 DestinationRule 直接就可以进行配置,这个算法可以使客户端每次都去访问当前活跃请求数小那个服务端。于是我们考虑在服务发布期间,将服务的负载均衡方式设置为小连接数。

对于利用小连接数的负载均衡的实际效果我们也做了相关的测试,可以看到引入后服务端在启动过程中,客户端的平均响应时间大幅度下降。

在 SOA SDK 支持的预热中需要配置预热时长,而用户很难确定这个时长需要配置为多少。不同的机器配置、不同的 QPS、不同的业务逻辑等,都会影响服务从启动到预热完成所花的时间。如果配置的预热时长太短,实例没有真正的预热完成,就会导致部分请求响应时间增加了;而如果配置的太长,就会导致已经预热完成的实例没有充分的发挥作用。

而在服务接入 Service Mesh 后,在发布期间我们通过小连接数的负载均衡算法,自适应地调整不同实例的负载。用户不需要关心需要配置什么预热算法,也不需要决定预热时长是多少,只要打开预热开关就可以了。

熔断

Istio 中可以通过设置DestinationRuleConnectionPoolOutlierDetection,来实现熔断策略。当服务由于某些故障开始响应变慢时,ConnectionPool中关于 pending 请求数、大并发请求数的设置,会限制客户端继续向变慢的服务发送更多请求,以此来给服务一些时间从响应变慢中恢复。当服务的部分实例出现故障时,OutlierDetection的配置使客户端停止对这些故障实例的访问,来减轻偶发的部分服务实例故障对客户端的影响,也让故障的那部分实例有时间恢复。

我们的 SOA SDK 目前基于 Hystrix 来实现线程隔离,这部分功能基本对应到 Istio 中的ConnectionPool配置,Istio 中不同的目标服务都有自己的一组连接池。两者实现方式不同,但基本可以达到同样的效果。

当服务的部分实例出现故障时,Hystrix 会将新的请求拒绝,这部分可以对应到 Istio 中的OutlierDetection配置。不过这里有个区别,这种情况下 Istio 会将故障实例摘除,而不是直接报错。Istio 中这个功能的本质是一种客户端健康检测,但可以达到类似的效果,应该来说比直接报错更好。

限流

我们 SOA SDK 支持的限流为本地限流,具体包括对特定操作限流、对特定的请求方 AppID 限流等,我们需要在 Service Mesh 中也支持这些功能。由于目前 Istio 没有对限流抽象出模型定义,我们通过EnvoyFilter打 patch 的方式,来对开启了限流的服务生成对应的 Envoy 限流配置。

为了将 SOA SDK 已有的丰富的限流配置都在 Service Mesh 中得到对应的支持,我们使用了 Envoy 限流的descriptors特性。

参考文档:Local rate limit

比如我们可以通过descriptor.entry,对来自不同客户端的请求、不同的请求 path 配置不同的限流值。如下的配置,对来自 client-a 的请求,设置的是每 60s 填充 10 个 token,对来自 client-b 且请求 path 是/foo的请求,设置的是每 60s 填充 100 个 token。

descriptors:
  - entries:
      - key: client_appid
        value: client-a
    token_bucket:
      max_tokens: 10
      tokens_per_fill: 10
      fill_interval: 60s
  - entries:
      - key: client_appid
        value: client-b
      - key: path
        value: /foo
    token_bucket:
      max_tokens: 100
      tokens_per_fill: 100
      fill_interval: 60s

这里的client_appid和 path 的值,通过如下在 route 的 ratelimit 配置中添加 actions 来设置。

route:
  cluster: service_protected_by_rate_limit
  rate_limits:
    - actions:
        - request_headers:
            header_name: x-envoy-client-appid
            descriptor_key: client_appid
        - request_headers:
            header_name: ":path"
            descriptor_key: path

以上配置本质上就是通过从请求中提取一些特征,例如读取一些特定的 Header,然后再针对不同的请求分配不同的限流值。

对于更加复杂的场景,例如需要根据多个 Header 做逻辑判断时,我们通过 Envoy Filter 实现相关逻辑并设置到 metadata 中。然后在 ratelimit 的 actions 中从 metadata 中提取特征。例如,我们可以 patch 如下的配置到 envoy 中,生成限流使用的 metadata 数据。

- applyTo: HTTP_FILTER
  match:
    context: SIDECAR_INBOUND
    listener:
      filterChain:
        filter:
          name: envoy.filters.network.http_connection_manager
          subFilter:
            name: istio.metadata_exchange
  patch:
    operation: INSERT_FIRST
    value:
      name: service.metadata.ratelimit
      typed_config:
        '@type': type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
        inline_code: |-
          function envoy_on_request(request_handle)
              custom_key_1 = build_custom_key_1()
              custom_key_2 = build_custom_key_2()
              request_handle:streamInfo():dynamicMetadata():set("metadata.custom.ratelimit", "request.info", {
                  customKey1 = custom_key_1,
                  customKey2 = custom_key_2
              })
          end

然后通过 metadata_key的方式,拿到我们自己设置的限流相关的 metadata。

route:
  cluster: inbound|8080|http|svc-a
  rate_limits:
    - actions:
        - metadata:
            descriptor_key: customKey1
            metadata_key:
              key: metadata.custom.ratelimit
              path:
                - key: request.info
                - key: customKey1
        - metadata:
            descriptor_key: customKey2
            metadata_key:
              key: metadata.custom.ratelimit
              path:
                - key: request.info
                - key: customKey2

其他功能

除了一些服务治理常见的功能,携程内部也有不少定制化的功能需要在 Service Mesh 中实现。

这部分需求部分是通过 Lua Filter 实现,部分是扩展 Envoy 编写了 C++ Filter 来实现的。

因为这 C++ Filter 这部分需求比较稳定,所以静态编译在 Sidecar 中也并不是个大问题。

如果可以通过 WebAssembly 实现当然是可以做得更灵活。但在我们项目启动的时候,Istio 的 WebAssembly 还未正式发布,后期我们会考虑引入 WebAssembly。

3.4 SDK 兼容

接入 Service Mesh 后的一大优势就是可以为 SOA SDK 做轻量化,仅保留基本的功能即可。

而 Service Mesh 的接入是一个长期的过程,应用是一批批接入的,同一个应用在不同的机房也有可能存在接入和不接入两种状态。让业务方写两个版本的代码肯定是不合适的。

因此我们在现有的 SOA SDK 中实现了无缝接入功能,原理也非常简单。

凡是接入 Service Mesh 的应用在发布时就会被注入一个环境变量,当 SOA SDK 探测到这个环境变量后,便会启动轻量化模式。

其中轻量化模式中被移出的功能包括:

  • • 禁用服务注册与发现

  • • 禁用路由功能,每个服务都有一个固定的域名,所有请求直接按服务请求固定的域名即可

  • • 禁用各种服务治理功能,例如:熔断、限流、黑白名单、重试、负载均衡、路由等

这样不仅有利于业务方快速回滚,也可以方便业务方对两种 SOA 架构进行性能对比。

四、数据平面

4.1 HTTP 协议

携程当前主流的 SOA 传输协议还是 HTTP,这块的确慢了半拍,但这也更利于我们接入 Service Mesh。

Istio 本身对 HTTP 协议有着很好的支持,因此这部分并不需要我们做什么调整。

4.2 Dubbo 协议

携程在 2018 年的时候引入了 dubbo 协议作为 HTTP 协议的补充,在两年时间的发展中也积累了较多的用户群体。

我们的 dubbo 的调用方式依赖 Dubbo 框架中的 dubbo 协议。

部分应用扩展使用了压缩率更高的 protobuf 来做序列化并进一步使用 gzip 压缩提升性能。

dubbo 协议本身是四层的私有协议,在 Istio 中的支持力度远不如 HTTP。另外 Dubbo 3.0 中也将 gRPC 协议作为了新的传输协议。

如何让 dubbo 协议升级到 gRPC 成为 Service Mesh 落地必须解决的问题。

现状

当前携程内部通过 Dubbo 框架调开发服务端应用有近千个,对应的调用方就更多了。

并且考虑到携程内部使用 Node.js、Python 等语言的应用越来越多,新版升级必须满足如下的要求:

1)使用 gRPC 协议以支持 Service Mesh

2)使用 Dubbo 框架的业务方尽量不改或者少改代码

3)新协议注册的服务端符合 gRPC 的规范并使得其他语言客户端可以很方便地调用

4)不强制依赖 protobuf,能让用户保持 Code First 的编码习惯

技术选型

我们调研了当时主流的做法并通尝试得出三条可行的升级道路。

这里大的难点是当前使用 dubbo 的旧服务不是基于 protobuf 编写契约的,所以不能直接通过依赖 protobuf 结构的 gRPC 发起调用。

【方案一】

依然用原来的序列化器将数据处理成二进制,在 gRPC 调用时 wrap 一个 protobuf 对象,用一个字段传递原来数据的二进制数据流,再用另外一些字段描述它的序列化方式。这也是 Dubbo 3.0 中透明升级 gRPC 协议时所使用的方案。

  • • 优点:

    • • 该方案不需要对业务代码改动,并且支持 Code First

  • • 缺点:

    • • 这种方式对于标准的 gRPC 客户端不友好

    • • 两次序列化和反序列化影响性能

【方案二】

gRPC 标准中,没有规定 gRPC 强依赖 protobuf 做序列化器,gRPC 官方的 FAQ 中这样写道:

gRPC is designed to be extensible to support multiple content *. The initial release contains support for Protobuf and with external support for other content * such as FlatBuffers and Thrift, at varying levels of maturity.

那具体如何使用其他序列化器呢?

从协议角度,只需要改变content-type即可。例如想表达用 json 作为序列化格式,那具体内容为:application/grpc+json

在 Golang 中只需要额外加一行配置一下即可:

grpc.WithDefaultCallOptions(grpc.CallContentSubtype(codec.JSON{}.Name()))


  • • 优点:

    • • 对业务开发友好,符合 gRPC 协议的标准

    • • 不会影响性能

  • • 缺点:

    • • Java gRPC 客户端并没有实现这个功能

【方案三】

通过代码规范来约束字段的前后顺序,然后使用类似 protostuff 这样的框架把其他契约转换成 protobuf。

  • • 优点:

    • • 符合 gRPC 标准,对标准 gRPC 客户端友好

    • • 不会影响性能

  • • 缺点:

    • • 对于业务开发不友好,不能删字段,不能调整字段顺序,非常容易出错

【终方案】

经过对比后,我们选择方案二,基于如下理由:

1)查看 gRPC 插件生成的 Java 类,其中存在可以定制序列化器的部分的代码,使用 json 改写序列化器是可能的。

2)对于 Golang 来说,天然支持这种使用方式。

3)对于 Node.js 和 Python 等动态语言,替换序列化器非常简单。

4)Dubbo 支持 POJO 并且基于 Java POJO 的服务定义方式在携程大多数应用的开发形式。

技术实现细节

【去契约化】

原生的 gRPC 基于 proto 文件,依赖 gRPC 的插件完成代码生成。

Dubbo 中的 gRPC 依赖 proto 文件生成 gRPC 代码的同时,需要还要配合 Dubbo 扩展的 proto 插件生成DubboGprcWrapper

Dubbo 本身为 gRPC 做了包装,可以让 gRPC 协议的入口复用 dubbo 本身的注册发现,服务注册与发现以及负载均衡等扩展。

proto 文件生成 gRPC 代码本质上有两部分,一部分是对服务的定义,另一部分是对 DTO 的定义和 DTO 的序列化反序列化代码。

如果只有代码而没有 proto,我们可以依靠反射服务接口类来实现获取服务和 DTO 的定义。

而 DTO 的序列化反序列化,如果替换成 json 或 hessian,那就不依赖 gRPC 生成的代码了,只要有 POJO 就可以处理。

XXXMethodDefinition.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(InputType.getDefaultInstance()))

上面的代码是 gRPC 插件生成的,只需要将这部分替换成其他序列化器,就可以实现序列化器的替换。

【Java 字节码编译技术动态编译】

有了上述去契约化的实现,后续我们基于 Java 字节码编译技术,直接在内存中生成对应的 gRPC 协议依赖的对象。

使得用户可以在代码优先的情况下,依旧可以继续开启 gRPC 协议传输。

同时因为 gRPC 相关类是动态生成的,我们可以在动态编译的时候为接口定制序列化器。

只要将序列化格式换成其他等不依赖 protobuf 结构的类,旧的服务也可以直接升级为 gRPC。

而 Dubbo 依赖的DubboGprcWrapper的类也改由动态编译生成,至此用户将脱离契约的限制。

这里我们使用了基于JDKCompiler实现编译器,使用 JDK8 的版本编译生成代码。

我们在代码中用 mustache 模板定义了代码生成的主要框架,然后根据 Java 服务类反射获取元数据渲染 Java 类。

部分模板代码如下:

{#methods}}
    {{^isMultiParameter}}
    {{#isManyInput}}
        {{#isManyOutput}}
        {{/isManyOutput}}
        {{^isManyOutput}}
    public io.grpc.stub.StreamObserver<{{inputType}}> {{methodName}}(
        io.grpc.stub.StreamObserver<{{outputType}}> responseObserver) {
        return asyncUnimplementedStreamingCall(getGetStreamMethod(), responseObserver);
     }
        {{/isManyOutput}}
    {{/isManyInput}}
     {{^isManyInput}}
        {{#isManyOutput}}
        {{/isManyOutput}}
        {{^isManyOutput}}
        {{/isManyOutput}}
    {{/isManyInput}}
{{/methods}}

【用户无缝升级】

解决代码热生成之后,支持旧服务无缝升级还需要解决一些问题。

首先业务原生的代码实现逻辑与 gRPC 接口存在差异。

比如 Dubbo 的 gRPC 要求实现类需要实现XXXBase接口,而实际 Code First 的用户实现类是实现了对应的XXXInterface服务接口。我们也无法让用户在编码阶段使用我们动态生成类。

我们在动态生成时代码内部额外生成一个 Wrapped 类,该类代理业务的实际逻辑并且实现 gRPC 服务需要实现的XXXBase基类。

并且在业务实际代码和 gRPC 代码之间实现各种兼容的逻辑。而 gRPC 接口在默认方法中调用子类的对应方法。

@javax.annotation.Generated(
value = "generate by CDubbo",
comments = "Source: {{protoName}}")
public class {{className}}Wrapper extends Dubbo{{serviceName}}Grpc.{{serviceName}}ImplBase {
    private static final TransformUtils transformUtils = CDubboInjector.getInstance(TransformUtils.class);
    private static final CDubboUtil cDubboUtil = CDubboInjector.getInstance(CDubboUtil.class);
    //实际的业务实现类
    private {{serviceName}} instance;

    public {{className}}Wrapper({{serviceName}} instance) {
      this.instance = instance;
    }

{{#unaryMethods}}
  // 被代理的对象
  @Override
  public {{outputType}} {{methodName}}({{inputType}} request) {
    try {
        return instance.{{methodName}}(request);
    }catch (Throwable e) {
        throw cDubboUtil.toGrpcException(e);
    }
  }
{{/unaryMethods}}

服务接口类:

public interface I{{serviceName}} extends {{serviceName}}{
  default public void {{methodName}}({{inputType}} request, io.grpc.stub.StreamObserver<{{outputType}}> responseObserver){
        responseObserver.onNext(this.{{methodName}}(request));
        responseObserver.onCompleted();
    }
}

我们在 Spring 注入的时候更新BeanDefinition来实现替换,将包装类设置为服务实现类的 Wrapper。

客户端这边也做类似的兼容逻辑。

上述过程完成后,用户只要在ProtocolConfig配置中明确申明使用 gRPC,就会自动为服务和客户端开启 gRPC 协议。

【新老方法兼容之CompletableFuture

改动生成服务上下文的时候标注该方法是CompletableFuture方法,在服务端的默认实现的位置获取到Future之后更改对应的方法转换为ResponseObserver。直接更新Dubbo部分代码生成的结果。

对应的更改的模板文件:

default public void {{methodName}}({{inputType}} request, io.grpc.stub.StreamObserver<{{outputType}}> responseObserver){
            CompletableFuture<{{outputType}}> response = this.{{methodName}}(request);
           response.whenComplete((res,thr)->{
             if(thr == null){
               responseObserver.onNext(res);
               responseObserver.onCompleted();
             }else {
               responseObserver.onError(thr);
             }
           });
      }

客户端使用返回ListenableFuture的方法返回Future之后再使用 Guava 的工具转换成CompletableFuture

{{#futureRequestMethods}}
    public CompletableFuture<{{outputType}}> {{methodName}}({{inputType}} request) {
          com.google.common.util.concurrent.ListenableFuture<{{outputType}}> responseFuture = futureStub
                  .withDeadlineAfter(url.getParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT), TimeUnit.MILLISECONDS)
                  .{{methodName}}(request);
          return FutureConverter.toCompletableFuture(responseFuture);
        }
{{/futureRequestMethods}}

【Stream 方法原地升级】

Dubbo 中并没有类似 gRPC Stream 的支持,所以我们基于 Dubbo Callback 进行了扩展。

CDubbo 中扩展后的接口所示:

public interface StreamContext<T{
    Result write(T v);    
    void close();
}

将 dubbo 协议升级到 gRPC 协议后如何对接到 gRPC Stream 呢?

我们在服务 Wrapper 里面 针对 gRPC 的ResponseObserver转换成对应的StreamContext传递给服务的实现类:

StreamContext<{{outputType}}> streamContext = new StreamContext<{{outputType}}>() {
          @Override
          public Result write({{outputType}} v) {
            try{
              responseObserver.onNext(v);
              return Result.SUCCESSFUL;
            }catch ( Exception e){
              throw new RuntimeException(e);
            }
          }
 
          @Override
          public void close() {
            responseObserver.onCompleted();
          }
        };
 
        try{
          // 现在都是一个请求然后拿stream的方式 暂时也只支持这样
          instance.callStreamMethod(request,streamContext);
        }catch (Throwable e){
          responseObserver.onError(e);
        }
      }

客户端需要在调用 Stream 的时候将 gRPC 的响应返回回调。Stream 有统一处理响应消息的位置,只要拿到请求的 StreamId 就能把 gRPC 的响应回调给具体的某次响应。

StreamContextImpl responseObserver1 = (StreamContextImpl) responseObserver;
    // stream 请求有个专门处理响应的地方 streamId 表示是第几个Stream 请求的响应
    String streamId = responseObserver1.getStreamId();
 
    io.grpc.stub.StreamObserver<{{outputType}}> streamObserver = new io.grpc.stub.StreamObserver<{{outputType}}>() {
      @Override
      public void onNext({{outputType}} value) {
        // streamManager是统一处理所有服务端响应的位置
        streamManager.dispatch(streamId,value);
      }
 
      @Override
      public void onError(Throwable t) {
        // 服务端报错就直接抛异常
        streamManager.killWithException(streamId);
        throw new RuntimeException(t);
      }
 
      @Override
      public void onCompleted() {
          // 服务端结束 则客户端把对应的entry 结束掉
        streamManager.kill(streamId);
      }
    };
    stub.withDeadlineAfter(url.getParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT), TimeUnit.MILLISECONDS)
            .getStreamResponse(request, streamObserver);

【服务端自动选择序列化器】

Java 中既然可以替换默认的序列化器,那么也可以实现根据 Content Type 自动选择序列化器。

我们在代码生成的时候为 gRPC 注入一个用于桥接的序列化器BridgeMarshaller,而不是一个特定的序列化器。

因为序列化器中没有办法拿到 Header,所以需要找一个扩展点从 Header 里面获取 Content Type,并通过上下文传递到序列化线程。

我们发现了StreamTracer这个扩展,虽然它的原始目的是为了处理 Trace Context,但和我们要做的事情是一样的,都是提取 Header 里的数据放到上下文中。

Trace Context:实现分布式追踪时需要透传相关上下文,在 HTTP 调用中一般将它放在 Header 中。

StreamTracer扩展:

public class CDubboGrpcConfigurator implements GrpcConfigurator {
  private static final CDubboUtil cDubboUtils = CDubboInjector.getInstance(CDubboUtil.class);
  // 获取header 传递上下文
  @Override
  public NettyServerBuilder configureServerBuilder(NettyServerBuilder builder, URL url) {
    builder.addStreamTracerFactory(new ServerStreamTracer.Factory() {
      @Override
      public ServerStreamTracer newServerStreamTracer(String fullMethodName, Metadata headers) {
        String contentType = cDubboUtils.extractContextType(headers);
        return new ServerStreamTracer() {
          @Override
          public Context filterContext(Context context) {
            return context.withValue(GrpcConstants.Serialization.SERIALIZATION_KEY, contentType);
          }
        };
      }
    });
    return builder;
  }
}

序列化选择:

public class GenericMarshaller<Timplements MethodDescriptor.Marshaller<T{
  //.....
@Override
  public T parse(InputStream stream) {
    String serialization = cDubboUtil.getSerialization(GrpcConstants.Serialization.SERIALIZATION_KEY.get());
    GrpcSerialization grpcSerialization = SERIALIZATION_MAP.get(serialization);
    if(grpcSerialization == null){
      throw new IllegalArgumentException("can not find any serialization [" + serialization + "] by content-type in header.");
    }
    return grpcSerialization.getMarshaller(clazz).parse(stream);
  }
}

CDubbo 升级 gRPC 后默认使用hessian2作为序列化器与 Dubbo 原生保持一致,尽量避免引入兼容性问题。

而其他语言例如 Golang 等就可以选择使用 json,而且并不需要 protobuf 定义契约,直接调用 gRPC 底层方法即可。

func main() {
    conn, err := grpc.Dial("127.0.0.1:9080",
        grpc.WithDefaultCallOptions(grpc.CallContentSubtype(codec.JSON{}.Name())), // codec.JSON 只要实现 gRPC 的`encoding.Codec`接口即可
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        log.Fatalf("%v\n", err)
    }

    resp := new(CheckHealthResponse) // Response 可以用 Code First 自己构建
    err = conn.Invoke(context.Background(),
        "/com.test.TestService/CheckHealth"// 这部分是 Java 中的服务接口完整名称 + 方法名
        CheckHealthRequest{Message: "Hello World"}, // Request 可以用 Code First 自己构建
        resp, grpc.EmptyCallOption{})
    if err != nil {
        log.Fatalf("%v\n", err)
    }
    fmt.Printf("%+v\n", resp)
}

另外如果这个 Dubbo 服务本身就是用 protobuf 做契约的,那么 prodobuf 也是可以使用的。

【gRPC 中异常传递】

而在 gRPC 的原生 API 中响应是缺少这部分数据的。服务处理的异常不 catch 直接会在客户端转化为 StatusRuntimeException: UNKNOWN

  @Override
  public void getLatest(User request, StreamObserver<CallHistory> responseObserver) {
    String name = request.getName();
    try {
      Integer.parseInt(name);
    }catch (Throwable e){
       // grpc 这边默认是可以带有异常 但是显示抛出
      responseObserver.onError(INVALID_ARGUMENT.withCause(e).withDescription("服务内部错误").asRuntimeException());
      return;
    }
  }

我们上述代码中已经对服务实现类做了一层 wrapper,实现了 gRPC API 之间的兼容。

由于我们是基于 Dubbo 开发的,Dubbo 遇到未知异常会转换成RpcException。如果我们不处理,这个异常在 gRPC 中就是UNKNOWN。因此需要在底层把RpcExceptionGrpcException的转换。将一般错误变为 gRPC 的异常抛出。

五、配置按需下发

在集群规模较小时,Service Mesh 中的每个服务默认可以访问任何其他的服务是没有问题的。但是当集群规模变大之后,就会出现以下问题:

  • • 推送频率高:任何一个服务的变动都要通知到所有的 Sidecar

  • • 推送数据量大:Sidecar 中包含所有服务的 Cluster、Route 等信息。

在实际的服务调用关系中,一个服务并不会真的要访问所有其他的服务,相对于服务的总量而言,每个服务只会访问到很小一部分的服务。

我们需要找到一种方式来管理服务间的调用依赖关系,并且能让 Istio 根据这份调用关系,减少推送的频率和数据量。也就是说,当 A 服务发生变化时,只有依赖 A 服务的其他服务会收到推送。

5.1 解决方案

在 Istio 中,可以通过Sidecar资源对 Sidecar 进行配置。例如如下的Sidecar配置,svc-a 需要访问 svc-b 和 svc-c,当 svc-b 和 svc-c 的配置变化时,svc-a 的代理会收到推送,而其他 svc 的配置变更,并不需要推送给 svc-a 的代理:

apiVersion: networking.istio.io/v1beta1
kind: Sidecar
metadata:
  name: svc-a
  namespace: prod
spec:
  egress:
    - hosts:
        - "prod/svc-b.soa.mesh"
        - "prod/svc-c.soa.mesh"
        - "istio-system/*"
  workloadSelector:
    labels:
      app: svc-a

这样可以解决推送量大和推送频率高的问题。

但是随之而来的新问题是,怎么知道 svc-a 需要访问 svc-b 和 svc-c 呢?一种直接的方式是,接入 Service Mesh 之前分析好服务调用依赖,配置到 Sidecar

携程目前并没有为所有服务之间梳理过调用关系,这种方式会给接入 Service Mesh 的用户带来负担,我们需要一种更加自动化的方式来透明地解决服务依赖关系的问题。

我们内部将所有的服务都映射到了.soa.mesh这个域名上。例如一个用户服务的域名是:user.soa.mesh

我们采取的方案是为每个Sidecar加一条 host 为*.soa.mesh 的路由,其中的路由目标设置为一个默认的 Gateway。当访问一个不在Sidecar资源的egress.hosts里列出的服务时,都会匹配到这条兜底路由,转发到 Gateway 进行处理。Gateway 收到这条转发过来的请求后,也就知道了服务间的调用依赖关系。转发这条请求的时候通过一定的机制去更新Sidecar资源。

图中的兜底路由通过 Host 做了分片,因为单个 Gateway 也无法承载所有的服务。

初我们考虑创建一个hosts*.soa.meshVirtualService来让 Istio 生成对应的路由,但是实际测试发现这种方式无法达到预期的效果,Istio 在处理包含*前缀域名的时候有点问题。另外如果想把这条路由下发到 Sidecar,那我们也必须在Sidecar资源里加上*.soa.mesh。但如果这么一加,岂不是就把所有服务下发下去了?

终我们通过EnvoyFilter的方式,为每个 Sidecar 来 patch 这条兜底路由。因为 Istio 中的EnvoyFilter作用在更底层,并不受Sidecar资源的控制。

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: soa-default-route
spec:
  configPatches:
    - applyTo: VIRTUAL_HOST
      match:
        context: SIDECAR_OUTBOUND
        routeConfiguration:
          name: "80"
      patch:
        operation: ADD
        value:
          name: mesh-soa-gateway
          domains:
            - "*.soa.mesh"
            - "*.soa.mesh:80"
          routes:
            - match:
                prefix: /
              route:
                cluster: outbound|80||mesh-soa-gateway
                timeout: 0s

                max_grpc_timeout: 0s

六、未来展望
6.1 WebAssembly
在 Service Mesh 中引入 WebAssembly 应该来说是一个大趋势,这也是 Service Mesh 的一大优势,不把这个优势利用好就太浪费了。
一方面,Istio 的内置功能不可能覆盖所有公司的业务需求。另一方面,各个事业部或部门也常常会有一些团队公共需求。
如果能将这些功能通过 WebAssembly 实现热加载、热更新,那 Service Mesh 的价值就被充分地发挥了。
抱着这个美好的愿景,我们在前期就调研了 Istio 中 WebAssembly 模块的功能、性能和易用性。
只可惜在一年多前这块还是有不少问题的,包括 Sidecar 占用内存大增、内存泄露、WebAssembly SDK 扩展性不够等。
目前我们内部的 Istio 版本经过了不少定制化,所以还停留在 1.10 版本,并不会紧跟官方新版。
但后续随着官方对 WebAssembly 功能的完善,将静态编译的 Envoy C++ Filter 迁移到 WebAssembly 上必定是一个趋势。
6.2 Sidecar 模式

Service Mesh 背后的模式一般被称为 Sidecar 模式。

这种模式是否可以扩展到其他领域?Service Mesh 只是解决了 SOA 这个领域的问题,能否把这种模式扩展到其它领域?充分发挥 Sidecar 模式的优势?

这是很多人会问的问题。

如果把所有模块都做成了 Sidecar 模式,我觉得要想清楚2个问题:

1)相关模块能接受额外 1ms 的响应延迟吗?Sidecar 带来的优势能否弥补这个问题?

2)Sidecar 模式和 Proxy 模式的本质区别是什么?

引入 Sidecar 势必会引入额外的性能损耗,不经特殊优化的情况下 Istio 好响应延迟也要增加 1ms 以上,不能忽略了这部分的影响。

例如 Redis 中,额外引入 1ms 的响应延迟一般是无法接受的。

在关系型数据库中,通过 Proxy 来实现读写分离和分库分表是一种很常见的做法,Sidecar 模式本质上也是一种 Proxy。

那它和传统 Proxy 模式有什么区别呢?

它们大的区别就在于这个代理部署在哪里。如果在客户端一侧,那就是 Sidecar 模式;如果在服务端一侧,那就是 Proxy 模式。

对于这两种模式如何取舍呢?

如果服务端并不是分布式的,或者目标服务器在同一个机房离得很近,那在服务器同一个机房部署一套 Proxy 集群维护起来会更加方便。

而 SOA 大的特点是一个应用往往既是客户端也是服务端,调用关系是网状的。

对于 SOA 来说,在 Sidecar 和 Proxy 之间也就只有 Sidecar 可选了。

所以,想在其他模领域引入 Sidecar 模式前要把这两个问题想清楚了,而不是无脑地引入 Sidecar 模式。

七、总结 

携程的 Service Mesh 从启动到现在经历了将近2年的时间,正式上线也有 1 年多了。当前已经接入了大约 2000 个应用、4000 个服务和6000 个 Pods,并且一直保持着稳定运行,也经受住了各种故障演练的考验。

接入 Service Mesh 后,很多 Bug 仅需修改一下控制面代码或配置就可以轻松地实现热更新;原来的 SOA SDK 也进入了冻结维护状态不再开发新功能;公司内部大量非 Java 应用也可以轻松地接入 SOA 体系。这让 SOA 团队可以为公司产出更大的价值。

当然,还有许多挑战摆在我们的面前:WebAssembly 还未发挥应有的作用;dubbo 升级 gRPC 也还未在生产部署;控制面的性能指标依然不能达到我们的要求等。这些是我们接下来需要去解决的。

后,希望本文中提到的各种问题和解决方案能给大家带来帮助和启发。

相关文章