灰度发布实战

1. 概念

灰度发布(Gray Release,又称金丝雀发布)是一种渐进式的软件发布策略,核心思想是逐步将新版本服务推送给小范围用户群体,验证稳定后再扩大发布范围。

2. 组件版本说明

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

spring-cloud 对应版本关系图

3. 核心组件说明

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

4. 灰度发布代码实现

要实现Spring Cloud 项目灰度发布技术方案有很多,重点在于服务发现,怎么将灰度流量只请求到灰度服务,这里我们会使用Nacos作为注册中心和配置中心,核心就是利用NacosMetadata设置一个version值,在调用下游服务时是通过version来区分要调用哪个版本。

image-20250307210441139

代码设计结构

1
2
3
4
5
6
7
8
9
10
spring-cloud-gray-example // 父工程
   common // 项目公共模块
   gateway // 微服务网关
   order // 订单模块
      order-app // 订单业务服务
   starter // 自定义springboot starter模块
      spring-cloud-starter-gray // 灰度发布starter包 (核心代码都在这里)
   user // 用户模块
      user-app // 用户业务服务
      user-client // 用户client(Feign和DTO)

核心包 spring-cloud-starter-gray 机构介绍

image-20250309105732663

入口 Gateway 实现灰度发布设计

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

存取请求灰度标记 Holder

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class GrayReleaseContextHolder {

private static final TransmittableThreadLocal<GrayStatusEnum> CONTEXT = new TransmittableThreadLocal<>();

public static void setGrayTag(final GrayStatusEnum tag) {
CONTEXT.set(tag);
}

public static GrayStatusEnum getGrayTag() {
return CONTEXT.get();
}

public static void remove() {
CONTEXT.remove();
}
}

前置过滤器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
public class GrayGatewayBeginFilter implements GlobalFilter, Ordered {
@Resource
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;
}
}
GrayReleaseContextHolder.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;
}
}

后置过滤器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class GrayGatewayAfterFilter implements GlobalFilter, Ordered {

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

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

全局异常处理器

全局异常处理器是为了处理在异常情况下无法进入后置过滤器,未将TransmittableThreadLocal清除导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class GrayGatewayExceptionHandler implements WebExceptionHandler, Ordered {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
// 请求执行完必须要remore当前线程的ThreadLocal
GrayReleaseContextHolder.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 负载均衡路由抽象类」 这里提供了两个获取服务列表的方法,会对GrayReleaseContextHolder 中存储的当前线程灰度状态枚举进行判断,如果枚举值为GrayStatusEnum.ALL则响应全部服务列表,不区分版本,如果枚举值为GrayStatusEnum.PROD则返回生产版本的服务列表,如果枚举值为GrayStatusEnum.GRAY则返回灰度版本的服务列表,版本号会在GrayVersionProperties 中配置,通过服务列表中在 Nacos 的 metadata 中设置的versionGrayVersionProperties的版本号进行匹配出对应版本的服务列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule {
@Resource
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 = GrayReleaseContextHolder.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 中的方法,其它算法也可以通过类似的方式实现。

image-20250309111113049

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

自定义 SpringMVC 请求拦截器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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) {
GrayReleaseContextHolder.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 {
GrayReleaseContextHolder.remove();
}
}

自定义 OpenFeign 请求拦截器

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

1
2
3
4
5
6
7
8
9
10
11
public class GrayFeignRequestInterceptor implements RequestInterceptor {

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

5. 项目运行配置

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lxd:
tool:
gray: true #启用灰度
load:
version:
prodVersion: V1
grayVersion: V2
#启用灰度后打开,选择相应的负载均衡策略
# user-app:
# ribbon:
# NFLoadBalancerRuleClassName: com.lxd.gray.loadbalancer.GrayRoundRobinRule
# order-app:
# ribbon:
# NFLoadBalancerRuleClassName: com.lxd.gray.loadbalancer.GrayRoundRobinRule

image-20250309111533700

配置网关 Nacos 配置文件(gateway-app.yaml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lxd:
tool:
gray:
gateway:
# 是否开启灰度发布功能
enabled: false
# 自定义灰度版本请求头
grayHeadKey: gray
# 自定义灰度版本请求头匹配值
grayHeadValue: gray-996
# 使用灰度版本IP数组
grayIPList:
# 使用灰度版本城市数组
grayCityList:

启动网关服务

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

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

先直接 Debug 启动会在 IDEA 右上角看到一个对应启动类名称的信息

点击 Edit 编辑这个启动配置

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

image-20250309114044858

配置启动参数,第一步点击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。

6. 灰度效果演示

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

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

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

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

调用演示

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

  • 第一次调用,Order 服务版本为 V1,User 服务版本也为 V1

image-20250309141716214

  • 第二次调用,Order 服务版本 V2,User 服务版本也为 V2

image-20250309133348778

场景二(开启灰度开关:只调用生产版本)

  1. 在项目启动之前修改 Nacos 全局配置文件中的lxd.tool.gray.load 配置为 true 加载整个灰度相关类

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

image-20250309141417663

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

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

image-20250309141450778

执行顺序

>>>>>> 进入GrayGatewayBeginFilter
>>>>>> 负载均衡:172.20.10.3:7102
>>>>>> GrayGatewayAfterFilter
======================================================================
>>>>>> /order/N0001 ==> GrayMvcHandlerInterceptor#preHandle()
>>>>>> getOrderInfo方法调用
>>>>>> 发起Feign调用
>>>>>> /user/U0001 ==> GrayFeignRequestInterceptor
>>>>>> 负载均衡:172.20.10.3:7202
>>>>>> /order/N0001 ==> GrayMvcHandlerInterceptor#afterCompletion()
======================================================================
>>>>>> /user/U0001 ==> GrayMvcHandlerInterceptor#preHandle()
>>>>>> getUserName方法调用
>>>>>> /user/U0001 ==> GrayMvcHandlerInterceptor#afterCompletion()

7. 源码

https://github.com/LXDaa/LXDaa-spring-cloud-gray-example.git

8. 存在问题

  1. 如果项目中使用到了分布式任务调度那怎么区分灰度版本
  • 这里其实挺好解决的,就拿 xxl-job 来说,注册不同的执行器就行,在发布灰度版本时注册到灰度版本的执行器即可。
  1. 如果项目中使用的了 MQ 我们收发消息怎么控制灰度
  • 这里和解决分布式任务调度思想是一样的灰度版本的服务发送消息的时候投递到另外一个 MQ 的服务端,就是弄两套 MQ 服务端,生产的服务使用生产的 MQ,灰度发布使用灰度的 MQ
  1. 这里整个实现流程不是很复杂,但也是很没必要,只是提供一种实现方案可以参考
  • 其实通过 Nginx + Lua 脚本方式直接路由网关,然后给灰度整套服务都使用一个 Nacos 灰度的命名空间,生产的使用生产的命名空间,这样就能将两套服务都隔离了,分布式任务调度、MQ 等配置都可以独立在自己命名空间的配置文件中岂不美哉。