Spring Cloud + Nacos + 负载均衡器,实现全链路灰度发布的最佳实战

程序员内点事

共 35257字,需浏览 71分钟

 ·

2024-06-21 08:42

概念

灰度发布, 也叫金丝雀发布。是指在黑与白之间,能够平滑过渡的一种发布方式。AB test就是一种灰度发布方式,让一部分用户继续用A,一部分用户开始用B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。

灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度,而我们平常所说的金丝雀部署也就是灰度发布的一种方式。

具体到服务器上,实际操作中还可以做更多控制,譬如说,给最初更新的10台服务器设置较低的权重、控制发送给这10台服务器的请求数,然后逐渐提高权重、增加请求数。一种平滑过渡的思路, 这个控制叫做“流量切分”。

组件版本说明

我们这项目已经练习了两年半了使用的版本不是很新,我这里的Demo也会使用这个版本,有感情了,使用新版本的朋友自己调整一下就行,实现思路是一样的只是这些框架源码可能会有变化。

  • spring-boot: 2.3.12.RELEASE
  • spring-cloud-dependencies: Hoxton.SR12
  • spring-cloud-alibaba-dependencies: 2.2.9.RELEASE

spring-cloud 对应版本关系图

https://blog.csdn.net/weixin_44606481/article/details/131726688

核心组件说明

  • 注册中心: Nacos
  • 网关: SpringCloudGateway
  • 负载均衡器: Ribbon (使用SpringCloudLoadBalancer实现也是类似的)
  • 服务间RPC调用: OpenFeign

灰度发布代码实现

要实现Spring Cloud项目灰度发布技术方案有很多,重点在于服务发现,怎么将灰度流量只请求到灰度服务,这里我们会使用Nacos作为注册中心和配置中心,核心就是利用Nacos的Metadata设置一个version值,在调用下游服务是通过version值来区分要调用那个版本,这里会省略一些流程,文章末尾提供了源码地址需要自提。

代码设计结构

这个是demo项目,结构都按最简单的来。

spring-cloud-gray-example // 父工程
   kerwin-common // 项目公共模块
   kerwin-gateway // 微服务网关
   kerwin-order // 订单模块
      order-app // 订单业务服务
   kerwin-starter // 自定义springboot starter模块
      spring-cloud-starter-kerwin-gray // 灰度发布starter包 (核心代码都在这里)
   kerwin-user // 用户模块
      user-app // 用户业务服务
      user-client // 用户client(Feign和DTO)
核心包spring-cloud-starter-kerwin-gray结构介绍
入口Spring Cloud Gateway实现灰度发布设计(一些基础信息类在下面)

在请求进入网关时开始对是否要请求灰度版本进行判断,通过Spring Cloud Gateway的过滤器实现,在调用下游服务时重写一个Ribbon的负载均衡器实现调用时对灰度状态进行判断。

存取请求灰度标记Holder(业务服务也是使用的这个)

使用ThreadLocal记录每个请求线程的灰度标记,会在前置过滤器中将标记设置到ThreadLocal中。

public class GrayFlagRequestHolder {
    /**
     * 标记是否使用灰度版本
     * 具体描述请查看 {@link com.kerwin.gray.enums.GrayStatusEnum}
     */

    private static final ThreadLocal<GrayStatusEnum> grayFlag = new ThreadLocal<>();
    public static void setGrayTag(final GrayStatusEnum tag) {
        grayFlag.set(tag);
    }
    public static GrayStatusEnum getGrayTag() {
        return grayFlag.get();
    }
    public static void remove() {
        grayFlag.remove();
    }
}

前置过滤器

在前置过滤器中会对请求是否要使用灰度版本进行判断,并且会将灰度状态枚举GrayStatusEnum设置到GrayRequestContextHolder中存储这一个请求的灰度状态枚举,在负载均衡器中会取出灰度状态枚举判断要调用那个版本的服务,同时这里还实现了Ordered 接口会对网关的过滤器进行的排序,这里我们将这个过滤器的排序设置为Ordered.HIGHEST_PRECEDENCE int的最小值,保证这个过滤器最先执行。

