【服务治理】基于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.现有的方案是服务实例粒度的灰度,如何实现接口粒度的灰度发布

  

 

 

 


   

  

  

posted @ 2023-02-18 23:28  听风是雨  阅读(1009)  评论(0编辑  收藏  举报
/* 看板娘 */