灰度发布, 也叫金丝雀发布。是指在黑与白之间,能够平滑过渡的一种发布方式。AB test就是一种灰度发布方式,让一部分用户继续用A,一部分用户开始用B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度,而我们平常所说的金丝雀[部署也就是灰度发布的一种方式。
具体到服务器上, 实际操作中还可以做更多控制,譬如说,给最初更新的10台服务器设置较低的权重、控制发送给这10台服务器的请求数,然后逐渐提高权重、增加请求数。一种平滑过渡的思路, 这个控制叫做“流量切分”。
我们这项目已经练习了两年半了使用的版本不是很新,我这里的Demo也会使用这个版本,有感情了,使用新版本的朋友自己调整一下就行,实现思路是一样的只是这些框架源码可能会有变化。
spring cloud 对应版本关系图
要实现Spring Cloud项目灰度发布技术方案有很多,重点在于服务发现,怎么将灰度流量只请求到灰度服务,这里我们会使用Nacos作为注册中心和配置中心,核心就是利用Nacos的Metadata设置一个version值,在调用下游服务是通过version值来区分要调用那个版本,这里会省略一些流程,文章末尾提供了源码地址需要自提。
这个是demo项目,结构都按最简单的来。
在请求进入网关时开始对是否要请求灰度版本进行判断,通过Spring Cloud Gateway的过滤器实现,在调用下游服务时重写一个Ribbon的负载均衡器实现调用时对灰度状态进行判断。
使用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 GlobalFilter, Ordered {
@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 GlobalFilter, Ordered {
@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 WebExceptionHandler, Ordered {
@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;
}
}
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;
}
}
自定义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请求拦截器,取出自定义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可以自己创建一个也可以换成自己的命名空间,源码里面配置都是存在的,有问题看源码就行
所有服务都会使用到这个配置
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
kerwin:
tool:
gray:
gateway:
## 是否开启灰度发布功能
enabled: true
## 自定义灰度版本请求头
grayHeadKey: gray
## 自定义灰度版本请求头匹配值
grayHeadValue: gray-996
## 使用灰度版本IP数组
grayIPList:
- '127.0.0.1'
## 使用灰度版本城市数组
grayCityList:
- 本地
网关服务启动一个就行,直接Debug启动即可,方便调试源码
源码中的user-app提供了一个获取用户信息的接口并且会携带当前服务的端口和版本信息,order-app服务提供了一个获取订单信息的接口,会去远程调用user-app获取订单关联的用户信息,并且也会携带当前服务的端口和版本信息响应。
关闭灰度开关有两个配置可以实现
这里调用不一定就是Order服务版本为V1 User服务版本也为V1,也有可能Order服务版本为V1 User服务版本也为V2.
修改网关Nacos配置文件中的kerwin.tool.gray.gateway.enabled 设置为true,其它灰度IP数组和城市数组配置匹配不上就行,这样怎么调用都是V1版本,因为在GrayVersionProperties版本配置中设置的生产版本就是为V1灰度版本为V2。
这里通过请求头测试,携带请求头gray=gray-996访问网关那么流量就会都进入灰度版本V2。