灰度发布实战
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
作为注册中心和配置中心,核心就是利用Nacos
的Metadata
设置一个version
值,在调用下游服务时是通过version
来区分要调用哪个版本。

代码设计结构
1 2 3 4 5 6 7 8 9 10
| spring-cloud-gray-example common gateway order order-app starter spring-cloud-starter-gray user user-app user-client
|
核心包 spring-cloud-starter-gray 机构介绍

入口 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; }
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(); } 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) { 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) { GrayReleaseContextHolder.remove(); ServerHttpResponse response = exchange.getResponse(); if (ex instanceof 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 中设置的version
和GrayVersionProperties
的版本号进行匹配出对应版本的服务列表。
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"); if (version != null && version.equals(currentVersion)) { result.add(server); } } return result; } }
|
「自定义轮询算法实现 GrayRoundRobinRule」 代码篇幅太长了这里只截取代码片段,这里是直接拷贝了 Ribbon 的轮询算法,将里面获取服务列表的方法换成了自定义AbstractGrayLoadBalancerRule
中的方法,其它算法也可以通过类似的方式实现。

业务服务实现灰度发布设计
自定义 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); 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) { 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
|

配置网关 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 grayIPList: 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。
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

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

场景二(开启灰度开关:只调用生产版本)
-
在项目启动之前修改 Nacos 全局配置文件中的lxd.tool.gray.load
配置为 true 加载整个灰度相关类
-
修改网关 Nacos 配置文件中的lxd.tool.gray.gateway.enabled
设置为 true,其它灰度 IP 数组和城市数组配置匹配不上就行,这样怎么调用都是 V1 版本,因为在GrayVersionProperties
版本配置中设置的生产版本为 V1,灰度版本为 V2。

场景三(开启灰度开关:通过请求头、ip、城市匹配调用灰度版本)
这里通过请求头测试,携带请求头gray=gray-996
访问网关那么流量就会都进入灰度版本 V2。

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