🚀微服务项目全自动灰度发布原理与实践

479 阅读16分钟

导读:

哈喽,小伙伴们,我是黑马苗老师。

今天我们来聊一个有趣的话题,微服务项目如何丝滑升级。

没错,现在做技术都讲究丝滑,好用、易扩展、易维护、低成本...这都是丝滑的表现,微服务项目在升级时如果采用简单粗暴的流程:停机--》升级--》启用 势必会影响用户的体验,如果我们做到升级过程用户无感知,这是不是很丝滑?

今天我们聊的灰度发布就是微服务项目升级的过程实现用户无感知,并且整个实现过程无需单独引入第三方中间件,且开发过程简单。

本文案例选自大型项目云岚到家。

概念

灰度发布(也称为灰度升级、金丝雀发布或渐进式部署)是一种在软件开发和部署中使用的策略,它允许您逐步将新功能版本推向用户群。这种方法有助于降低风险,因为它可以确保新版本的稳定性和性能在完全推出之前得到验证。

灰度发布主要是运维人员的工作内容,但是也需要软件开发人员熟知并配合技术支持。

灰度发布的常见步骤包括:

  1. 小范围测试:通过请求入口控制,首先将新版本推送给一小部分用户,通常是从内部员工开始,然后是外部测试人员。
  2. 监控反馈与性能:密切监控这些用户的反馈以及新版本的性能指标,如错误率、延迟等,整个灰度过程新旧版本是并存的。
  3. 逐步扩大范围:如果一切正常,则逐渐增加接收新版本的用户数量,通过更改入口控制将更多的流放行到新版本程序。
  4. 全面发布:当所有测试都成功并通过足够的用户验证后,将新版本全面推向所有用户。

image-20240824150248863.png

谈到灰度发布就不得不说蓝绿发布。

蓝绿发布(Blue-Green Deployment)是一种软件部署策略,它的主要目标是在部署新版本时尽量减少服务中断时间,并提供一个简单且快速的回滚机制。

核心概念:

  • 两套相同的生产环境:

    蓝绿发布涉及两个完全相同的应用环境,通常被称为“蓝色”环境和“绿色”环境。其中一个环境处于活动状态(提供服务),另一个环境则用于部署新版本。

  • 活动与非活动状态:

    在任何时候,只有一个环境是活动的,另一个是非活动的。活动环境接收所有用户请求,而非活动环境用于部署和测试新版本。

工作流程

  1. 初始状态:假设“绿色”环境是活动环境,提供当前版本的服务,而“蓝色”环境是非活动状态,用于部署新版本。
  2. 部署新版本:将新版本部署到“蓝色”环境中。在此阶段,“绿色”环境继续为用户提供服务。
  3. 测试新版本:在“蓝色”环境中进行必要的测试,确保新版本稳定可靠。
  4. 切换负载:如果测试成功,可以通过更改负载均衡器的设置,将所有用户流量切换到“蓝色”环境。这样,“蓝色”环境变成了新的活动环境,而“绿色”环境变为空闲状态。
  5. 回滚机制:如果新版本有问题,可以立即将流量切换回“绿色”环境,恢复到之前的状态,从而实现快速回滚。

各位,清楚蓝绿发布和灰度发布的主要区别了吗?

蓝绿发布 (Blue/Green Deployment)

  • 并行环境:蓝绿发布涉及到两个并行的生产环境,通常一个是当前正在使用的“蓝色”环境,另一个是准备好的“绿色”环境用于部署新版本。
  • 切换机制:一旦新版本在绿色环境中部署完毕并通过测试,流量会被切换到新的绿色环境,而旧的蓝色环境则被标记为备用或被废弃。
  • 回滚简单:如果新版本有问题,可以立即切换回旧版本,因为旧版本的环境一直保持着。
  • 适用场景:适用于对稳定性要求极高、需要快速回滚的场景