public class GrayGatewayBeginFilter implements GlobalFilterOrdered {
    @Autowired
    private GrayGatewayProperties grayGatewayProperties;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        GrayStatusEnum grayStatusEnum = GrayStatusEnum.ALL;
        // 当灰度开关打开时才进行请求头判断
        if (grayGatewayProperties.getEnabled()) {
            grayStatusEnum = GrayStatusEnum.PROD;
            // 判断是否需要调用灰度版本
            if (checkGray(exchange.getRequest())) {
                grayStatusEnum = GrayStatusEnum.GRAY;
            }
        }
        GrayFlagRequestHolder.setGrayTag(grayStatusEnum);
        ServerHttpRequest newRequest = exchange.getRequest().mutate()
                .header(GrayConstant.GRAY_HEADER, grayStatusEnum.getVal())
                .build();
        ServerWebExchange newExchange = exchange.mutate()
                .request(newRequest)
                .build();
        return chain.filter(newExchange);
    }

    /**
     * 校验是否使用灰度版本
     */

    private boolean checkGray(ServerHttpRequest request) {
        if (checkGrayHeadKey(request) || checkGrayIPList(request) || checkGrayCiryList(request) || checkGrayUserNoList(request)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本请求头判断是否需要调用灰度版本
     */

    private boolean checkGrayHeadKey(ServerHttpRequest request) {
        HttpHeaders headers = request.getHeaders();
        if (headers.containsKey(grayGatewayProperties.getGrayHeadKey())) {
            List<String> grayValues = headers.get(grayGatewayProperties.getGrayHeadKey());
            if (!Objects.isNull(grayValues)
                    && grayValues.size() > 0
                    && grayGatewayProperties.getGrayHeadValue().equals(grayValues.get(0))) {
                return true;
            }
        }
        return false;
    }

    /**
     * 校验自定义灰度版本IP数组判断是否需要调用灰度版本
     */

    private boolean checkGrayIPList(ServerHttpRequest request) {
        List<String> grayIPList = grayGatewayProperties.getGrayIPList();
        if (CollectionUtils.isEmpty(grayIPList)) {
            return false;
        }
        String realIP = request.getHeaders().getFirst("X-Real-IP");
        if (realIP == null || realIP.isEmpty()) {
            realIP = request.getRemoteAddress().getAddress().getHostAddress();
        }
        if (realIP != null && CollectionUtils.contains(grayIPList.iterator(), realIP)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本城市数组判断是否需要调用灰度版本
     */

    private boolean checkGrayCiryList(ServerHttpRequest request) {
        List<String> grayCityList = grayGatewayProperties.getGrayCityList();
        if (CollectionUtils.isEmpty(grayCityList)) {
            return false;
        }
        String realIP = request.getHeaders().getFirst("X-Real-IP");
        if (realIP == null || realIP.isEmpty()) {
            realIP = request.getRemoteAddress().getAddress().getHostAddress();
        }
        // 通过IP获取当前城市名称
        // 这里篇幅比较长不具体实现了,想要实现的可以使用ip2region.xdb,这里写死cityName = "本地"
        String cityName = "本地";
        if (cityName != null && CollectionUtils.contains(grayCityList.iterator(), cityName)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
     */

    private boolean checkGrayUserNoList(ServerHttpRequest request) {
        List<String> grayUserNoList = grayGatewayProperties.getGrayUserNoList();
        if (CollectionUtils.isEmpty(grayUserNoList)) {
            return false;
        }
        return false;
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

后置过滤器

后置过滤器是为了在调用完下游业务服务后在响应之前将 GrayFlagRequestHolder 中的 ThreadLocal 清除避免照成内存泄漏。

public class GrayGatewayAfterFilter implements GlobalFilterOrdered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 请求执行完必须要remore当前线程的ThreadLocal
        GrayFlagRequestHolder.remove();
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.LOWEST_PRECEDENCE;
    }
}

全局异常处理器

全局异常处理器是为了处理异常情况下将 GrayFlagRequestHolder 中的 ThreadLocal 清除避免照成内存泄漏,如果在调用下游业务服务时出现了异常就无法进入后置过滤器。

public class GrayGatewayExceptionHandler implements WebExceptionHandlerOrdered {
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        // 请求执行完必须要remore当前线程的ThreadLocal
        GrayFlagRequestHolder.remove();
        ServerHttpResponse response = exchange.getResponse();
        if (ex instanceof ResponseStatusException) {
            // 处理 ResponseStatusException 异常
            ResponseStatusException responseStatusException = (ResponseStatusException) ex;
            response.setStatusCode(responseStatusException.getStatus());
            // 可以根据需要设置响应头等
            return response.setComplete();
        } else {
            // 处理其他异常
            response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            // 可以根据需要设置响应头等
            return response.setComplete();
        }
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

自定义Ribbon负载均衡路由(业务服务也是使用的这个)

「灰度Ribbon负载均衡路由抽象类:」 这里提供了两个获取服务列表的方法,会对GrayFlagRequestHolder 中存储的当前线程灰度状态枚举进行判断,如果枚举值为GrayStatusEnum.ALL则响应全部服务列表不区分版本,如果枚举值为GrayStatusEnum.PROD则返回生产版本的服务列表,如果枚举值为GrayStatusEnum.GRAY则返回灰度版本的服务列表,版本号会在GrayVersionProperties 中配置,通过服务列表中在Nacos的metadata中设置的versionGrayVersionProperties的版本号进行匹配出对应版本的服务列表。

public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule {
    @Autowired
    private GrayVersionProperties grayVersionProperties;

    @Value("${spring.cloud.nacos.discovery.metadata.version}")
    private String metaVersion;

    /**
     * 只有已启动且可访问的服务器,并对灰度标识进行判断
     */

    public List<Server> getReachableServers() {
        ILoadBalancer lb = getLoadBalancer();
        if (lb == null) {
            return new ArrayList<>();
        }
        List<Server> reachableServers = lb.getReachableServers();

        return getGrayServers(reachableServers);
    }

    /**
     * 所有已知的服务器,可访问和不可访问,并对灰度标识进行判断
     */

    public List<Server> getAllServers() {
        ILoadBalancer lb = getLoadBalancer();
        if (lb == null) {
            return new ArrayList<>();
        }
        List<Server> allServers = lb.getAllServers();
        return getGrayServers(allServers);
    }

    /**
     * 获取灰度版本服务列表
     */

    protected List<Server> getGrayServers(List<Server> servers) {
        List<Server> result = new ArrayList<>();
        if (servers == null) {
            return result;
        }
        String currentVersion = metaVersion;
        GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
        if (grayStatusEnum != null) {
            switch (grayStatusEnum) {
                case ALL:
                    return servers;
                case PROD:
                    currentVersion = grayVersionProperties.getProdVersion();
                    break;
                case GRAY:
                    currentVersion = grayVersionProperties.getGrayVersion();
                    break;
            }
        }

        for (Server server : servers) {
            NacosServer nacosServer = (NacosServer) server;
            Map<String, String> metadata = nacosServer.getMetadata();
            String version = metadata.get("version");
            // 判断服务metadata下的version是否于设置的请求版本一致
            if (version != null && version.equals(currentVersion)) {
                result.add(server);
            }
        }
        return result;
    }
}

「自定义轮询算法实现GrayRoundRobinRule:」 代码篇幅太长了这里只截取代码片段,我这里是直接拷贝了Ribbon的轮询算法,将里面获取服务列表的方法换成了自定义AbstractGrayLoadBalancerRule 中的方法,其它算法也可以通过类似的方式实现。

业务服务实现灰度发布设计

自定义SpringMVC请求拦截器

自定义SpringMVC请求拦截器获取上游服务的灰度请求头,如果获取到则设置到GrayFlagRequestHolder 中,之后如果有后续的RPC调用同样的将灰度标记传递下去。

@SuppressWarnings("all")
public class GrayMvcHandlerInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String grayTag = request.getHeader(GrayConstant.GRAY_HEADER);
        // 如果HttpHeader中灰度标记存在,则将灰度标记放到holder中,如果需要就传递下去
        if (grayTag!= null) {
            GrayFlagRequestHolder.setGrayTag(GrayStatusEnum.getByVal(grayTag));
        }
        return true;
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        GrayFlagRequestHolder.remove();
    }
}

自定义OpenFeign请求拦截器

自定义OpenFeign请求拦截器,取出自定义SpringMVC请求拦截器中设置到GrayFlagRequestHolder中的灰度标识,并且放到调用下游服务的请求头中,将灰度标记传递下去。

public class GrayFeignRequestInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        // 如果灰度标记存在,将灰度标记通过HttpHeader传递下去
        GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
        if (grayStatusEnum != null ) {
            template.header(GrayConstant.GRAY_HEADER, Collections.singleton(grayStatusEnum.getVal()));
        }
    }
}
基础信息设计

这里会定义一些基础参数,比如是否开启灰度还有什么请求需要使用灰度版本等,为后续业务做准备。

  • 调用业务服务时设置的灰度统一请求头
public interface GrayConstant {
    /**
     * 灰度统一请求头
     */

    String GRAY_HEADER="gray";
}
  • 灰度版本状态枚举
public enum GrayStatusEnum {
    ALL("ALL","可以调用全部版本的服务"),
    PROD("PROD","只能调用生产版本的服务"),
    GRAY("GRAY","只能调用灰度版本的服务");
    GrayStatusEnum(String val, String desc) {
        this.val = val;
        this.desc = desc;
    }
    private String val;
    private String desc;
    public String getVal() {
        return val;
    }
    public static GrayStatusEnum getByVal(String val){
        if(val == null){
            return null;
        }
        for (GrayStatusEnum value : values()) {
            if(value.val.equals(val)){
                return value;
            }
        }
        return null;
    }
}
  • 网关灰度配置信息类
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.gateway")
public class GrayGatewayProperties {

    /**
     * 灰度开关(如果开启灰度开关则进行灰度逻辑处理,如果关闭则走正常处理逻辑)
     * PS:一般在灰度发布测试完成以后会将线上版本都切换成灰度版本完成全部升级,这时候应该关闭灰度逻辑判断
     */

    private Boolean enabled = false;

    /**
     * 自定义灰度版本请求头 (通过grayHeadValue来匹配请求头中的值如果一致就去调用灰度版本,用于公司测试)
     */

    private String grayHeadKey="gray";

    /**
     * 自定义灰度版本请求头匹配值
     */

    private String grayHeadValue="gray-996";

    /**
     * 使用灰度版本IP数组
     */

    private List<String> grayIPList = new ArrayList<>();

    /**
     * 使用灰度版本城市数组
     */

    private List<String> grayCityList = new ArrayList<>();

    /**
     * 使用灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
     */

    private List<String> grayUserNoList = new ArrayList<>();
}
  • 全局版本配置信息类
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.version")
public class GrayVersionProperties {
    /**
     * 当前线上版本号
     */

    private String prodVersion;

    /**
     * 灰度版本号
     */

    private String grayVersion;
}
  • 全局自动配置类
@Configuration
// 可以通过@ConditionalOnProperty设置是否开启灰度自动配置 默认是不加载的
@ConditionalOnProperty(value = "kerwin.tool.gray.load",havingValue = "true")
@EnableConfigurationProperties(GrayVersionProperties.class)
public class GrayAutoConfiguration 
{
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(value = GlobalFilter.class)
    @EnableConfigurationProperties(GrayGatewayProperties.class)
    static class GrayGatewayFilterAutoConfiguration 
{
        @Bean
        public GrayGatewayBeginFilter grayGatewayBeginFilter() {
            return new GrayGatewayBeginFilter();
        }
        @Bean
        public GrayGatewayAfterFilter grayGatewayAfterFilter() {
            return new GrayGatewayAfterFilter();
        }
        @Bean
        public GrayGatewayExceptionHandler grayGatewayExceptionHandler(){
            return new GrayGatewayExceptionHandler();
        }
    }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(value = WebMvcConfigurer.class)
    static class GrayWebMvcAutoConfiguration 
{
        /**
         * Spring MVC 请求拦截器
         * @return WebMvcConfigurer
         */

        @Bean
        public WebMvcConfigurer webMvcConfigurer() {
            return new WebMvcConfigurer() {
                @Override
                public void addInterceptors(InterceptorRegistry registry) {
                    registry.addInterceptor(new GrayMvcHandlerInterceptor());
                }
            };
        }
    }
    @Configuration
    @ConditionalOnClass(value = RequestInterceptor.class)
    static class GrayFeignInterceptorAutoConfiguration 
{
        /**
         * Feign拦截器
         * @return GrayFeignRequestInterceptor
         */

        @Bean
        public GrayFeignRequestInterceptor grayFeignRequestInterceptor() {
            return new GrayFeignRequestInterceptor();
        }
    }
}

项目运行配置

这里我会启动五个服务,一个网关服务、一个用户服务V1版本、一个订单服务V1版本、一个用户服务V2版本、一个订单服务V2版本,来演示灰度发布效果。

PS:Nacos的命名空间我这里叫spring-cloud-gray-example可以自己创建一个也可以换成自己的命名空间,源码里面配置都是存在的,有问题看源码就行

配置Nacos全局配置文件(common-config.yaml)

所有服务都会使用到这个配置

kerwin:
  tool:
    gray:
      ## 配置是否加载灰度自动配置类,如果不配置那么默认不加载
      load: true
      ## 配置生产版本和灰度版本号
      version:
        prodVersion: V1
        grayVersion: V2

## 配置Ribbon调用user-app和order-app服务时使用我们自定义灰度轮询算法
user-app:
  ribbon:
    NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
  ribbon:
    NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
配置网关Nacos配置文件(gateway-app.yaml)
kerwin:
  tool:
    gray:
      gateway:
        ## 是否开启灰度发布功能
        enabled: true
        ## 自定义灰度版本请求头
        grayHeadKey: gray
        ## 自定义灰度版本请求头匹配值
        grayHeadValue: gray-996
        ## 使用灰度版本IP数组
        grayIPList:
          - '127.0.0.1'
        ## 使用灰度版本城市数组
        grayCityList:
          - 本地
启动网关服务

网关服务启动一个就行,直接Debug启动即可,方便调试源码

启动业务服务V1 和 V2版本(用户服务和订单服务都用这种方式启动)

先直接Debug启动会在IDEA这个位置看到一个对应启动类名称的信息

点击Edit编辑这个启动配置

复制一个对应启动配置作为V2版本,自己将Name改成自己能区分的即可

配置启动参数,第一步点击Modify options 然后第二步将Add VM options勾选上,第三步填写对应服务的启动端口和Nacos的metadata.version,我这里用户服务V1版本配置为-Dserver.port=7201 -Dspring.cloud.nacos.discovery.metadata.version=V1,用户服务V2版本配置为-Dserver.port=7202 -Dspring.cloud.nacos.discovery.metadata.version=V2,订单服务配置类似,配置好后点Apply。

最后启动好的服务信息

灰度效果演示

源码中的user-app提供了一个获取用户信息的接口并且会携带当前服务的端口和版本信息,order-app服务提供了一个获取订单信息的接口,会去远程调用user-app获取订单关联的用户信息,并且也会携带当前服务的端口和版本信息响应。

场景一(关闭灰度开关:不区分调用服务版本)

关闭灰度开关有两个配置可以实现

1、在项目启动之前修改Nacos全局配置文件中的kerwin.tool.gray.load 配置是否加载灰度自动配置类,只要配置不为true就不会加载整个灰度相关类

2、关闭网关灰度开关,修改网关Nacos配置文件中的kerwin.tool.gray.gateway.enabled ,只要配置不为true就不会进行灰度判断。

调用演示

这里调用不一定就是Order服务版本为V1 User服务版本也为V1,也有可能Order服务版本为V1 User服务版本也为V2.

  • 第一次调用,Order服务版本为V1,User服务版本也为V1
  • 第二次调用,Order服务版本为V2,User服务版本也为V2
场景二(开启灰度开关:只调用生产版本)

修改网关Nacos配置文件中的kerwin.tool.gray.gateway.enabled 设置为true,其它灰度IP数组和城市数组配置匹配不上就行,这样怎么调用都是V1版本,因为在GrayVersionProperties版本配置中设置的生产版本就是为V1灰度版本为V2。

场景三(开启灰度开关:通过请求头、ip、城市匹配调用灰度版本)

这里通过请求头测试,携带请求头gray=gray-996访问网关那么流量就会都进入灰度版本V2。

源码

https://gitee.com/kerwin_code/spring-cloud-gray-example

存在问题

1、如果项目中使用到了分布式任务调度那怎么区分灰度版本

这里其实挺好解决的,就拿xxl-job来说,注册不同的执行器就行,在发布灰度版本时注册到灰度版本的执行器即可。

2、如果项目中使用的了MQ我们收发消息怎么控制灰度

这里和解决分布式任务调度思想是一样的灰度版本的服务发送消息的时候投递到另外一个MQ的服务端,就是弄两套MQ服务端,生产的服务使用生产的MQ,灰度发布使用灰度的MQ

3、这里整个实现流程不是很复杂,但也是很没必要,只是提供一种实现方案可以参考

其实通过Nginx + Lua脚本方式直接路由网关,然后给灰度整套服务都使用一个Nacos灰度的命名空间,生产的使用生产的命名空间,这样就能将两套服务都隔离了,分布式任务调度、MQ等配置都可以独立在自己命名空间的配置文件中岂不美哉


浏览 83
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报