vivo 微服务 API 网关架构实践
一、背景介绍
网关作为微服务生态中的重要一环,由于历史原因,中间件团队没有统一的微服务 API 网关,为此准备技术预研打造一个功能齐全、可用性高的业务网关。
二、技术选型
常见的开源网关按照语言分类有如下几类:
Nginx+Lua:OpenResty、Kong 等;
Java:Zuul1/Zuul2、Spring Cloud Gateway、gravitee-gateway、Dromara Soul 等;
Go:janus、GoKu API Gateway 等;
Node.js:Express Gateway、MicroGateway 等。
由于团队内成员基本上为 Java 技术栈,因此并不打算深入研究非 Java 语言的网关。接下来我们主要调研了 Zuul1、Zuul2、Spring Cloud Gateway、Dromara Soul。
业界主流的网关基本上可以分为下面三种:
Servlet + 线程池
NIO(Tomcat / Jetty) + Servlet 3.0 异步
NettyServer + NettyClient
在进行技术选型的时候,主要考虑功能丰富度、性能、稳定性。在反复对比之后,决定选择基于 Netty 框架进行网关开发;但是考虑到时间的紧迫性,最终选择为针对 Zuul2 进行定制化开发,在 Zuul2 的代码骨架之上去完善网关的整个体系。
三、Zuul2 介绍
接下来我们简要介绍一下 Zuul2 关键知识点。
Zuul2 的架构图:
为了解释上面这张图,接下来会分别介绍几个点
如何解析 HTTP 协议
Zuul2 的数据流转
两个责任链:Netty ChannelPipeline 责任链 + Filter 责任链
3.1 如何解析 HTTP 协议
学习 Zuul2 需要一定的铺垫知识,比如:Google Guice、RxJava、Netflix archaius 等,但是更关键的应该是:如何解析 HTTP 协议,会影响到后续 Filter 责任链的原理解析,为此先分析这个关键点。
首先我们介绍官方文档中的一段话:
By default Zuul doesn't buffer body content, meaning it streams the received headers to the origin before the body has been received.
This streaming behavior is very efficient and desirable, as long as your filter logic depends on header data.
翻译成中文:
默认情况下 Zuul2 并不会缓存请求体,也就意味着它可能会先发送接收到的请求 Headers 到后端服务,之后接收到请求体再继续发送到后端服务,发送请求体的时候,也不是组装为一个完整数据之后才发,而是接收到一部分,就转发一部分。
这个流式行为是高效的,只要 Filter 过滤的时候只依赖 Headers 的数据进行逻辑处理,而不需要解析 RequestBody。
上面这段话映射到 Netty Handler 中,则意味着 Zuul2 并没有使用 HttpObjectAggregator。
我们先看一下常规的 Netty Server 处理 HTTP 协议的样例:
NettyServer 样例
这个例子中的两个关键类为:HttpServerCodec、HttpObjectAggregator。
HttpServerCodec 是 HttpRequestDecoder、HttpResponseEncoder 的组合器。
HttpRequestDecoder 职责:将输入的 ByteBuf 解析成 HttpRequest、HttpContent 对象。
HttpResponseEncoder 职责:将 HttpResponse、HttpContent 对象转换为 ByteBuf,进行网络二进制流的输出。
HttpObjectAggregator 的作用:组装 HttpMessage、HttpContent 为一个完整的 FullHttpRequest 或者 FullHttpResponse。
当你不想关心 chunked 分块传输的时候,使用 HttpObjectAggregator 是非常有用的。
HTTP 协议通常使用 Content-Length 来标识 body 的长度,在服务器端,需要先申请对应长度的 buffer,然后再赋值。如果需要一边生产数据一边发送数据,就需要使用"Transfer-Encoding: chunked" 来代替 Content-Length,也就是对数据进行分块传输。
接下来我们看一下 Zuul2 为了解析 HTTP 协议做了哪些处理。
Zuul 的源码:https://github.com/Netflix/zuul,基于 v2.1.5。
通过对比上面的样例发现,Zuul2 并没有添加 HttpObjectAggregator,也就是需要自行去处理 chunked 分块传输问题、自行组装请求体数据。
为了解决上面说的 chunked 分块传输问题,Zuul2 通过判断是否 LastHttpContent,来判断是否接收完成。
3.2 Zuul2 数据流转
如上图所示,Netty 自带的 HttpServerCodec 会将网络二进制流转换为 Netty 的 HttpRequest 对象,再通过 ClientRequestReceiver 编解码器将 HttpRequest 转换为 Zuul 的请求对象 HttpRequestMessageImpl;
请求体 RequestBody 在 Netty 自带的 HttpServerCodec 中被映射为 HttpContent 对象,ClientRequestReceiver 编解码器依次接收 HttpContent 对象。
完成了上述数据的转换之后,就流转到了最重要的编解码 ZuulFilterChainHandler,里面会执行 Filter 链,也会发起网络请求到真正的后端服务,这一切都是在 ZuulFilterChainHandler 中完成的。
得到了后端服务的响应结果之后,也经过了 Outbound Filter 的过滤,接下来就是通过 ClientResponseWriter 把 Zuul 自定义的响应对象 HttpResponseMessageImpl 转换为 Netty 的 HttpResponse 对象,然后通过 HttpServerCodec 转换为 ByteBuf 对象,发送网络二进制流,完成响应结果的输出。
这里需要特别说明的是:由于 Zuul2 默认不组装一个完整的请求对象/响应对象,所以 Zuul2 是分别针对请求头+请求 Headers、请求体进行 Filter 过滤拦截的,也就是说对于请求,会走两遍前置 Filter 链,对于响应结果,也是会走两遍后置 Filter 链拦截。
3.3 两个责任链
3.3.1 Netty ChannelPipeline 责任链
Netty 的 ChannelPipeline 设计,通过往 ChannelPipeline 中动态增减 Handler 进行定制扩展。
接下来看一下 Zuul2 Netty Server 中的 pipeline 有哪些 Handler?
接着继续看一下 Zuul2 Netty Client 的 Handler 有哪些?
本文不针对具体的 Handler 进行详细解释,主要是给大家一个整体的视图。
3.3.2 Filter 责任链
请求发送到 Netty Server 中,先进行 Inbound Filters 的拦截处理,接着会调用 Endpoint Filter,这里默认为 ProxyEndPoint(里面封装了 Netty Client),发送请求到真实后端服务,获取到响应结果之后,再执行 Outbound Filters,最终返回响应结果。
三种类型的 Filter 之间是通过 nextStage 属性来衔接的。
Zuul2 存在一个定时任务线程 GroovyFilterFileManagerPoller,定期扫描特定的目录,通过比对文件的更新时间戳,来判断是否发生变化,如果有变化,则重新编译并放入到内存中。
通过定位任务实现了 Filter 的动态加载。
四、功能介绍
上面介绍了 Zuul2 的部分知识点,接下来介绍网关的整体功能。
4.1 服务注册发现
网关承担了请求转发的功能,需要一定的方法用于动态发现后端服务的机器列表。
这里提供两种方式进行服务的注册发现:
集成网关 SDK
网关 SDK 会在服务启动之后,监听 ContextRefreshedEvent 事件,主动操作 zk 登记信息到 zookeeper 注册中心,这样网关服务、网关管理后台就可以订阅节点信息。
网关 SDK 添加了 ShutdownHook,在服务下线的时候,会删除登记在 zk 的节点信息,用于通知网关服务、网关管理后台,节点已下线。
手工配置服务的机器节点信息
在网关管理后台,手工添加、删除机器节点。
在网关管理后台,手工设置节点上线、节点下线操。
为了防止 zookeeper 故障,网关管理后台已提供 HTTP 接口用于注册、取消注册作为兜底措施。
4.2 动态路由
动态路由分为:机房就近路由、灰度路由(类似于 Dubbo 的标签路由功能)。
机房就近路由:请求最好是不要跨机房,比如请求打到网关服务的 X 机房,那么也应该是将请求转发给 X 机房的后端服务节点,如果后端服务不存在 X 机房的节点,则请求到其他机房的节点。
灰度路由:类似于 Dubbo 的标签路由功能,如果希望对后端服务节点进行分组隔离,则需要给后端服务一个标签名,建立"标签名→节点列表"的映射关系,请求方携带这个标签名,请求到相应的后端服务节点。
网关管理后台支持动态配置路由信息,动态开启/关闭路由功能。
4.3 负载均衡
当前支持的负载均衡策略:加权随机算法、加权轮询算法、一致性哈希算法。
可以通过网关管理后台动态调整负载均衡策略,支持 API 接口级别、应用级别的配置。
负载均衡机制并未采用 Netflix Ribbon,而是仿造 Dubbo 负载均衡的算法实现的。
4.4 动态配置
API 网关支持一套自洽的动态配置功能,在不依赖第三方配置中心的条件下,仍然支持实时调整配置项,并且配置项分为全局配置、应用级别治理配置、API 接口级别治理配置。
在自洽的动态配置功能之外,网关服务也与公司级别的配置中心进行打通,支持公司级配置中心配置相应的配置项。
4.5 API 管理
API 管理支持网关 SDK 自动扫描上报,也支持在管理后台手工配置。
4.6 协议转换
后端的服务有很多是基于 Dubbo 框架的,网关服务支持 HTTP→HTTP 的请求转发,也支持 HTTP→Dubbo 的协议转换。
同时 C++技术栈,采用了 tars 框架,网关服务也支持 HTTP → tras 协议转换。
4.7 安全机制
API 网关提供了 IP 黑白名单、OAuth 认证授权、appKey&appSecret 验签、矛盾加解密、vivo 登录态校验的功能。
4.8 监控/告警
API 网关通过对接通用监控上报请求访问信息,对 API 接口的 QPS、请求响应吗、请求响应时间等进行监控与告警;
通过对接基础监控,对网关服务自身节点进行 CPU、IO、内存、网络连接等数据进行监控。
4.9 限流/熔断
API 网关与限流熔断系统进行打通,可以在限流熔断系统进行 API 接口级别的配置,比如熔断配置、限流配置,而无需业务系统再次对接限流熔断组件。
限流熔断系统提供了对 Netflix Hystrix、Alibaba Sentinel 组件的封装。
4.10 无损发布
业务系统的无损发布,这里分为两种场景介绍:
集成了网关 SDK:网关 SDK 添加了 ShutdownHook,会主动从 zookeeper 删除登记的节点信息,从而避免请求打到即将下线的节点。
未集成网关 SDK:如果什么都不做,则只能依赖网关服务的心跳检测功能,会有 15s 的流量损失。庆幸的是管理后台提供了流量摘除、流量恢复的操作按钮,支持动态的上线、下线机器节点。
网关集群的无损发布:我们考虑了后端服务的无损发布,但是也需要考虑网关节点自身的无损发布,这里我们不再重复造轮子,直接使用的是 CICD 系统的 HTTP 无损发布功能(Nginx 动态摘除/上线节点)。
4.11 网关集群分组隔离
网关集群的分组隔离指的是业务与业务之间的请求应该是隔离的,不应该被部分业务请求打垮了网关服务,从而导致了别的业务请求无法处理。
这里我们会对接入网关的业务进行分组归类,不同的业务使用不同的分组,不同的网关分组,会部署独立的网关集群,从而隔离了风险,不用再担心业务之间的互相影响。
五、系统架构
5.1 模块交互图
5.2 网关管理后台
模块划分
5.3 通信机制
由于需要动态的下发配置,比如全局开关、应用级别的治理配置、接口级别的治理配置,就需要网关管理后台可以与网关服务进行通信,比如推拉模式。
两种设计方案
基于注册中心的订阅通知机制
基于 HTTP 的推模式 + 定时拉取
这里并未采用第一种方案,主要是因为以下缺点:
严重依赖 zk 集群的稳定性
信息不私密(zk 集群权限管控能力较弱、担心被误删)
无法灰度下发配置,比如只对其中的一台网关服务节点配置生效
5.3.1 基于 HTTP 的推模式
因为 Zuul2 本身就自带了 Netty Server,同理也可以再多启动一个 Netty Server 提供 HTTP 服务,让管理后台发送 HTTP 请求到网关服务,进而发送配置数据到网关服务了。
所以图上的蓝色标记 Netty Server 用于接收客户端请求转发到后端节点,紫色标记 Netty Server 用于提供 HTTP 服务,接收配置数据。
5.3.2 全量配置拉取
网关服务在启动之初,需要发送 HTTP 请求到管理后台拉取全部的配置数据,并且也需要拉取归属当前节点的灰度配置(只对这个节点生效的试验性配置)。
5.3.3 增量配置定时拉取
上面提到了"基于 HTTP 的推模式"进行配置的动态推送,也介绍了全局配置拉取,为了保险起见,网关服务还是新增了一个定时任务,用于定时拉取增量配置。
可以理解为兜底操作,就好比配置中心支持长轮询获取数据实时变更+定时任务获取全部数据。
在拉取到增量配置之后,会比对内存中的配置数据是否一致,如果一致,则不操作直接丢弃。
5.3.4 灰度配置下发
上面也提到了"灰度配置"这个词,这里详细解释一下什么是灰度配置?
比如当编辑了某个接口的限流信息,希望在某个网关节点运行一段时间,如果没有问题,则调整配置让全部的网关服务节点生效,如果有问题,则也只是其中一个网关节点的请求流量出问题。
这样可以降低出错的概率,当某个比较大的改动或者版本上线的时候,可以控制灰度部署一台机器,同时配置也只灰度到这台机器,这样风险就降低了很多。
灰度配置:可以理解为只在某些网关节点生效的配置。
灰度配置下发其实也是通过"5.3.1 基于 HTTP 的推模式"来进行下发的。
5.4 网关 SDK
网关 SDK 旨在完成后端服务节点的注册与下线、API 接口列表数据上报,通过接入网关 SDK 即可减少手工操作。网关 SDK 通过 ZooKeeper client 操作节点的注册与下线,通过发起 HTTP 请求进行 API 接口数据的上报。
支持 SpringMVC、SpringBoot 的 web 接口自动扫描、Dubbo 新老版本的 Service 接口扫描。
Dubbo 接口上报:
旧版 Dubbo:自定义 BeanPostProcessor,用于提取到 ServiceBean,放入线程池异步上报到网关后台。
新版 Dubbo:自定义 ApplicationListener,用于监听 ServiceBeanExportedEvent 事件,提取 event 信息,上报到网关后台。
HTTP 接口上报:
自定义 BeanPostProcessor,用于提取到 Controller、RestController 的 RequestMapping 注解,放入线程池异步上报 API 信息。
六、改造之路
6.1 动态配置
关联知识点:
Zuul2 依赖的动态配置为 archaius,通过扩展 ConcurrentMapConfiguration 添加到 ConcurrentCompositeConfiguration 中。
新增 GatewayConfigConfiguration,用于存储全局配置、治理配置、节点信息、API 数据等。
通过 Google Guice 控制 Bean 的加载顺序,在较早的时机,执行 ConfigurationManager.getConfigInstance(),获取到 ConcurrentCompositeConfiguration,完成 GatewayConfigConfiguration 的初始化,然后再插入到第一个位置。
后续只需要对 GatewayConfigConfiguration 进行配置的增删查改操作即可。
6.2 路由机制
路由机制也是仿造的 Dubbo 路由机制,灰度路由是仿造的 Dubbo 的标签路由,就近路由可以理解为同机房路由。
请求处理过程:
客户端请求过来的时候,网关服务会通过 path 前缀提取到对应的后端服务名或者在请求 Header 中指定传递对应的 serviceName,然后只在匹配到的后端服务中,继续 API 匹配操作,如果匹配到 API,则筛选出对应的后端机器列表,然后进行路由、负载均衡,最终选中一台机器,将请求转发过去。
这里会有个疑问,如果不希望只在某个后端服务中进行请求路由匹配,是希望在一堆后端服务中进行匹配,需要怎么操作?
在后面的第七章节会解答这个疑问,请耐心阅读。
6.2.1 就近路由
当请求到网关服务,会提取网关服务自身的机房 loc 属性值,读取全局、应用级别的开关,如果就近路由开关打开,则筛选服务列表的时候,会过滤相同 loc 的后端机器,负载均衡的时候,在相同 loc 的机器列表中挑选一台进行请求。
如果没有相同 loc 的后端机器,则降级从其他 loc 的后端机器中进行挑选。
其中 loc 信息就是机房信息,每个后端服务节点在 SDK 上报或者手工录入的时候,都会携带这个值。
6.2.2 灰度路由
灰度路由需要用户传递 Header 属性值,比如 gray=canary_gray。
网关管理后台配置灰度路由的时候,会建立 grayName -> List<Server>映射关系,当网关管理后台增量推送到网关服务之后,网关服务就可以通过 grayName 来提取配置下的后端机器列表,然后再进行负载均衡挑选机器。
如下图所示:
6.3 API 映射匹配
网关在进行请求转发的时候,需要明确知道请求哪一个服务的哪一个 API,这个过程就是 API 匹配。
因为不同的后端服务可能会拥有相同路径的 API,所以网关要求请求传递 serviceName,serviceName 可以放置于请求 Header 或者请求参数中。
携带了 serviceName 之后,就可以在后端服务的 API 中去匹配了,有一些是相等匹配,有些是正则匹配,因为 RESTFul 协议,需要支持 /* 通配符匹配。
这里会有人疑问了,难道请求一定需要显式传递 serviceName 吗?
为了解决这个问题,创建了一个 gateway_origin_mapping 表,用于 path 前缀或者域名前缀 映射到 serviceName,通过在管理后台建立这个映射关系,然后推送到网关服务,即可解决显式传递 serviceName 的问题,会自动提取请求的 path 前缀、域名前缀,找到对应的 serviceName。
如果不希望是在一个后端服务中进行 API 匹配,则需阅读后面的第七章节。
6.4 负载均衡
替换 ribbon 组件,改为仿造 Dubbo 的负载均衡机制。
替换的理由:ribbon 的服务列表更新只是定期更新,如果不考虑复杂的筛选过滤,是满足要求的,但是如果想要灵活的根据请求头、请求参数进行筛选,ribbon 则不太适合。
6.5 心跳检测
核心思路:当网络请求正常返回的时候,心跳检测是不需要,此时后端服务节点肯定是正常的,只需要定期检测未被请求的后端节点,超过一定的错误阈值,则标记为不可用,从机器列表中剔除。
第一期先实现简单版本:通过定时任务定期去异步调用心跳检测 Url,如果超过失败阈值,则从从负载均衡列表中剔除。
异步请求采用 httpasyncclient 组件处理。
方案为:HealthCheckScheduledExecutor + HealthCheckTask + HttpAsyncClient。
6.6 日志异步化改造
Zuul2 默认采用的 log4j 进行日志打印,是同步阻塞操作,需要修改为异步化操作,改为使用 logback 的 AsyncAppender。
日志打印也是影响性能的一个关键点,需要特别注意,后续会衡量是否切换为 log4j2。
6.7 协议转换
HTTP -> HTTP
Zuul2 采用的是 ProxyEndpoint 用于支持 HTTP -> HTTP 协议转发。
通过 Netty Client 的方式发起网络请求到真实的后端服务。
HTTP -> Dubbo
采用 Dubbo 的泛化调用实现 HTTP -> Dubbo 协议转发,可以采用 $invokeAsync。
HTTP → Tars
基于 tars-java 采用类似于 Dubbo 的泛化调用的方式实现协议转发,基于https://github.com/TarsCloud/TarsGateway 改造而来的。
6.8 无损发布
网关作为请求转发,当然希望在业务后端机器部署的期间,不应该把请求转发到还未部署完成的节点。
业务后端机器节点的无损发布,这里分为两种场景介绍:
集成了网关 SDK 网关 SDK 添加了 ShutdownHook,会主动从 zookeeper 删除登记的节点信息,从而避免请求打到即将下线的节点。
未集成网关 SDK 如果什么都不做,则只能依赖网关服务的心跳检测功能,会有 15s 的流量损失。庆幸的是管理后台提供了流量摘除、流量恢复的操作按钮,支持动态的上线、下线机器节点。
设计方案
我们给后端机器节点 dynamic_forward_server 表新增了一个字段 online,如果 online=1,则代表在线,接收流量,反之,则代表下线,不接收流量。
网关服务 gateway-server 新增一个路由:OnlineRouter,从后端机器列表中筛选 online=1 的机器,过滤掉不在线的机器,则完成了无损发布的功能。
6.9 网关集群分组隔离
网关集群的分组隔离指的是业务与业务之间的请求应该是隔离的,不应该被部分业务请求打垮了网关服务,从而导致了别的业务请求无法处理。
这里我们会对接接入网关的业务进行分组归类,不同的业务使用不同的分组,不同的网关分组,会部署独立的网关集群,从而隔离了风险,不用再担心业务之间的互相影响。
举例:
金融业务在生产环境存在一个灰度点检环境,为了配合金融业务的迁移,这边也必须有一套独立的环境为之服务,那是否重新部署一套全新的系统呢(独立的前端+独立的管理后台+独立的网关集群)
其实不必这么操作,我们只需要部署一套独立的网关集群即可,因为网关管理后台,可以同时配置多个网关分组的数据。
创建一个新的网关分组 finance-gray,而新的网关集群只需要拉取 finance-gray 分组的配置数据即可,不会对其他网关集群造成任何影响。
七、如何快速迁移业务
在业务接入的时候,现有的网关出现了一个尴尬的问题,当某些业务方自行搭建了一套 Spring Cloud Gateway 网关,里面的服务没有清晰的 path 前缀、独立的域名拆分,虽然是微服务体系,但是大家共用一个域名,接口前缀也没有良好的划分,混用在一起。
这个时候如果再按照原有的请求处理流程,则需要业务方进行 Nginx 的大量修改,需要在 location 的地方都显式传递 serviceName 参数,但是业务方不愿意进行这一个调整。
针对这个问题,其实本质原因在于请求匹配逻辑的不一致性,现有的网关是先匹配服务应用,再进行 API 匹配,这样效率高一些,而 Spring Cloud Gateway 则是先 API 匹配,命中了才知道是哪个后端服务。
为了解决这个问题,网关再次建立了一个 "微服务集" → "微服务应用列表" 的映射关系,管理后台支持这个映射关系的推送。
一个网关分组下面会有很多应用服务,这里可以拆分为子集合,可以理解为微服务集就是里面的子集合。
客户端请求传递过来的时候,需要在请求 Header 传递 scTag 参数,scTag 用来标记是哪个微服务集,然后提取到 scTag 对应的所有后端服务应用列表,依次去对应的应用服务列表中进行 API 匹配,如果命中了,则代表请求转发到当前应用的后端节点,而对原有的架构改造很小。
如果不想改动客户端请求,则需要在业务域名的 Nginx 上进行调整,传递 scTag 请求 Header。
作者:Lin Chengjun
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/9e616fe50f942c1206b913989】。文章转载请联系作者。
评论