灰度发布 (Canary Release / Gradual Rollout)

  • 逐步部署:灰度发布指的是向一小部分用户推出新版本,通常是通过版本间的流量划分实现。
  • 监控反馈:通过观察这部分用户的使用情况和反馈来决定是否继续推广新版本或是回滚。
  • 灵活性高:可以根据实际情况调整新版本的推广速度,甚至可以选择暂停推广。
  • 适用场景:适用于需要逐步验证市场反应、用户体验等场景。

总的来说,蓝绿发布更注重系统的稳定性和快速回滚的能力,而灰度发布则更关注于对新版本的市场反馈和逐步推广的风险管理。选择哪一种发布策略取决于业务的需求、技术条件以及对新版本上线的自信程度。

好啦,下面开始今天的主角:灰度发布。

灰度发布流程

在实际操作中,灰度发布需要一些技术工具的支持,例如K8S、配置管理、流量控制和实时监控系统等,此外,还需要一个良好的回滚计划,以便在出现问题时能够迅速恢复到旧版本。

今天我们用微服务项目的标配工具: 网关(Spring Cloud Gateway)加 配置与服务中心(阿里 Nacos )来实现灰度发布,整个实现过程无须单独引入第三方工具。

下边我们用一个案例来讲解整个灰度发布流程,如下图:

通过网关请求购物车服务,购物车服务可能会远程调用商品服务,即购物车服务依赖商品服务。

购物车服务old表示旧版本实例,购物车服务new表示新版本即灰度实例,最终我们要实现的就是灰度实例和旧版本实例并存的画面,并且最终将服务实例完全升级灰度版本。

image-20240824160332241.png

最终我们要实现的流程如下:

  1. 用户访问首先到达网关,网关根据请求头区分出是灰度用户还是正常用户,比如:根据IP地址区分,根据Token区分等。

  2. 对于灰度用户的来源,网关将请求转发到灰度实例,正常用户来源将转发到旧版本实例。

  3. 如果请求转发到了灰度实例,灰度实例发生远程调用时要能够调用同版本的服务实例。

    举例:请求到达购物车服务new,购物车服务依赖商品服务,发生远程调用购物车服务new要调用同版本的商品服务new,不允许出现跨版本远程调用,比如购物车服务new调用了商品服务old,或购物车服务old调用了商品服务new,这样会出现由于版本区别导致调用报错。

    那么本次灰度发布需要将购物车服务new版本和商品服务new版本同时发布,如果本次升级只需要升级购物车服务new则灰度发布只需要发布购物车服务new即可。

  4. 如果请求转发到了旧版本实例,旧版本实例发生远程调用时要能够调用同版本的服务实例。

  5. 观察灰度版本实例运行正常,通过Nacos将一部分旧版本实例下线并进行升级,升级过程中另一个旧版本和灰度版本仍然同时提供服务。

  6. 最终实现全部服务实例升级,如下图:

image-20240824160444674.png

思路分析

根据灰度发布流程我们分析要做的工作:

  1. 自定义网关负载均衡器

    需求:根据请求头区分是正常用户还是灰度用户,灰度用户转发到灰度实例,正常用户转发到旧版本实例。

    分析:

    1)前期可以给灰度版本实例分少一点的流量 ,或设定某些用户、某些地区使用灰度版本,我们可以实现网关在路由时解析HTTP请求头中的的IP地址,如果IP在灰度IP列表中则转发到灰度服务实例,否则转发到旧版本实例,也可以根据IP获取归属地从而指定使用灰度版本的地区。

    2)网关负载均衡算法默认为RoundRobinLoadBalancer,它实现了ReactorServiceInstanceLoadBalancer接口,我们可以实现ReactorServiceInstanceLoadBalancer接口自定义方法,实现逻辑是解析请求头中的IP地址,判断是否在灰度Ip列表中,如果在通过RoundRobinLoadBalancer从灰度服务实例选择一个进行路径转发。

    3)如何找到灰度服务实例呢?

    通过服务元数据去实现,最终网关会从Nacos获取所有的目标服务的实例,我们可以根据服务元数据去找到灰度服务实例。

    在Nacos服务列表中可以查看服务元数据,如下图:

