基于spring cloud 的灰度发布实践_【收藏】基于spring cloud灰度发版方案
简介
敏捷开发迭代周期短发布快,每周都可能面临版本发版上线,为最大可能的降低对用户的影响提高服务可用率,大部分团队都需要等到半夜做发布和支持。本文就如何基于spring cloud体系做灰度发版改造提供了方案,让我们终于白天也能偷偷摸摸的无感知发版,验证,上线等动作,从此再也不用因为发版要熬夜了。
本文阐述的方案是灰度发版方案的一种实现(各种部署方案可参考文档最后的附录),属于一种比较节约资源的部署方案,通过精准的导流和开关控制实现用户无感知的热部署,比较适合中小企业采纳应用。整体技术架构基于nepxion discovery插件结合项目中各个实践场景做了方案说明和源代码展示,如需要做权重,分组等策略可自行扩展。
术语与配置
名称 | 说明 |
---|---|
灰度节点 | 被标记为灰度的节点 |
灰度入口 | 前端部署的节点被标记为灰度的节点 |
灰度用户 | 账号被标记位灰度的用户 |
灰度流量 | 路由是需要优先选择灰度节点的请求链 |
灰度开关 | 是否开启灰度路由,值为:开启/关闭 |
灰度流量开关 | 是否所有流量都是灰度流量,值为开启/关闭 |
开关与流量关系
灰度流量开关\灰度总开关 | 适用场景 |
正常用户 正常入口 |
灰度用户 灰度入口 |
---|---|---|---|
(灰度总开关)开 (灰度流量开关)关 |
灰度节点发版,新版本验证阶段 | 旧版本体验 | 新版本体验 |
(灰度总开关)开 (灰度流量开关)开 |
正常节点发版,新版本批量部署阶段 | 新版本体验 | 新版本体验 |
(灰度总开关)关 (灰度流量开关)开/关 |
新版本完成上线 | 新版本体验 | 新版本体验 |
灰度配置 Gray Properties
![1591942259553](d:\user\01388368\Application Data\Typora\typora-user-images\1591942259553.png)
用户白名单:
- 节点清单加载可以从eureka获取
public ResultMessage getServices() { //本地配置的服务map Map servicesLocalMap = getServicesLocalMap();//要返回的服务清单 List services = new ArrayList(); discoveryClient.getServices().forEach(service -> {final List instances = new ArrayList(); discoveryClient.getInstances(service).forEach(instanceInfo -> { instances.add(toInstance(instanceInfo)); });//优先使用本地实例if (null != servicesLocalMap.get(service)) {final List serviceLocalInstances = servicesLocalMap.get(service).getData();//更新状态 List serviceLocalInstancesHasLatestStatus = serviceLocalInstances.stream() .map(instanceLocal -> instances.stream() .filter(instance -> StringUtils.join(instance.getHost(), instance.getPort().toString()).equals(StringUtils.join(instanceLocal.getHost(), instanceLocal.getPort().toString()))) .findFirst().map(m -> { instanceLocal.setStatus(m.getStatus());return instanceLocal; })// .orElse(null) .orElseGet(() -> { instanceLocal.setStatus("OFFLINE");return instanceLocal; }) ).filter(Objects::nonNull).collect(Collectors.toList());//去除eureka中本地已配置的实例 List instancesRemoveLocal = instances.stream().filter(instance -> !serviceLocalInstancesHasLatestStatus.stream() .anyMatch(instanceLocal -> StringUtils.join(instance.getHost(), instance.getPort().toString()).equals(StringUtils.join(instanceLocal.getHost(), instanceLocal.getPort().toString())))) .collect(Collectors.toList());//清空并重新添加处理过的本地和远程实例 instances.clear(); instances.addAll(instancesRemoveLocal); instances.addAll(serviceLocalInstancesHasLatestStatus);//本地服务实例排除已经添加的服务 servicesLocalMap.remove(service); }//排序 Collections.sort(instances);//单个服务和节点添加 Service serviceResp = new Service(); serviceResp.setService(service); serviceResp.setData(instances); services.add(serviceResp); });//添加eureka中不存在,本地存在的服务 servicesLocalMap.values().forEach(service->{ Collections.sort(service.getData()); services.add(service); }); Collections.sort(services);return ResultCode.SUCCESS.withData(services); }
依赖 Gray Dependency
<dependency> <groupId>com.sfgroupId> <artifactId>cloud-discovery-service-starterartifactId> <version>0.0.1-SNAPSHOTversion>dependency>
<dependency> <groupId>com.sfgroupId> <artifactId>cloud-discovery-gateway-starterartifactId> <version>0.0.1-SNAPSHOTversion>dependency>
<plugin> <groupId>com.sfgroupId> <artifactId>maven-pluginartifactId> <version>0.0.1-SNAPSHOTversion> <executions> <execution> <phase>compilephase> <goals> <goal>gray-plugingoal> goals> <configuration> <grayBuildLocationExclude>grayBuildLocationExclude> configuration> execution> executions>plugin>
灰度头部 GrayHeader
灰度头部信息信息,编码支持的类有 < GrayHeader,GrayHeaderConstant,GrayUtil, ServiceGrayUtil >
灰度对象存放的上下文有:自定义实现类 < GrayHeaderHolder >,Request内置实现类 < RequestContextHolder >
参数key | 参数value | 说明 |
---|---|---|
h-gray-is | true/false | 是否为灰度流量 |
h-gray-domain | 域名 | 用户请求的域名 |
h-gray-userid | xxx | 用户请求的账户 |
技术改造点
灰度改造分三大类:网关改造,服务改造,场景改造。主要目的是实现灰度头部计算、复用、续传,负载均衡的改造。
网关改造 cloud-discovery-gateway-starter
修改pom.xml依赖
<dependency> <groupId>com.nepxiongroupId> <artifactId>discovery-plugin-starter-eurekaartifactId> <version>0.0.2-SNAPSHOTversion>dependency><dependency> <groupId>com.nepxiongroupId> <artifactId>discovery-plugin-strategy-starter-gatewayartifactId> <version>0.0.2-SNAPSHOTversion>dependency>
configure加载项
@Configurationpublic class DiscoveryGatewayAutoConfiguration { //负载均衡改造注入 @Bean public DiscoveryEnabledAdapter discoveryEnabledAdapter() { return new GatewayGrayDiscoveryEnabledAdapter(); } //灰度路由计算注入 @Bean public GrayRouteFlagFilter grayRouteFilter() { return new GrayRouteFlagFilter(); } //灰度配置获取注入 @Bean public GrayPropertiesLoader grayPropertiesLoader() { return new GrayPropertiesLoader(gatewayRedisson); }}
[路由场景]增加header
在网关新增Filter,将request上下文和灰度配置匹配,算出灰度路由标记
public class GrayRouteFlagFilter implements GlobalFilter, Ordered { @Autowired private GrayPropertiesLoader grayPropertiesLoader;
@Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { try { ServerHttpRequest request = exchange.getRequest(); Boolean isGray = grayPropertiesLoader.calculateGrayFlag(request.getHeaders().getFirst(GrayHeaderConstant.GRAY_IS), request.getHeaders().getFirst(GrayHeaderConstant.GRAY_NGINX_IP), request.getHeaders().getFirst(GrayHeaderConstant.GRAY_USER_ACCOUNT)); request = request.mutate().header(GrayHeaderConstant.GRAY_IS, isGray.toString()).build(); exchange = exchange.mutate().request(request).build(); }catch (Throwable e){ log.error("未知错误",e); }finally { return chain.filter(exchange); } }}
[路由场景]改造feign负载均衡实现节点筛选
改造负载均衡,计算灰度路由标记,灰度开关和节点清单的匹配性,筛选出符合条件的节点
public class GatewayGrayDiscoveryEnabledAdapter extends DefaultDiscoveryEnabledAdapter {
@Autowired private GrayPropertiesLoader grayPropertiesLoader;
public boolean apply(Server server) { if (!this.applyVersion(server)) { return false; } }
private boolean applyVersion(Server server) { //判断灰度总开关 if(!Boolean.valueOf(grayPropertiesLoader.get().getEnable())){ return true; } Boolean isGray = Boolean.valueOf(strategyContextHolder.getHeader(GrayHeaderConstant.GRAY_IS)); try{ return GrayLoadBalanceUtil.isServerMatch(this.pluginAdapter.getServerServiceId(server), isGray, server.getHost(), server.getPort()); }catch(Exception e){ return true; } }
}
服务改造 cloud-discovery-service-starter
修改pom.xml依赖
<dependency> <groupId>com.nepxiongroupId> <artifactId>discovery-plugin-starter-eurekaartifactId> <version>0.0.2-SNAPSHOTversion>dependency><dependency> <groupId>com.nepxiongroupId> <artifactId>discovery-plugin-strategy-starter-serviceartifactId> <version>0.0.2-SNAPSHOTversion>dependency>
configure加载项
@Configurationpublic class DiscoveryServiceAutoConfiguration {
@Autowired Redisson getRedisson; //灰度负载均衡注入 @Bean public DiscoveryEnabledAdapter discoveryEnabledAdapter() { return new ServiceGrayDiscoveryEnabledAdapter(); } //Feign灰度头部续传改造注入 @Bean public FeignStrategyInterceptor feignStrategyInterceptor() { return new ServiceFeignStrategyInterceptor(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS); } //灰度配置获取注入 @Bean public GrayPropertiesLoader grayPropertiesLoader() { return new GrayPropertiesLoader(getRedisson); } //restTemplate灰度头部续传改造注入 @Bean public RestTemplateStrategyInterceptor restTemplateStrategyInterceptor() { return new ServiceRestTemplateStrategyInterceptor(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS); } //线程池灰度头部续传改造注入 @Bean public RequestContextDecorator requestContextDecorator() { return new RequestContextDecorator(); }
}
- [feign同步调用场景]改造feign负载均衡实现节点筛选 < ServiceGrayDiscoveryEnabledAdapter >
public class ServiceGrayDiscoveryEnabledAdapter extends DefaultDiscoveryEnabledAdapter {
@Autowired private GrayPropertiesLoader grayPropertiesLoader;
public boolean apply(Server server) { if (!this.applyVersion(server)) { return false; } return true; }
private boolean applyVersion(Server server) { //灰度开关是否开启 if(!Boolean.valueOf(grayPropertiesLoader.get().getEnable())){ return true; } Boolean isGray = Boolean.valueOf(ServiceGrayUtil.getGrayHeaderFromContext().getIsGray()); try{ return GrayLoadBalanceUtil.isServerMatch(this.pluginAdapter.getServerServiceId(server), isGray, server.getHost(), server.getPort()); }catch(Exception e){ return true; } }
}
- [restTemplate调用场景]支持header续传 < ServiceRestTemplateStrategyInterceptor >
public class ServiceRestTemplateStrategyInterceptor extends RestTemplateStrategyInterceptor {
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { this.applyOuterHeader(request); return execution.execute(request, body); }
private void applyOuterHeader(HttpRequest request) { HttpHeaders headers = request.getHeaders(); GrayUtil.toMap(ServiceGrayUtil.getGrayHeaderFromContext()).forEach((key, value)->{ if(null==headers.get(key)){ headers.add(key, value); } }); }
}
场景改造
线程池灰度改造 ThreadPoolTaskExecutor
对于线程池的使用请使用封装过的taskExecutor,这样就自动实现了跨线程的头部续传,装饰类为: < RequestContextDecorator >
public class RequestContextDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) {
try{ RequestAttributes context = RequestContextHolder.currentRequestAttributes(); GrayHeader grayHeader = ServiceGrayUtil.getGrayHeaderFromContext(); log.debug(" --- gray debug: THREAD parent:{}", Thread.currentThread().getName()); return () -> { try { try { RequestContextHolder.setRequestAttributes(context); GrayUtil.setGrayHeader2Local(grayHeader); log.debug(" --- gray debug: THREAD child:{}", Thread.currentThread().getName()); }catch(Exception e){ log.error("跨线程传递变量出错:", e); } runnable.run(); }catch(Exception e){ log.error("定时任务执行出错:", e); }finally{ RequestContextHolder.resetRequestAttributes(); GrayUtil.removeGrayHeader(); } }; }catch(Exception e){ log.debug("线程装饰出错:", e); } return () -> { runnable.run(); }; }}
- 使用场景
#引入bean依赖(此线程池添加了灰度header续传,已把RequestContextHolder从主线程迁移过来):@ResourceThreadPoolTaskExecutor taskExecutor;
//场景1,异步注解方式* @Async("taskExcutor")
//场景2,异步调用taskExcutor.submit(task);
//场景3,异步回调方式* CompletableFuture userAccountCompletableFuture = CompletableFuture.completedFuture(null) .thenApplyAsync(dummy -> {...}, taskExcutor);自定义executor注意添加setTaskDecorator(new RequestContextDecorator())private ThreadPoolTaskExecutor executorService;@PostConstructpublic void init() { executorService = new ThreadPoolTaskExecutor(); ...//此处添加了灰度header续传 executorService.setTaskDecorator(new RequestContextDecorator()); executorService.initialize(); }
定时任务改造 Job
- 需要考虑支持的定时任务调度模式模式
全随机:从所有的节点选一个执行任务 灰度随机:从灰度节点中选一个执行任务 并行随机:从非灰度节点中选一个执行任务,同时从灰度节点中选一个执行任务 非灰度随机:从非灰度节点中选一个执行任务
- 以Quartz Job Scheduling分布式任务为例,其他xxl-job可参考此方案改造
public class QuartzJobRunner implements Job {
@Override public final void execute(JobExecutionContext context) throws JobExecutionException { //对不同的调度模式执行不同的负载均衡算法选择节点,可参考附件工具类 GrayLoadBalanceUtil.getServerUrl(String serviceName, Boolean isGray, String grayDomain, String grayUserid) } }
消息队列改造 Queue
统一标准的消息队列结构封装,带入GrayHeader信息。
统一改造封装消费方,如果GrayHeader信息与消费方机器匹配才做消费。
遗留问题
第三方组件未做隔离带来的问题
因此方案从节约成本的考虑仍然共用redis,db等第三方组件,如开发过程中应尽量避免第三方存储的数据结构发生变更,需要评估新旧版本是否会存在差异导致服务不可用来决定是否停服维护。
跨平台的灰度服务改造探讨
- 对于spring cloud体系发起调用时,统一按灰度规则改造Http Client实现灰度负载均衡,实现GrayHeader传递
- 对spring cloud体系接收调用时,统一规范第三方填入GrayHeader信息
- 对于通过消息队列交互的第三方,可定制A,B topic轮换策略来实现灰度发版
附录:
灰度工具类代码
- GrayUtil,ServiceGrayUtil提供了一系列工具类,如获取灰度上下文,构造灰度上下文,设置灰度上下文到下一个请求等
/** 灰度头部上下文获取方式 */GrayHeader ServiceGrayUtil.getGrayHeaderFromContext() /** 跨线程的灰度头部续传范例 **/GrayUtil.snapshotGrayHeaderProperties();...GrayUtil.recoverGrayHeaderProperties(new HttpHeaders(), grayHeaderMap));
GrayUtil
public class GrayUtil1 {
public static List requestHeaderList = StringUtil.splitToList(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS.toLowerCase(), ";");/** * 此方法只适用于网关入口处获取灰度头部,不能在续传过程中使用,如续传使用请参见ServiceGrayUtil.getGrayHeaderFromContext() * 从头部获取灰度灰度参数对象,指定grayUserid覆盖头部中的参数 * 此方法不会重用header:is-gray标记,只会重新计算。 * @param headers * @param grayUserid */public static GrayHeader getGrayHeaderFromHeader(HttpHeaders headers, String grayUserid){ GrayHeader grayHeader = new GrayHeader();if(StringUtils.isEmpty(grayUserid)){ grayUserid = headers.getFirst(GrayHeaderConstant.GRAY_USER_ACCOUNT); } GrayPropertiesLoader grayPropertiesLoader = GrayContextUtil.getBean(GrayPropertiesLoader.class);/** * 无法获取grayUserid和grayDomain的情况下,从缓存灰度配置拿灰度flag:满足多数用户的流量走向 */if(null!= grayPropertiesLoader){ grayHeader = grayPropertiesLoader.calculateGrayHeader(null, headers.getFirst(GrayHeaderConstant.GRAY_NGINX_IP), grayUserid); }return grayHeader; }/** * 设置当前header的灰度头部信息 * @param headers * @param grayHeader */public static HttpHeaders appendGrayHeader(HttpHeaders headers, GrayHeader grayHeader){try{ Iterator> iter = toMap(grayHeader).entrySet().iterator();while(iter.hasNext()){ Map.Entry row = iter.next();if(!headers.containsKey(row.getKey())){ headers.add(row.getKey(), row.getValue()); } }return headers; }catch(Exception e){ log.error("恢复灰度头部信息失败", e); }return headers; }/** * * @param headersJson request header json * @param grayHeaderString gray header json * @return */public static String appendGrayHeader(String headersJson, String grayHeaderString){try{ JSONObject headParamObj = new JSONObject();if(StringUtils.isNotEmpty(headersJson)){ headParamObj = JSONObject.parseObject(headersJson); } JSONObject grayHeaderJson = new JSONObject();if(StringUtils.isNotEmpty(grayHeaderString)){ grayHeaderJson = JSONObject.parseObject(grayHeaderString); } Iterator> iter = grayHeaderJson.entrySet().iterator();while(iter.hasNext()){ JSONObject.Entry row = iter.next();if(StringUtils.isEmpty(headParamObj.getString(row.getKey()))){ headParamObj.put(row.getKey(), row.getValue()); } }return headParamObj.toJSONString(); }catch(Exception e){ log.error("设置恢复灰度头部信息失败", e); }return headersJson; }/** * 把grayHeader写入到当前线程local(适合消息队列等通过消息接收到grayHeader信息后写入threadlocak,提供给restTemplate发起时抓取并塞入头部) * @param grayHeader */public static void setGrayHeader2Local(GrayHeader grayHeader){ GrayHeaderHolder.setGrayContext(grayHeader); }/** * 清除threadLocal存储的灰度路由信息 */public static void removeGrayHeader(){ GrayHeaderHolder.removeGrayContext(); }/** * Gray头部对象转map * @param grayHeader * @return */public static Map toMap(GrayHeader grayHeader){ Map res = new HashMap();if(null!=grayHeader.getIsGray()){ res.put(GrayHeaderConstant.GRAY_IS, grayHeader.getIsGray().toString()); }if(null!=grayHeader.getGrayUserid()){ res.put(GrayHeaderConstant.GRAY_USER_ACCOUNT, grayHeader.getGrayUserid()); }if(null!=grayHeader.getGrayDomain()){ res.put(GrayHeaderConstant.GRAY_NGINX_IP, grayHeader.getGrayDomain()); }return res; }/** * map中获取grayHeader对象 * @param map * @return */public static GrayHeader toGrayHeader(Map map){ GrayHeader grayHeader = new GrayHeader();if(null!=map.get(GrayHeaderConstant.GRAY_IS)){ grayHeader.setIsGray(Boolean.valueOf(map.get(GrayHeaderConstant.GRAY_IS))); }if(null!=map.get(GrayHeaderConstant.GRAY_USER_ACCOUNT)){ grayHeader.setGrayUserid((String)map.get(GrayHeaderConstant.GRAY_USER_ACCOUNT)); }if(null!=map.get(GrayHeaderConstant.GRAY_NGINX_IP)){ grayHeader.setGrayDomain((String)map.get(GrayHeaderConstant.GRAY_NGINX_IP)); }return grayHeader; }public static boolean isHeaderContains(String headerName) {return headerName.startsWith("n-d-") || requestHeaderList.contains(headerName); }public static boolean isHeaderContainsExcludeInner(String headerName) {return isHeaderContains(headerName) ; }}
ServiceGrayUtil
public class ServiceGrayUtil { /** * 获取当前header的灰度头部信息,优先从discoveryRequest上下文获取,如果抓不到则从threadLocal获取 * @return */ public static GrayHeader getGrayHeaderFromContext(){ String grayDomain = null; String grayUserid = null; GrayHeader grayHeader = new GrayHeader(); try{ /** * 从request上下文获取 */ Map grayHeaderMap = new HashMap(); ServiceStrategyContextHolder serviceStrategyContextHolder = GrayContextUtil.getBean(ServiceStrategyContextHolder.class); ServiceStrategyRouteFilter serviceStrategyRouteFilter = GrayContextUtil.getBean(ServiceStrategyRouteFilter.class); ServletRequestAttributes attributes = serviceStrategyContextHolder.getRestAttributes();if (attributes != null) { HttpServletRequest previousRequest = attributes.getRequest(); Enumeration headerNames = previousRequest.getHeaderNames();if (headerNames != null) { String routeRegionWeight;while (headerNames.hasMoreElements()) { routeRegionWeight = (String) headerNames.nextElement(); String headerValue = previousRequest.getHeader(routeRegionWeight);boolean isHeaderContains = GrayUtil.isHeaderContainsExcludeInner(routeRegionWeight.toLowerCase());if (isHeaderContains) { grayHeaderMap.put(routeRegionWeight, headerValue); } } } } grayHeader = GrayUtil.toGrayHeader(grayHeaderMap); grayDomain = null==grayDomain?grayHeader.getgrayDomain():grayDomain; grayUserid = null==grayUserid?grayHeader.getgrayUserid():grayUserid;if(null!=grayHeader.getIsGray()){ log.debug(" --- gray debug:从request头部获取灰度信息成功:{}", JsonUtil.toJson(grayHeader));return grayHeader; } }catch(Exception e){ log.debug("从request头部获取灰度信息失败", e); }try{/** * 从threadlocal获取:如果request上下文未获取到isGray标记则进入 */if(null!= GrayHeaderHolder.getGrayContext()){ grayHeader = GrayHeaderHolder.getGrayContext(); grayDomain = null==grayDomain?grayHeader.getgrayDomain():grayDomain; grayUserid = null==grayUserid?grayHeader.getgrayUserid():grayUserid; }if(null!=grayHeader.getIsGray()){ log.debug(" --- gray debug:从threadlocal获取灰度信息成功:{}", JsonUtil.toJson(grayHeader));return grayHeader; } }catch(Exception e){ log.debug("从threadlocal获取灰度信息失败", e); }try{ GrayPropertiesLoader grayPropertiesLoader = GrayContextUtil.getBean(GrayPropertiesLoader.class);/** * 无法获取grayUserid和grayDomain的情况下,从缓存灰度配置拿灰度flag:满足多数用户的流量走向 */if(null!= grayPropertiesLoader){ grayHeader = grayPropertiesLoader.calculateGrayHeader(null, grayDomain, grayUserid); }if(null!=grayHeader.getIsGray()){ log.debug(" --- gray debug:本地上下文获取灰度信息失败,从配置计算灰度信息信息成功:{} , grayDomain:{}, grayUserid:{}", JsonUtil.toJson(grayHeader),grayDomain,grayUserid );return grayHeader; } }catch(Exception e){ log.debug("从配置获取灰度标志失败", e); }return grayHeader; }public static String getGrayHeaderStrFromContext(){return JsonUtil.toJson(GrayUtil.toMap(getGrayHeaderFromContext())); }}
GrayLoadBalanceUtil
public class GrayLoadBalanceUtil { public static GrayLoadBalanceUtil1 grayLoadBalanceUtil;
@Autowired private DiscoveryClient discoveryClient;
@Autowired private Registration registration; @Autowired private GrayPropertiesLoader grayPropertiesLoader;
private static AntPathMatcher matcher = new AntPathMatcher();
@PostConstruct public void init() { grayLoadBalanceUtil = this; }
public static String getServerUrl(String serviceName, Boolean isGray, String grayDomain, String grayUserid){
List serviceInstancesList = grayLoadBalanceUtil.discoveryClient.getInstances(serviceName);if(null!=serviceInstancesList){for(ServiceInstance serviceInstance: serviceInstancesList){try{//计算isGray的过滤器在此入口之后,所以这里自己计算一遍isGray isGray = grayLoadBalanceUtil.grayPropertiesLoader.calculateGrayFlag(null==isGray?null:isGray.toString(), grayDomain, grayUserid);if(isServerMatch(serviceInstance.getServiceId(), isGray, serviceInstance.getHost(), serviceInstance.getPort())){return serviceInstance.getUri().toString(); } }catch(Exception e){ log.info(" --- gray inf: 获取版本或者服务名失败 > {} ", JsonUtil.toJson(serviceInstance));return serviceInstance.getUri().toString(); } } } log.error("自定义负载均衡匹配失败,无法继续请求服务:{}, {}", serviceName, isGray);return ""; }/** * 当前节点是否被选中 * @param isGray 路由模式,true为灰度路由 * @param serviceName 服务名称 * @return */public static Boolean isServerMatch(String serviceName, Boolean isGray, String host, Integer port){if(!grayLoadBalanceUtil.grayPropertiesLoader.get().getGrayServiceMap().containsKey(serviceName)){//该服务未配置灰度节点则匹配第一个return true; }else{//该服务配置了灰度节点则需要匹配return (isGray && isGrayNode(host,port)) || (!isGray && !isGrayNode(host,port)); } }/** * 匹配版本规则 * @param pattern * @param value * @return */private static boolean match(String pattern, String value) {return GrayLoadBalanceUtil1.matcher.match(pattern, value); }/** * 获取当前服务 * @return */public static ServiceInstance getLocalServiceInstance() {return grayLoadBalanceUtil.registration; }/** * 当前节点是否为金丝雀节点 * @return */public static Boolean isCurrentGrayNode(){return isGrayNode(grayLoadBalanceUtil.registration.getHost(), grayLoadBalanceUtil.registration.getPort()); }public static Boolean isGrayNode(String host, Integer port){try{return grayLoadBalanceUtil.grayPropertiesLoader.get().getGrayInstanceMap().containsKey(StringUtils.join(host, ":",port)); }catch(Exception e){ log.error(" --- gray debug: node judge 获取当前节点是否为灰度节点失败", e); }return false; }}
GrayHeaderHolder
public class GrayHeaderHolder { private static final InheritableThreadLocal grayContextLocal = new InheritableThreadLocal<>(); public static GrayHeader getGrayContext(){ return grayContextLocal.get(); } public static void setGrayContext(GrayHeader grayHeader){ grayContextLocal.set(grayHeader); } public static void removeGrayContext(){ grayContextLocal.remove(); }}
GrayProperties
public class GrayProperties implements Serializable {
public GrayProperties(){
} public GrayProperties(Boolean enable,Boolean online, String userWhitelist,String frontNginxipWhitelist, String userWhitelistRemark){ this.enable = enable; this.online = online; this.userWhitelist = userWhitelist; this.frontNginxipWhitelist = frontNginxipWhitelist; this.userWhitelistRemark = userWhitelistRemark; }
//金丝雀路由启用总开关 private Boolean enable = false; //金丝雀节点匹配流量控制开关true为导流全部流量,false按service金丝雀节点匹配规则导流量 private Boolean online = false; //用户金丝雀路由条件----客户端用户白名单 private String userWhitelist; //用户金丝雀路由条件----客户端用户白名单备注 private String userWhitelistRemark; //用户金丝雀路由条件----前端nginxip白名单(具体参数为最新前端代码统一部署的负载均衡ip) private String frontNginxipWhitelist;
private Map userWhitelistMap = null; private Map frontNginxipWhitelistMap = null;
private GrayProductVersion productVersion; /** * 金丝雀服务清单 */ private List servicesLocal; //金丝雀节点清单 private Map grayInstanceMap = null; //存在金丝雀节点的服务清单 private Map grayServiceMap = null; @PostConstruct public void initMap() { userWhitelistMap = transfer2Map(userWhitelist); frontNginxipWhitelistMap = transfer2Map(frontNginxipWhitelist); grayInstanceMap = new HashMap(); grayServiceMap = new HashMap(); if(null!=servicesLocal && servicesLocal.size()>0){ servicesLocal.forEach(serviceLocal->{ if(null!=serviceLocal.getData() && serviceLocal.getData().size()>0){ grayServiceMap.put(serviceLocal.getService(), ""); serviceLocal.getData().forEach(instanceLocal->{ grayInstanceMap.put(StringUtils.join(instanceLocal.getHost(),":", instanceLocal.getPort()), ""); }); } }); } } /** * 转换逗号分隔的字符串到map, value为null * @param str * @return */ Map transfer2Map(String str){ Map map = new HashMap(); if(StringUtils.isNotEmpty(str)){ for(String key: str.trim().split(",")){ map.put(key.toLowerCase(), null); } } return map; }}
GrayPropertiesLoader
public class GrayPropertiesLoader { public static String GRAY_PROPERTIES_SYNC_TOPIC = "gray-properties-topic"; public static String GRAY_PROPERTIES_KEY = "GLOBAL:CONFIG:GRAY_PROPERTIES";
Redisson redisson;
GrayProperties grayProperties = null;
public GrayPropertiesLoader(Redisson redisson){ this.redisson = redisson; } /** * 本地获取配置 */ public GrayProperties get(){ if(null!=grayProperties){ return grayProperties; }else{ log.debug(" --- gray debug: PROPERTIES > {}", "未加载到配置本地缓存,开始从远程获取"); return load(); } }
/** * 从缓存或db加载配置 * @return */ private synchronized GrayProperties load(){ //缓存获取 GrayProperties cacheGrayProperties = null; try{ RBucket bucket = redisson.getBucket(GrayPropertiesLoader.GRAY_PROPERTIES_KEY); cacheGrayProperties = bucket.get(); }catch(Exception e){ log.error("从redis获取金丝雀配置信息失败", e); } if(null!=cacheGrayProperties){ cacheGrayProperties.initMap(); grayProperties = cacheGrayProperties; log.debug(" --- gray debug: PROPERTIES > {}, {}", "从缓存加载:", JsonUtil.toJson(grayProperties)); return grayProperties; }else{ //数据库获取(暂不实现,redis重启后需要修改配置来加载) log.debug(" --- gray debug: PROPERTIES > {}", "未加载到配置缓存,跳过数据库加载"); //都没有则,返回一个空的 return new GrayProperties(); } } /** * 金丝雀灰度设置修改监听 */ @PostConstruct public void listener(){ RTopic topic = redisson.getTopic(GRAY_PROPERTIES_SYNC_TOPIC, new JsonJacksonCodec()); topic.addListener(GrayProperties.class, new MessageListener() { @Override public void onMessage(CharSequence charSequence, GrayProperties cacheGrayProperties) { if(null!=cacheGrayProperties){ cacheGrayProperties.initMap(); grayProperties = cacheGrayProperties; log.debug(" --- gray debug: PROPERTIES > {}, {}", "订阅的配置更新了:", JsonUtil.toJson(grayProperties)); } } }); } /** * 发布配置修改 * @param grayProperties */ public void publish(GrayProperties grayProperties){ RTopic topic = redisson.getTopic(GRAY_PROPERTIES_SYNC_TOPIC, new JsonJacksonCodec()); topic.publish(grayProperties); log.debug(" --- gray debug: PROPERTIES > {}, {}", "修改了配置并发布订阅:", JsonUtil.toJson(grayProperties)); } /** * 根据头部传参判断请求是否走灰度 * @param isGray * @param nginxIp * @param userAccount * @return */ public Boolean calculateGrayFlag(String isGray, String nginxIp, String userAccount){ Boolean flag = false; /** * 保底方案1: nginxIp,userAccount为空会根据开关状态来返回灰度标记 */ if(null!=isGray){ flag = Boolean.valueOf(isGray); log.debug(" --- gray debug - 【金丝雀标记】 - 优先使用已有的金丝雀标记:{}", flag); return flag; }else{ if(this.get().getEnable()){ //金丝雀全局开关开启--金丝雀发版到上线阶段 if(this.get().getOnline()){ //金丝雀上线开关开启--金丝雀密集上线阶段 flag = true; log.debug(" --- gray debug - 【金丝雀标记】 - 总开关开【开】,金丝雀开关【开】:{}", flag); return flag; }else{//金丝雀上线开关关闭--金丝雀验证阶段 log.debug(" --- gray debug: USER > {} {}", nginxIp, userAccount); if((null!=userAccount && this.get().getUserWhitelistMap().containsKey(userAccount)) || (null!=nginxIp && this.get().getFrontNginxipWhitelistMap().containsKey(nginxIp))){ flag = true; log.debug(" --- gray debug - 【金丝雀标记】 - 总开关开【开】,金丝雀开关【关】, 用户和nginxip【匹配成功】:{}", flag); return flag; } /** * 保底方案2: 根据当前节点是否为金丝雀节点来计算灰度 */ flag = GrayLoadBalanceUtil.isCurrentGrayNode(); log.debug(" --- gray debug - 【金丝雀标记】 - 总开关开【开】,金丝雀开关【关】, 用户和nginxip【匹配失败】,判断当前CLOUD节点是否为金丝雀节点: {}", flag); return flag; } }else{ log.debug(" --- gray debug - 【金丝雀标记】 - 总开关开【关】:{}", flag); } } return flag; } public GrayHeader calculateGrayHeader(String isGray, String nginxIp, String userAccount){ GrayHeader grayHeader = new GrayHeader(); grayHeader.setIsGray(calculateGrayFlag(isGray, nginxIp, userAccount)); if(null!=nginxIp){ grayHeader.setNginxIp(nginxIp); } if(null!=userAccount){ grayHeader.setUserAccount(userAccount); } return grayHeader; }}
GrayHeaderConstant
public class GrayHeaderConstant {
public static String GRAY_IS = "cloudp-is-gray"; public static String GRAY_NGINX_IP= "nginxip"; public static String GRAY_USER_ACCOUNT= "user-account";
public static String STRATEGY_REQUEST_HEADERS="sf-domain;cloudp-is-gray;user-account;nginxip;access_token;authorization;refresh_token;redirectUrl;cloudType;Referer;back-pass";
}
GrayHeader
public class GrayHeader implements Serializable { public GrayHeader(){
} public GrayHeader(Boolean isGray, String nginxIp, String userAccount){ this.isGray = isGray; this.nginxIp = nginxIp; this.userAccount = userAccount; }
//是否为灰度 Boolean isGray; //前端路由ip String nginxIp; //用户账号 String userAccount;
}
自定义负载均衡算法参考
/** * 根据灰度规则在服务器节点中选择节点 * @return */ private NodeCacheVO selectServer(ProviderCacheVO providerCacheVO, String isGray, String userAccount, String nginxIp){ NodeCacheVO nodeCacheVO = null; GrayProperties grayProperties = grayPropertiesLoader.get(); /* 第①部分 */ //总开关关闭,则所有节点随机跳转 if(!grayProperties.getEnable()){ return providerCacheVO.randomNodeByWeightInAll(); }
/* 第②部分*/ Boolean gray = grayPropertiesLoader.calculateGrayFlag(isGray, nginxIp, userAccount); if(gray){//走灰度 nodeCacheVO = providerCacheVO.randomNodeByWeightInCanary(); }else{//走正常节点 nodeCacheVO = providerCacheVO.randomNodeByWeightInStandard(); }
/* 第③部分*/ if(null==nodeCacheVO){//兜底:如果总开关开,灰度开关关,找不到节点时在所有节点随机选择 nodeCacheVO = providerCacheVO.randomNodeByWeightInAll(); } return nodeCacheVO; }
灰度路由算法保底方案
- getGrayHeaderFromContext()实现原理
ServiceGrayUtil.getGrayHeaderFromContext() 实现了保底方案//① GrayHeader从request上下文获取(isGray有就直接取,userAccount/nginxIp有就传递下去)//② GrayHeader从GrayHeaderHolder的ThreadLocal中获取(isGray有就直接取,userAccount/nginxIp有就传递下去)//③ GrayHeader从GrayPropertiesLoader进入重新计算灰度标记模式
灰度标记算法实现
GrayPropertiesLoader.calculateGrayFlag
❝
实现了重新计算灰度标记,其中采用了保底方案:保底方案1:计算时userAccout,nginxIp不存在则根据开关规则来判断 保底方案2:则根据当前节点是不是灰度节点来设置后续节点是不是走灰度
❞
部署方案对比(此小节来自网络)
蓝绿部署
所谓蓝绿部署,是指同时运行两个版本的应用,如上图所示,蓝绿部署的时候,并不停止掉老版本,而是直接部署一套新版本,等新版本运行起来后,再将流量切换到新版本上。但是蓝绿部署要求在升级过程中,同时运行两套程序,对硬件的要求就是日常所需的二倍,比如日常运行时,需要10台服务器支撑业务,那么使用蓝绿部署,你就需要购置二十台服务器。
「滚动发布」
滚动发布能够解决掉蓝绿部署时对硬件要求增倍的问题。
所谓滚动升级,就是在升级过程中,并不一下子启动所有新版本,是先启动一台新版本,再停止一台老版本,然后再启动一台新版本,再停止一台老版本,直到升级完成,这样的话,如果日常需要10台服务器,那么升级过程中也就只需要11台就行了。
但是滚动升级有一个问题,在开始滚动升级后,流量会直接流向已经启动起来的新版本,但是这个时候,新版本是不一定可用的,比如需要进一步的测试才能确认。那么在滚动升级期间,整个系统就处于非常不稳定的状态,如果发现了问题,也比较难以确定是新版本还是老版本造成的问题。
为了解决这个问题,我们需要为滚动升级实现流量控制能力。
「灰度发布」
灰度发布也叫金丝雀发布,起源是,矿井工人发现,金丝雀对瓦斯气体很敏感,矿工会在下井之前,先放一只金丝雀到井中,如果金丝雀不叫了,就代表瓦斯浓度高。
在灰度发布开始后,先启动一个新版本应用,但是并不直接将流量切过来,而是测试人员对新版本进行线上测试,启动的这个新版本应用,就是我们的金丝雀。如果没有问题,那么可以将少量的用户流量导入到新版本上,然后再对新版本做运行状态观察,收集各种运行时数据,如果此时对新旧版本做各种数据对比,就是所谓的A/B测试。
当确认新版本运行良好后,再逐步将更多的流量导入到新版本上,在此期间,还可以不断地调整新旧两个版本的运行的服务器副本数量,以使得新版本能够承受越来越大的流量压力。直到将100%的流量都切换到新版本上,最后关闭剩下的老版本服务,完成灰度发布。
如果在灰度发布过程中(灰度期)发现了新版本有问题,就应该立即将流量切回老版本上,这样,就会将负面影响控制在最小范围内。
推荐实战文章
Java实现图片水印+压缩So easy!分布式定时任务xxJob的常用姿势都集齐了,So Easy!这个轮子让SpringBoot实现api加密So Easy!实战:SpringBoot集成xxl-sso实现单点登录神奇!不需要服务器,搭建免费个人Blog,so easy!(待发布)分布式事务框架...
基于spring cloud 的灰度发布实践_【收藏】基于spring cloud灰度发版方案相关推荐
- 云视频自动化部署与灰度发布实践
概要:Kubernetes改造与自动化灰度发布是一个长期的过程,需要克服很多困难.但改造也能实实在在带来研发效能的提升,支持灰度发布以后,测试的主要时间可以从晚上转到白天,减轻研发和测试和运维人员的负 ...
- spring java 灰度发布_SpringCloud灰度发布实践(附源码)
前言 在平时的业务开发过程中,后端服务与服务之间的调用往往通过fegin或者resttemplate两种方式.但是我们在调用服务的时候往往只需要写服务名就可以做到路由到具体的服务,这其中的原理相比 ...
- 分布式系统灰度发布实践
文章目录 0.分布式系统灰度要实现的功能清单 1.携带灰度因子 1.1.Http请求中 1.2.JVM中 1.3.调用链中 2.前端资源灰度 2.1.方案一:基于verynginx 2.2.方案二:基 ...
- java灰度发布系统_灰度发布系统架构设计
灰度发布的定义 互联网产品需要快速迭代开发上线,又要保证质量,保证刚上线的系统,一旦出现问题可以很快控制影响面,就需要设计一套灰度发布系统.灰度发布系统的作用,可以根据配置,将用户的流量导到新上线的系 ...
- 轻量化的灰度发布实践技术方案
前段时间业务组负责人提出因为合规原因,一个功能模块需要在 App 实现灰度发布,具体来讲就是要在不同的地域和用户等级开展差异化的活动内容展示.利用这个契机恶补了一些"灰度发布"相关 ...
- git灰度发布版本_灰度发布/蓝绿发布_部署到Kubernetes_选择部署方式_用户指南_CodePipeline - 阿里云...
蓝绿发布 蓝绿部署是不停老版本,部署新版本然后进行测试,确认OK后将流量逐步切到新版本.蓝绿部署无需停机,并且风险较小. 示例 本例是一个 nginx 应用,包含一个 deployment. serv ...
- git灰度发布版本_一种前端灰度发布方案
(给前端大学加星标,提升前端技能.)作者:吕大豹 https://www.cnblogs.com/lvdabao/p/11920919.html 本文介绍一种前端灰度发布方案,主要解决的是传统的灰度发 ...
- java基于ssm的个人博客系统_一个基于 Spring Boot 的开源免费博客系统
概况 mblog 开源免费的博客系统, Java 语言开发, 支持 mysql/h2 数据库, 采用 spring-boot.jpa.shiro.bootstrap 等流行框架开发.支持多用户, 支持 ...
- 基于特征的对抗迁移学习论文_[综述]基于对抗学习的图像间转换问题-1
写在前面:因为下定决心要打起精神来好好扎实自己的学术基础,所以打算从阅读综述入手,对自己想要深入的领域有个总体的认识.文章就是自己的阅读随笔,如果有不对的地方,欢迎大家指出来~ 这篇综述的英文题目是& ...
最新文章
- 8.2.1.3 Range 优化
- [CQOI2015]任务查询系统(差分+主席树)
- c#重命名文件 递归_文件结构、文件操作及压缩解压操作
- RabbitMQ 高可用之镜像队列
- pg加密扩展的安装_PHP7安装已废弃的对称加密扩展mcrypt记录
- TokenInsight对话首席——暗流涌动,钱包如何引领数字资产新生态
- IM 产品设计思考(4)- 问答机器人
- 树莓派linux声卡设置
- 第七章 文本数据-学习笔记+练习题
- 服务器存档修改器,太吾绘卷存档修改器v2.6
- 【技能图谱免费下载】进阶数据库工程师 你需要Get这些技能
- [网络安全自学篇] 十六.Python攻防之弱口令、自定义字典生成及网站暴库防护
- Python得到前面12个月的数据
- ISCSLP 2022 | AccentSpeech—从众包数据中学习口音来构建目标说话人的口音语音合成系统
- CentOS 7中DHCP的介绍与搭建DHCP中继服务(理论+实践)
- AntiVir UNIX 在Ubuntu 8.04下的安装
- 运维之思科篇 -----3.HSRP(热备份路由协议),STP(生成树协议),PVST(增强版PST)
- 关于AQS中enq( )方法CAS操作的疑惑
- 常子楠主编 c语言程序设计答案,4G下的C语言程序设计教学研究-教学研究论文-教育论文(8页)-原创力文档...
- Raft论文解读对话
热门文章
- 2018,腾讯110,感谢有你
- ffmpeg ffplay播放延时大问题:播放延时参数设置
- Upsync:微博开源基于Nginx容器动态流量管理方案
- Lua基础之math(数学函数库)
- ?????nested exception is org.apache.ibatis.reflection.ReflectionException: There is no getter for pr
- 都说现在的主流技术是Flink,那么让我们看看FLink在网易是如何实战的?
- Flume实操(四)【单数据源多出口案例(选择器)】
- 爬虫 spider05——使用httpclient发送get请求、post请求
- JVM系列之:从汇编角度分析Volatile
- 程序员如何用糖果实现盈利 - [别人家的程序员01]