本文整理自云原生技术实践营广州站 Meetup 的分享,其中的经验来自于我们团队开发的阿里云 CSB 2.0 这款产品,其基于开源 SpringCloud Gateway 开发,在完全兼容开源用法的前提下,做了诸多企业级的改造,涉及功能特性、稳定性、安全、性能等方面。
从功能角度来看,微服务网关通常用来统一提供认证授权、限流、熔断、协议转换等功能。
从使用场景上来看:
微服务架构、微服务/API 网关这些关键词发展至今,早已不是什么新鲜的概念,技术选型者也从出于好奇心关注一个技术,转移到了更加关注这个技术的本质。市场上各类网关产品的功能也逐渐趋于同质化,基本可以用同一张图来概括:
企业在选择使用一款网关产品时,通常会有两个选择,一是基于某一款开源产品做二次开发,二是选择某一款商业化产品开箱即用,无论如何,都应当从稳定性、安全、性能、业务兼容性等方面去进行选型。请相信我今天是站在 SpringCloud Gateway 角度进行的分享,我会尽可能做到客观、公正。
早期 SpringCloud 社区出现过 Zuul 这种产品,时至今日搜索微服务网关的资料,大概率都会出现它的身影,仅其通信模型是同步的线程模型这一条,就不足以支撑其成为企业级的网关产品选型,我会主要对比 SpringCloud Gateway、阿里云 CSB 2.0、Nginx、Kong、Envoy。
严谨来说,这几个网关并不适合对比,因为他们都有其各自适用的场景,表格仅供参考。
SpringCloud Gateway 的优势在于其可以很好地跟 Spring 社区和 SpringCloud 微服务体系打通,这一点跟 Java 语言流行的原因如出一辙,所以如果一个企业的语言体系是 Java 技术栈,并且基于 SpringBoot/ SpringCloud 开发微服务,选型 SpringCloud Gateway 作为微服务网关,会有着得天独厚的优势。
SpringCloud Gateway 选型的优势:
如果你之前没有了解过 SpringCloud Gateway,也不用担心,下面一小部分篇幅会介绍 SpringCloud Gateway 基本用法,这是一段非常基础的 SpringCloud Gateway 路由配置示例。
spring:
cloud:
gateway:
routes:
- id: aliyun
uri: https://www.aliyun.com
predicates:
- Host=*.aliyun.com
- id: httpbin
uri: http://httpbin.org
predicates:
- Path=/httpbin/**
filters:
- StripPrefix=1
- id: sca-provider
uri: lb://sca-provider
predicates:
- Path=/sca/**
filters:
- StripPrefix=1
nacos:
discovery:
server-addr: mse-xxxxx-p.nacos-ans.mse.aliyuncs.com:8848
该示例介绍了微服务网关常见的几种路由配置示例:
SpringCloud Gateway 支持丰富的路由匹配逻辑,以应对各种类型的业务诉求:
其中 Path、Header、Method 这几种断言最为常用。
针对于网关请求路径、参数和后端服务请求路径、参数不一致的场景,SpringCloud Gateway 也提供了诸多开箱即用的 GatewayFilter,以实现对请求和响应的定制。
SpringCloud Gateway 的 user guide 介绍到此为止,如果想要了解 develop guide,建议参考 SpringCloud Gateway 的官方文档。
众所周知,开源产品直接投入企业级生产使用一般是会面临一些挑战的,毕竟场景不同。以扩展性为例,开源产品大多讲究扩展点丰富,以应对开源用户千奇百怪的需求,而企业级产品场景更为单一,性能和稳定性是第一考虑因素,当二者发生 trade off 时,则需要一些取舍了。
开源 SpringCloud Gateway 没有开箱即用地支持一些重要的企业级特性,如果选型 SpringCloud Gateway 构建生产级别可用的微服务网关,那我的建议是需要补足以上这些能力。下面我会花较多的篇幅介绍我们在开源基础上做的一些企业级改造,希望能够抛砖引玉。
表面看来,SpringCloud Gateway 并没有配套一个管理控制台,深层次一点来看,是 SpringCloud Gateway 还停留在一个开发框架层面,不是那么的产品化,同时它的领域模型也不是划分的那么清晰,说的好听点,这说明 SpringCloud Gateway 有充足的改造空间。
我们的改造原则有两点,一是完全兼容开源的规则及模型,不破坏底层规则的语义,这样我们可以跟随社区的节奏一起演进,将来也有机会贡献给社区,二是区分研发态的领域模型和用户态的产品模型,我们抽象出了路由、服务、来源、消费者、策略、插件等领域对象,这算不上什么创新,实际上网关领域的这些模型早已有了一些约定俗成的规范。
白屏化管控的背后,也意味着一切配置:路由配置、服务配置、策略配置...都是动态的,并且配置的变更都会实时生效。
上文提到了配置实时生效这一改造,有人可能会有疑问,开源不是已经支持将路由配置存储在 Nacos 中了吗?对的,开源支持两种配置方式,一是将路由配置在 application.yaml 中,这样最简单,但对于路由配置的 curd 都需要重启进程,非常繁琐,二是将配置托管到 Nacos 这样的配置中心组件中,实现分布式配置,能够动态刷新,但我们认为这还不足以支持企业级需求,将配置存储在单个 dataId 中这种开源方案有以下痛点:
配置拆分势在必行,但其中困难也很多,例如动态监听的管理,稳定性的保障流程尤为复杂,额外提供的视图层与实际配置中心数据一致性保障等等。方案参考下图:
图中还有一个细节,也是我们优先选择 Nacos 作为配置中心的原因,nacos-client 的 snapshot 机制可以保证在管控以及配置中心组件都不可用时,即使网关 broker 重启了,依旧保证路由不丢失,保证自身可用性。
经过这套方案的改造,我们获得显著的优化效果:
这两个企业级改造放到一起说,在实现上这两个模块也耦合的比较紧密。
协议转换:就以 Java 微服务体系而言,后端服务很有可能会出现 Dubbo 框架或者 GRPC 框架,甚至有些老的业务还会使用 WebService 这类框架,大多数时候我们说的网关都是只对接 HTTP 这一类通信协议,这限制了我们后端服务只能是 SpringBoot 或者 SpringCloud 框架,网关支持后端不同协议类型的能力,我们称之为协议转换。
服务发现:微服务框架离不开服务发现,一般常见的注册中心包括 Nacos、Eureka 等,例如开源 SpringCloud Gateway 便支持对接 Nacos/Eureka 两类注册中心。
这类开源特性的痛点是:
一些常见的企业级诉求:
针对这些痛点和诉求,分享一些我们改造时遇到的难点以及经验
在支持不同协议时,对应的服务框架可能已经有了对应的 remoting 层和 discovery 层,我们的选择是仅引入该协议的 remoting 二方包解决协议转换问题,对于 discovery 层,应当自行封装,避免使用对应协议的 discovery 层这个误区,因为回归到网关领域,服务发现和协议转换是对等的模块,抽象 ServiceDiscoveryFIlter 负责服务发现,ProtocolTransferFilter 则负责点对点的协议通信。
在服务发现层,为了适配不同注册中心的模型(推和拉),提供了两个实现 PullServiceRegistry、PushServiceRegistry,这些改造是独立于 spring-cloud-loadbalancer 模块实现的,开源的默认实现存在诸多的限制,例如仅支持拉模型 + 缓存服务列表的方案,实际上推模型能够为网关的服务发现提供更高的实时性。
基本流程:服务发现 serviceName -> n x IP,负载均衡 IP n ->1,协议转换 IP 点对点通信。
这样一套扩展机制可以在有新的协议类型、注册中心、负载均衡算法需要对接时实现快速扩展。
如果仔细阅读过 SpringCloud Gateway 的文档,你会发现,开源对限流熔断的支持是非常有限的,它强依赖一个 Redis 做集群限流,且限流方案是自己实现的,而我们可能会更加信赖 Sentinel 提供的解决方案。事实上,开源 Sentinel 也对 SpringCloud Gateway 提供了一部分开箱即用的能力,使用层面完全没问题,主要是欠缺了一部分可观测性的能力。
在改造中,尤为注意要使用高版本的 Sentinel,即按比例阈值这套模型实现的限流方案,集成 Sentinel 之后,我们按照网关的通用场景提供了两类限流模型:基于慢调用比例的限流熔断和基于响应码比例的限流熔断。借助于 Sentinel 的能力,可惜实现渐进式的恢复。
可观测性体系的建设,可以说是很多开源产品距离企业级使用的距离,SpringCloud Gateway 亦是如此。
网关通常会需要记录三类可观测性指标。
开源 SpringCloud Gateway 集成了 micrometer-registry-prometheus,提供了一个开箱即用的大盘:https://docs.spring.io/spring-cloud-gateway/docs/3.1.8/reference/html/gateway-grafana-dashboard.json,需要更加丰富维度的指标则需要自行埋点。
Trace 方案推荐对接 opentelemetry。
Logging 方案则是 SpringCloud Gateway 开源欠缺的,在实际生产中至少应该打印 accessLog 记录请求信息,按需开启 requestLog 记录请求的 payload 信息和响应体信息,以及与后端服务连接的日志,用于排查一些连接问题。日志采集方案我们的实践是将 accessLog 输出到标准输出中,方便在 K8s 架构下配置采集,或者采用日志 agent 的方案进行文件采集。
除了功能层面的优化与新增,网关的性能也是使用者尤为关注的点。在前文中,我并没有把 SpringCloud Gateway 归为一个性能特别高的网关分类中,主要是基于我们的实践,发现其有不少优化空间。下面的章节我会分享一些基于 SpringCloud Gateway 进行的性能优化。
网关优化道阻且长,为了验证优化效果,建设性能基线不可避免,需要面向 benchmark 进行优化。
一些常用的优化技巧在网关中也同样适用,例如:缓存、懒加载、预分配、算法复杂度优化、CPU 友好操作,减少线程切换。
通过火焰图观测性能可以从宏观角度分析大的性能损耗点:
一个理想的网关火焰图应当是大部分的时间片占用花费在 IO 上,即图中的 netty 相关的损耗,除此之外占用了 CPU 的类,都需要重点关注。通过火焰图,我们也定位到了相当多的性能损耗点,并针对进行了优化。
SpringCloud Gateway 中通过 GlobalFilter、GatewayFilter 对请求进行过滤,在 FilteringWebHandler 中可以看到这段逻辑:
public Mono<Void> handle(ServerWebExchange exchange) {
Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
List<GatewayFilter> gatewayFilters = route.getFilters();
List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
combined.addAll(gatewayFilters);
// TODO: needed or cached?
AnnotationAwareOrderComparator.sort(combined);
return new DefaultGatewayFilterChain(combined).filter(exchange);
}
开源实现在每次请求级别都会重新组装出一个 FilterChain,并进行排序,内存分配和排序会占用 CPU,无疑会导致性能下降,通过注释可以看到 Contributor 自己也意识到了这里的性能问题,但一直没有修复。
一个可行的优化手段是在路由或者策略变更时,触发 FilterChain 的更新,这样请求时 FilterChain 就没必要重新构造了。而观测到这一性能问题,正是通过了火焰图中的 FilteringWebHandler.handle 的占用。
之前的企业级特性章节中,我介绍了配置中心改造的方案,其中提及了开源方案爆炸半径大的问题,可以从下面的代码中,窥见一斑:
public class RouteDefinitionRouteLocator implements RouteLocator {
@Override
public Flux<Route> getRoutes() {
Flux<Route> routes = this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute);
if (!gatewayProperties.isFailOnRouteDefinitionError()) {
// instead of letting error bubble up, continue
routes = routes.onErrorContinue((error, obj) -> {
if (logger.isWarnEnabled()) {
logger.warn("RouteDefinition id " + ((RouteDefinition) obj).getId()
+ " will be ignored. Definition has invalid configs, " + error.getMessage());
}
});
}
return routes.map(route -> {
return route;
});
}
可以见得,SpringCloud Gateway 认为路由配置是一个整体,任意路由的变更,就会导致整个 Route 序列重新构建。并且在默认情况下,如果其中一个路由配置出错了,会导致整个网关路由不可用,除非 isFailOnRouteDefinitionError 被关闭。
我们的改造方案是使用 Map 结构进行改造,配合路由配置的增量推送,实现 Route 的单点更新。
public class DynamicRouteRepository implements Ordered, RouteLocator, ApplicationEventPublisherAware, RouteDefinitionWriter {
private RouteConverter routeConverter;
static class RouteKey implements Ordered {
private String id;
private int order;
...
}
static final Map<RouteKey, Route> ORDERED_ROUTE = new TreeMap<>((o1, o2) -> {
int order1 = o1.order;
int order2 = o2.order;
if (order1 != order2) {
return Integer.compare(order1, order2);
}
return o1.id.compareTo(o2.id);
});
private static final Map<String, Integer> ORDER = new HashMap<>();
public Route getRouteById(String id) {
return ORDERED_ROUTE.get(new RouteKey(id, ORDER.getOrDefault(id, 0)));
}
...
}
这个优化来自于我们一次生产问题的排查,起初我们并没有意识到该问题。问题表现为路由数量非常大时,内存占用的消耗超过了我们的预期,经过 dump 发现,同一份路由的配置内容竟然以 3 种形式常驻于内存中。
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。