image-20240824164307297.png ​ 所有灰度服务实例元数据中version=new。

​ 设置服务元数据也非常简单,可以通过nacos设置,也可以在微服务的bootstrap.yml文件中设置。

  1. 更改微服务远程调用负载均衡算法

    需求:只允许同版本的服务实例之间进行远程调用,即:灰度实例只能调用其它灰度服务实例,旧版本服务实例只允许调用旧版本服务实例。

    分析:

    微服务远程调用与网关使用相同的负载均衡器,所以上边实现ReactorServiceInstanceLoadBalancer接口的负载均衡器可以用于微服务远程调用,只不过需要区分当前执行负载均衡器的是网关还是微服务,实现这个也很简单,我们在配置文件中增加一个配置项即可,在网关中配置为gateway,除了gateway都是微服务。

自定义网关负载均衡器

灰度配置

在nacos的网关配置文件中配置如下:

  hm:
      gray:
        ## 是否开启灰度发布功能
        enabled: false
        ## 用于匹配灰度服务实例
        grayKey: version
        ## 用于匹配灰度服务实例
        grayValue: new
        ## 执行灰度发布的服务名
        grayService: gateway
        ## 使用灰度版本IP数组
        grayIPList:
          - 'localhost'
          - '192.168.101.1'
        ## 使用灰度版本城市数组
        grayCityList:
          - zhengzhou
          - beijing

enabled:开启了此开关表示当前需要灰度发布,关闭此开关表示当前为正常流程,无需处理灰度发布。

grayKey,grayValue:这两个属性从Nacos中查询所有灰度服务实例,这两个属性是一个key一个value,在灰度服务实例的metadata(元数据)中指定此key/value为:version:new。

grayService:当前执行灰度发布的微服务名,用于在执行负载均衡器区分是网关还是普通微服务,网关就是网关的服务名,购物车服务则填写购物车服务的服务名。

grayIPList:使用灰度版本的IP数组,根据请求头获取客户端ip地址,如果在此数组中则表示该请求需要转发到灰度服务实例。

grayCityList:使用灰度版本的城市名称数组,根据请求头获取客户端ip地址,判断ip地址所属的地区,如果地区在该数组中表示要转发到灰度服务实例。

定义此配置信息对应的类:

由于微服务远程调用和网关路由使用同一个负载均衡器,所以我们把它定义在common模块以便公用。

package com.hmall.common.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

/**
 * @author Mr.M
 * @version 1.0
 * @description 灰度发布配置项
 * @date 2024/8/12 10:01
 */
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("hm.gray")
public class GrayProperties {
    /**
     * 灰度开关,开启灰度开关走灰度判断逻辑,关闭则不再走灰度流程,灰度发布全部完成后关闭灰度开关
     */
    private Boolean enabled = false;

    /**
     * 自定义灰度版本key (与服务实例中的metadata中的key匹配)
     */
    private String grayKey="version";

    /**
     * 自定义灰度版本key对应的值(与服务实例中的metadata中的key对应的值匹配)
     */
    private String grayValue="new";

    /**
     * 灰度版本所在服务名称
     */
    private String grayService;
    /**
     * 使用灰度版本IP数组
     */
    private List<String> grayIPList = new ArrayList<>();

    /**
     * 使用灰度版本城市数组
     */
    private List<String> grayCityList = new ArrayList<>();

    /**
     * 使用灰度版本用户编号数组
     */
    private List<String> grayUserIdList = new ArrayList<>();
}

负载均衡器核心代码

下边是自定义负载均衡器的核心代码:

阅读负载均衡器中的核心代码:

