【服务治理】基于SpringCloudAlibaba微服务组件的灰度发布设计(一)
背景
灰度发布是微服务架构中非常重要的一环,也是服务治理不可缺少的一项能力,同样的,随着敏捷开发的发展与成熟,开发的速度越来越快,迭代的周期越来越短,在频繁的需求开发迭代过程中,为了保障服务的上线稳定和产品质量,产品具备的灰度的能力就显得尤为重要
借此机会,整理基于SpringCloudAlibaba微服务组件的灰度设计和可落地的具体方案,以及在此过程中的个人的一些思考
灰度发布
这里借助百度百科,简单说明关于灰度发布的定义:
灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。
项目架构
项目主要以SpringCloud微服务组件为主,主要包括,SpringCloudGateWay,Nacos,OpenFeign,Ribbon 部分依赖及其版本号如下
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR3</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.6.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>0.9.0.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.2.6.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> <version>2.2.2-RELEASE</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2.2.1.RELEASE</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2.2.1.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> <version>2.2.2.RELEASE</version> </dependency>
微服务架构灰度发布的问题点
由于后端服务拆分之后,要实现全链路的灰度发布,这里整理了一些在实设计灰度发布过程中的问题点和思考
问题1.入口流量的路由选择?
这个问题主要是在于如何将入口流量分流,以及分流的位置选择
流量分流:
如果灰度流量,需要在入口进行路由到下游的对应灰度服务
如果非灰度流量,需要在入口进行路由到下游的非灰度服务
分流位置的选择: 目前可选择的位置大致分为两类
流量网关:Nginx,Kong
API网关: SpringCloudGateWay,Zuul,
问题2.微服务之间流量的路由选择?
由于SpringCloud组件的灰度仅是在逻辑上将灰度流量和非灰度流量进行隔离,因此在微服务之间进行远程调用时,可能出现下述情况,
调用链上游:微服务A,调用链下游:微服务B
微服务A(正常),微服务A(灰度)
微服务B(正常),微服务B(灰度)
对于A,D向的流量调度,这是微服务灰度发布过程中最基础的要求,但是对于B,C向的流量调度,是否合理,这取决于是否同时灰度服务A和服务B
即
首先对于C向流量的选择
在同时灰度服务A和服务B时,需要完成D向流量的调度,且不允许C向流量的调度
在仅灰度服务A时,则允许C向流量的调度
其次对于B向流量的选择
在灰度服务B时,由于微服务B的相比于服务A,位于请求流量的下游,常用的做法是,在业务逻辑中保证微服务B(灰度)对于上流流量请求的兼容
问题3.灰度服务实例如何标记?
是微服务注册中心和配置中心使用的Nacos组件,这里需要将灰度服务实例进行标记,常用的做法是对微服务服务实例指定元数据(metadata)
基于Nacos这里衍生出有两种方案
方案1:基于配置中心的配置分组,指定灰度服务配置的微服务元数据标记
优点:1.服务重启后,配置仍然存在
2.无需一个一个实例配置元数据信息
缺点:1.需要增加额外的配置
2.灰度元数据配置是基于服务级别的配置
在GRAY分组对应的配置文件中配置了灰度服务对应的元数据信息
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
metadata:
VERSION: 9999
方案2:基于注册中心的元数据配置,指定灰度实例的微服务元数据标记
优点:1.无需借助增加额外的配置,无需借助配置中心的能力
2.元数据配置粒度比较细,基于实例级别的配置
缺点:1.服务重启后,配置消失
2.需要在注册中心,每一个灰度实例都要进行配置
网关入口流量的灰度
SpringCloudGateway可以整合Ribbon或者SpringCloudLoadBalancer作为负载均衡器,两者的区别在于
Ribbon进行负责均衡远程调用时,线程是阻塞的,
SpringCloudLoadBalancer进行负载均衡远程调用时,线程是非阻塞的 (后期可优化)
考虑到现有的架构中鉴权认证使用的是Ribbon做远程调用,这里仍然使用Ribbon作为网关的负载均衡器
1.网关增加灰度路由过滤器
增加自定义灰度过滤器的目的在于:
SpringCloudGateway网关作为流量入口,需要在网关中对入口流量进行分流,例如灰度流量和正常流量,此时是借助Gateway网关的断言工厂来实现灰度路由
Gateway中灰度路由配置参考
#灰度流量路由
- id: gray-service
uri: grayLb://service #uri 灰度服务以grayLb关键词作为前缀,在Gateway过滤器中进行处理
predicates:
- Path=/service/** #基于路径断言
- Header=version, 10000 #基于请求头断言
- Header=appId, xxx #基于请求头断言
- RemoteAddr=111.175.xx.14 #基于Host断言
#正常流量路由
- id: service
uri: lb://service
predicates:
- Path=/service/**
filters:
- StripPrefix=1
/** * @author Sam.yang * @since 2023/2/23 13:11 */ @Slf4j @Component public class GatewayLoadBalancerClientFilter extends LoadBalancerClientFilter { public GatewayLoadBalancerClientFilter(LoadBalancerClient loadBalancer, LoadBalancerProperties properties) { super(loadBalancer, properties); } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { URI uri = (URI) exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); String schemePrefix = (String) exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR); if (ObjectUtils.isEmpty(uri)) { return chain.filter(exchange); } ServerWebExchangeUtils.addOriginalRequestUrl(exchange, uri); ServiceInstance serviceInstance = this.choose(exchange); if (serviceInstance == null) { throw NotFoundException.create(true, "Unable to find instance for " + uri.getHost()); } URI uri0 = exchange.getRequest().getURI(); String overrideScheme = serviceInstance.isSecure() ? "https" : "http"; if (schemePrefix != null) { overrideScheme = uri.getScheme(); } URI requestUrl = this.loadBalancer.reconstructURI(new DelegatingServiceInstance(serviceInstance, overrideScheme), uri); if (log.isTraceEnabled()) { log.trace("负载均衡获取请求地址:{}", requestUrl); } exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, requestUrl); return chain.filter(exchange); } @Override protected ServiceInstance choose(ServerWebExchange exchange) { URI uri = (URI) exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); String schemePrefix = (String) exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR); if (this.loadBalancer instanceof RibbonLoadBalancerClient) { RibbonLoadBalancerClient client = (RibbonLoadBalancerClient) this.loadBalancer; String serviceId = ((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost(); HttpHeaders headers = exchange.getRequest().getHeaders(); String version = headers.getFirst("version"); GrayscaleProperties build = GrayscaleProperties.builder() .version(version).uri(uri).schemePrefix(schemePrefix).serverName(serviceId) .build(); return client.choose(serviceId, build); } return super.choose(exchange); } }
2.重写Ribbon路由规则
/** * @author Sam.yang * @since 2023/2/23 12:57 */ @Slf4j public class GateWayLoadBalancerRule extends AbstractLoadBalancerRule { @Autowired private NacosDiscoveryProperties nacosDiscoveryProperties; @Override public void initWithNiwsConfig(IClientConfig iClientConfig) { } @Override public Server choose(Object key) { try { if (ObjectUtils.isEmpty(key)) { DynamicServerListLoadBalancer loadBalancer = (DynamicServerListLoadBalancer) getLoadBalancer(); String name = loadBalancer.getName(); NamingService namingService = nacosDiscoveryProperties.namingServiceInstance(); List<Instance> instances = namingService.selectInstances(name, true); if (CollectionUtils.isEmpty(instances)) { log.warn("服务实例不存在:{}", name); return null; } Instance instance = ExtendBalancer.getHostByRandomWeight2(instances); log.debug("执行正常流量实例调度信息:{}", JSON.toJSONString(instance)); return new NacosServer(instance); } GrayscaleProperties grayscaleProp = (GrayscaleProperties) key; String version = grayscaleProp.getVersion(); String serverName = grayscaleProp.getServerName(); //服务Id String schemePrefix = grayscaleProp.getSchemePrefix(); //路由前缀lb或者grayLb 这里grayLb标识需要灰度的服务--参考网关配置 URI uri = grayscaleProp.getUri(); String clusterName = this.nacosDiscoveryProperties.getClusterName(); NamingService namingService = this.nacosDiscoveryProperties.namingServiceInstance(); List<Instance> instances = namingService.selectInstances(serverName, true); if (CollectionUtils.isEmpty(instances)) { log.warn("服务实例不存在:{}", serverName); return null; } if (ObjectUtils.isEmpty(key)) { Instance instance = ExtendBalancer.getHostByRandomWeight2(instances); return new NacosServer(instance); } if (StringUtils.isEmpty(version)) { Instance instance = ExtendBalancer.getHostByRandomWeight2(instances); return new NacosServer(instance); } if (uri != null && ("grayLb".equals(uri.getScheme()) || "grayLb".equals(schemePrefix))) { List<Instance> toChooseInstances = instances.stream() .filter(instance -> version.equals(instance.getMetadata().get("version"))) .collect(Collectors.toList()); if (CollectionUtils.isEmpty(toChooseInstances)) { Instance instance = ExtendBalancer.getHostByRandomWeight2(instances); return new NacosServer(instance); } Instance instance = ExtendBalancer.getHostByRandomWeight2(toChooseInstances); log.debug("执行灰度负载均衡调用,实例信息:{}", instance); return new NacosServer(instance); } else { Instance instance = ExtendBalancer.getHostByRandomWeight2(instances); return new NacosServer(instance); } } catch (Exception var9) { log.warn("NacosRule error", var9); return null; } } }
微服务之间流量的灰度
SpringCloud微服务之间的服务调用主要通过OpenFeign作为声明式客户端,而OpenFeign又整合了Ribbon作为负载均衡器,因此需要我们在进行服务间流量调用时,获取到被调服务中有标记指定元数据信息节点进行调用,这里涉及关键要素
即是需要确认当前调用实例是否为灰度实例,目前有两种方案
方案1:通过在微服务中设置上下文,将请求头的metadata相关的信息解析到上下文中,从上下文中获取当前实例的灰度信息(这里以version作为版本号)
方案2:通过获取当前服务实例在注册中心中的metadata数据
这里方案1,有个明显的问题,就是当OpenFeign的调用逻辑在多线程环境下,无法获取到线程上下文中的版本信息,这里选择方案2
/** * 自定义负载规则 * */ public class NnLoadBalancerRule extends AbstractLoadBalancerRule { private static final Logger LOGGER = LoggerFactory.getLogger(NnLoadBalancerRule.class); @Autowired private NacosDiscoveryProperties nacosDiscoveryProperties; @Autowired private DiscoveryClient discoveryClient; @Override public Server choose(Object key) { List<Instance> allInstances = null; try { LOGGER.debug("开始执行负载均衡服务调用"); String clusterName = this.nacosDiscoveryProperties.getClusterName(); DynamicServerListLoadBalancer loadBalancer = (DynamicServerListLoadBalancer) getLoadBalancer(); String name = loadBalancer.getName(); NamingService namingService = nacosDiscoveryProperties.namingServiceInstance(); //Map<String, String> consumerMetaMap = nacosDiscoveryProperties.getMetadata(); //LOGGER.debug("服务调用方实例元数据信息:{}", consumerMetaMap); //获取所有实例 allInstances = namingService.selectInstances(name, true); LOGGER.debug("服务提供方实例信息列表:{}", JSON.toJSONString(allInstances)); if (CollectionUtils.isEmpty(allInstances)) { LOGGER.error("服务提供方实例信息不存在 InstantName:{}", name); return null; }
//获取当前服务实例的元数据信息 List<Instance> instances = namingService.selectInstances(nacosDiscoveryProperties.getService(), true); LOGGER.debug("服务调用方实例信息列表:{}", JSON.toJSONString(instances)); if (CollectionUtils.isEmpty(instances)) { return new NacosServer(ExtendBalancer.getHostByRandomWeight2(allInstances)); } String ip = nacosDiscoveryProperties.getIp(); int port = nacosDiscoveryProperties.getPort(); Optional<Instance> optional = instances.stream().filter(instance -> ip.equals(instance.getIp()) && port == (instance.getPort())).findFirst(); if (!optional.isPresent()) {
//如果当前实例不存在调用方灰度列表中,则随机选择调用方实例 return new NacosServer(ExtendBalancer.getHostByRandomWeight2(allInstances)); } Map<String, String> consumerMetaMap = optional.get().getMetadata(); List<Instance> grayInstances = new ArrayList<>(); List<Instance> noneGrayInstances = new ArrayList<>(); //当前选择的实例 Instance toBeChooseInstance; if (StringUtils.isNoneBlank(clusterName)) { for (Instance instance : allInstances) { Map<String, String> metadata = instance.getMetadata(); if (!consumerMetaMap.containsKey("version") || !metadata.containsKey("version")) { //调用方 或 被调方 任意一方不包含灰度标记 则进行普通路由 noneGrayInstances.add(instance); } else if (consumerMetaMap.get("version").trim().equalsIgnoreCase(metadata.get("version").trim())) { //调用方和被调方灰度标记相同 grayInstances.add(instance); } else if (!StringUtils.isBlank(metadata.get("version"))) { //被调方不包含灰度标记 noneGrayInstances.add(instance); } } } LOGGER.debug("当前灰度实例信息:{}", JSON.toJSONString(grayInstances)); LOGGER.debug("当前非灰度实例信息:{}", JSON.toJSONString(noneGrayInstances)); if (grayInstances.size() > 0) { toBeChooseInstance = ExtendBalancer.getHostByRandomWeight2(grayInstances); LOGGER.debug("执行灰度实例调用,灰度实例信息:{}", JSON.toJSONString(toBeChooseInstance)); return new NacosServer(toBeChooseInstance); } if (noneGrayInstances.size() > 0) { toBeChooseInstance = ExtendBalancer.getHostByRandomWeight2(noneGrayInstances); } else { toBeChooseInstance = ExtendBalancer.getHostByRandomWeight2(allInstances); } return new NacosServer(toBeChooseInstance); } catch (Exception e) { LOGGER.warn("NacosRule error", e); if (!CollectionUtils.isEmpty(allInstances)) { return new NacosServer(ExtendBalancer.getHostByRandomWeight2(allInstances)); } return null; } } @Override public void initWithNiwsConfig(IClientConfig clientConfig) { } }
服务中增加灰度配置文件
bootstrap-gray.yml
spring:
application:
name: demo
main:
allow-bean-definition-overriding: true
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
file-extension: yaml
namespace: 999
shared-dataids: demo-param.yaml
refreshable-dataids: demo-param.yaml
group: GRAY //这里表示读取配置中心,GRAY分组下的配置信息
注意:如果正常服务实例和灰度服务实例要使用公共的配置,建议使用shared-dataids, shared-dataids,默认会读取DEFAULT_GROUP下的配置文件,减少了多余的配置
spring.cloud.nacos.config.shared-configs[x] 会读取到指定配置分组下的配置文件,虽然建议使用该配置,但是配置会比较繁琐
思考
1.SpringCloud生态虽然提供基于组件的灰度能力,但是无法开箱即用,需重写负载均衡规则后,但是当服务跨集群调用时,负载均衡和灰度实例的选择应该怎样做才能更优雅?
2.借助K8s平台,如何构建自动化的灰度发布能力,减少手动发布出错的概率
3.现有的方案是服务实例粒度的灰度,如何实现接口粒度的灰度发布