private Response<ServiceInstance> getInstanceResponse(Request request, List<ServiceInstance> instances) {
    if (instances.isEmpty()) {
        if (log.isWarnEnabled()) {
            log.warn("No servers available for service: " + serviceId);
        }
        return new EmptyResponse();
    }
    //灰度开关
    Boolean enabled = grayProperties.getEnabled();
    //不走灰度流程
    if (!enabled) {
        //进入网关
        if(grayProperties.getGrayService().equals("gateway")){
            //走原有的RoundRobinLoadBalancer负载均衡器
            return this.getRoundRobinInstance(instances);
        }
        //进入普通微服务,微服务远程调用,获取没有匹配version:new的实例即旧版本实例
        List<ServiceInstance> newInstances = instances.stream().filter(instance -> !Objects.equals(instance.getMetadata().get(grayProperties.getGrayKey()), grayProperties.getGrayValue())).collect(Collectors.toList());
        if(!newInstances.isEmpty()){
            return this.getRoundRobinInstance(newInstances);
        }
    }
    //下边为灰度流程
    DefaultRequest<RequestDataContext> defaultRequest = Convert.convert(new TypeReference<DefaultRequest<RequestDataContext>>() {
    }, request);
    RequestDataContext context = defaultRequest.getContext();
    RequestData clientRequest = context.getClientRequest();
    HttpHeaders headers = clientRequest.getHeaders();
    //获取ip地址
    String ipAddressFromHttpHeaders = IpUtils.getIpAddressFromHttpHeaders(headers);
    //ip地址在灰度ip列表中则挑选instances,挑选metadate中version为new的实例,即灰度实例
       if (grayProperties.getGrayIPList().contains(ipAddressFromHttpHeaders)) {
            //获取匹配metadate的实例即灰度列表
            List<ServiceInstance> newInstances = getServiceInstances(instances);
            if(!newInstances.isEmpty()){
                return this.getRoundRobinInstance(newInstances);
            }
        }
        //这里用于微服务远程调用的情况
        if(grayProperties.getEnabled() && grayProperties.getGrayService()!=null && !grayProperties.getGrayService().equals("gateway")){
            //获取匹配metadate的实例即灰度列表
            List<ServiceInstance> newInstances = getServiceInstances(instances);
            if(!newInstances.isEmpty()){
                return this.getRoundRobinInstance(newInstances);
            }
        }
        return this.getRoundRobinInstance(instances);

    }

    private List<ServiceInstance> getServiceInstances(List<ServiceInstance> instances) {
        List<ServiceInstance> newInstances = instances.stream().filter(instance -> Objects.equals(instance.getMetadata().get(grayProperties.getGrayKey()), grayProperties.getGrayValue())).collect(Collectors.toList());
        return newInstances;
    }

上线灰度服务实例

灰度服务实例即新版本服务实例,这里我们以查询我的购物车接口为例,假设这里查询我的购物车接口升级了,涉及升级的微服务还有商品服务,这里要灰度发布的服务是购物车服务和商品服务。

下边我们将购物车服务实例和商品服务实例上线,最终我们测试整个灰度发布的过程。

首先编写配置文件,灰度实例即新版本实例,要用与新版本配套的配置文件,这里我们在创建文件时通过group指定版本号。

我们用配置文件克隆的方法创建新版本的配置文件:

进入服务列表:

选择要克隆的配置文件,点击“克隆”

image-20240824170104931.png 打开克隆页面

image-20240824170142865.png 仍然选择原来的命名空间,group填写v1.1,表示一个新版本,dataid与原配置文件一致。

克隆成功新版本的配置文件自动生成:

image-20240824170220800.png 点击“编辑”在配置文件中加入 如下配置,表示使用该配置文件的服务要执行灰度发布。

hm:
  gray:
    ## 是否开启灰度发布功能
    enabled: true
    grayKey: version
    grayValue: new
    grayService: cart-service

旧版本服务要配置enabled:false。

使用同样的方法克隆item-service-local.yaml。

另外,新版本的购物车服务和商品服务仍然需要依赖shared-jdbc.yaml、shared-log.yaml等公用配置文件,所以这里也对这几个公用配置文件进行克隆,注意:公用 配置文件中不用添加gray相关的配置。

新版本配置文件添加成功,如下:

image-20240824170259000.png 接下来启动新版本服务实例:

在购物车服务和商品服务的bootstrap.yml中添加如下内容:

image-20240824170332568.png

这里每个服务要启动多个实例,多个实例中有新版本实例有旧版本实例。

这里编辑新版本实例的启动配置:GROUP_NAME=v1.1,METADATA_VERSION=new

商品服务:

image-20240824170420986.png 购物车:

image-20240824170447798.png 旧版本实例启动配置中不用配置GROUP_NAME=v1.1,METADATA_VERSION=new

分别启动购物车服务(2个实例),商品服务(2个实例),启动成功查看服务列表,可以看出购物车服务和商品服务分别有2个实例。

image-20240824170707418.png 分别点击“详情”查看服务实例信息:

从服务详细可以看出旧版本和灰度版本的服务实例已成功启动。

商品服务:

image-20240824170745230.png 购物车服务:

image-20240824170807357.png

灰度发布测试

启动网关、用户服务,购物车服务、商品服务,服务列表如下:

image-20240824171110349.png 把购物车服务(2个实例)和商品服务(2个实例)的控制台日志清空,方便稍后观察日志。

  1. 首先测试请求灰度实例:

由于在网关的gray配置中灰度ip列表有localhost,这里访问http://localhost:18080/cart.html,会请求灰度实例,观察灰度实例的控制台日志,如果输出数据库查询的日志说明此实例被访问,观察旧版本实例的控制台应该没有打出任何信息。

  1. 下边测试旧版本实例:

访问:http://127.0.0.1:18080/cart.html 观察旧版本实例的控制台日志,如果输出数据库查询的日志说明此实例被访问,观察新版本实例的控制台应该没有打出任何信息。

  1. 下边断点跟踪自定义负载均衡器:

首先网关进行路由时会调用负载均衡器,这时根据ip地址判断是走新版本还是旧版本。

当购物车远程调用商品服务时也会执行负载均衡器,如下代码:

对于当前执行灰度发布的微服务会配置gray.grayService,这里通过if判断如果不是网关表示当前是普通微服务服务,此时远程调用时过滤出新版本的服务列表。

//这里用于微服务远程调用的情况
if(grayProperties.getEnabled() && grayProperties.getGrayService()!=null && !grayProperties.getGrayService().equals("gateway")){
    //获取匹配version=new的实例即灰度列表
    List<ServiceInstance> newInstances = instances.stream().filter(instance -> Objects.equals(instance.getMetadata().get(grayProperties.getGrayKey()), grayProperties.getGrayValue())).collect(Collectors.toList());
    if(!newInstances.isEmpty()){
        return this.getRoundRobinInstance(newInstances);
    }
}

如果没有执行灰度发布的微服务即那些旧版本的微服务,进行远程调用执行下边的代码:

//灰度开关
Boolean enabled = grayProperties.getEnabled();
//不走灰度流程
if (!enabled) {
    //进入网关
    if(grayProperties.getGrayService().equals("gateway")){
        return this.getRoundRobinInstance(instances);
    }
    //不执行灰度发布的微服务进行远程调用,获取没有匹配version=new的实例即旧版本实例
    List<ServiceInstance> newInstances = instances.stream().filter(instance -> !Objects.equals(instance.getMetadata().get(grayProperties.getGrayKey()), grayProperties.getGrayValue())).collect(Collectors.toList());
    if(!newInstances.isEmpty()){
        return this.getRoundRobinInstance(newInstances);
    }
}
  1. 接下来下线一部分旧版本实例,进行升级,直到最终全部服务实例升级完毕。

image-20240824171729793.png 请小伙伴们自行测试吧...

总结

到这里整个灰度发布的流程我们就介绍完了,下边划重点了:

  1. 学到了如何自定义负载均衡器。

    实现ReactorServiceInstanceLoadBalancer接口完成。

  2. 如何利于服务元数据区分不同的服务实例

    通过Nacos手动设置或通过bootstrap.yml配置文件设置。

详细的代码可以关注“黑马程序员-研究院”公众号获取。

如果你喜欢这篇文章,别忘了点赞、收藏、转发,分享给更多需要的小伙伴。