全链路灰度实现
目录
一、什么是灰度发布,有哪些好处?
介绍:
好处:
二、灰度发布几种类型
灰度发布的主要分类:
1.金丝雀部署
2.滚动部署
3.蓝绿部署
三、选型
介绍:
总结:建议选择 金丝雀或者全链路灰度 进行服务的升级发布。
四、全链路灰度思路实例讲解
灰度发布架构
灰度发布实现思路
代码实现
注意
1.服务基础配置、依赖包
2.网关配置
3.消费者服务通过feign到生产者服务,负载配置
五、探讨内容
一、什么是灰度发布,有哪些好处?
介绍:
- 灰度发布是指在 黑和白(0和1)之间,能够平滑过渡的一种发布方式。
- 灰度发布,只升级部分服务,即让一部分用户继续用老版本,一部分用户开始用新版本,如果用户对新版本没什么意见,那么逐步扩大范围,把所有用户都迁移到新版本上面来。
好处:
- 降低发布影响面: 就算出问题,也只会影响部分测试用户,从而可以提前发现新版本中的 bug,然后提前修复,避免影响真实用户;
- 提升用户体验: 除了能发现 bug,还能很好的收集新版本的用户使用反馈,从而提前调整系统,提升用户体验,也能给后续的产品演进带来参考价值。
- 可以做到不停机的热迁移,版本回滚便捷(速度快)
二、灰度发布几种类型
灰度发布的主要分类:
- 金丝雀发布
- 滚动发布
- 蓝绿发布
1.金丝雀部署
金丝雀部署又称灰度部署(或者,灰度发布),是指在黑与白之间,能够平滑过渡的一种发布方式 。
金丝雀的名称来源于「矿井中的金丝雀」,早在 17 世纪,英国矿井工人发现,金丝雀对瓦斯这种气体十分敏感,空气中哪怕有极其微量的瓦斯,金丝雀也会停止歌唱;而当瓦斯含量超过一定限度时,虽然鲁钝的人类毫无察觉,金丝雀却早已毒发身亡。当时在采矿设备相对简陋的条件下,工人们每次下井都会带上一只金丝雀作为“瓦斯检测指标”,以便在危险状况下紧急撤离。
我们来看一下金丝雀部署的步骤:
- 准备好部署各个阶段的工件,包括:构建工件,测试脚本,配置文件和部署清单文件
- 从负载均衡列表中移除掉“金丝雀”服务器
- 升级“金丝雀”应用(切断原有流量并进行部署)
- 对原有应用进行自动化测试
- 将“金丝雀”服务器重新添加到负载均衡列表中(连通性和健康检查)
- 如果“金丝雀”在线使用测试成功,升级剩余的其他服务器(否则就回滚)
金丝雀部署比较典型的例子,就是我们在使用某个应用的时候,该应用邀请我们进行“内测”或者“新版本体验”,如果我们同意了,那么我们就成了金丝雀。
优点:
用户体验影响小,金丝雀发布过程出现问题只影响少量用户
缺点:
当升级全部剩余实例时,如果流量过多,可能会导致服务中断。
2.滚动部署
滚动部署,同样是一种可以保证系统在不间断提供服务的情况下上线的部署方式。和蓝绿部署不同的是,滚动部署对外提供服务的版本并不是非此即彼,而是在更细的粒度下平滑完成版本的升级。
如何做到细粒度平滑升级版本呢?滚动部署只需要一个集群,集群下的不同节点可以独立进行版本升级。比如在一个 12 节点的集群中,我们每次升级 4 个节点,并将升级后的节点重新投入使用,周而复始,直到集群中所有的节点都更新为新版本。
滚动部署的步骤:
滚动发布则是在金丝雀发布的基础上进行的改进和优化,第一次也是使用金丝雀发布,后续则使用多批次的形式发布剩余实例,每次批次之间会进行观察,如果有问题,再进行回滚。
优点:
- 只需要维护一个集群,成本低
- 用户体验影响小,体验较平滑
缺点:
- 上线过程中,两个版本同时对外服务,不易定位问题,且容易造成数据错乱;
- 升级和回滚以节点为粒度,操作相对复杂。(举个例子,在某一次发布中,我们需要更新 100 个实例,每次更新 10 个实例,每次部署需要 5 分钟。当滚动发布到第 80 个实例时,发现了问题,需要回滚。这时,我们估计就要疯了。)
3.蓝绿部署
蓝绿部署,是一种可以保证系统在不间断提供服务的情况下上线的部署方式。
如何保证系统不间断提供服务呢?那就是同时部署两个集群,但仅对外提供一个集群的服务,当需要升级时,切换集群进行升级。蓝绿部署无需停机,并且风险较小。其大致步骤为:
- 部署集群 1 的应用(初始状态),将所有外部请求的流量都打到这个集群上
- 部署集群 2 的应用,集群 2 的代码与集群 1 不同,如新功能或者 Bug 修复等
- 将流量从集群 1 切换到集群 2
- 如集群 2 测试正常,就删除集群 1 正在使用的资源,使用集群 2 对外提供服务
优点:
- 1.同一时间对外服务的只有一个版本,容易定位问题;
- 2.升级和回滚以集群为粒度,操作相对简单;
缺点:
- 1.需要维护两个集群,成本高;
- 2.切换是全量的,如果 V2 版本有问题,则对用户体验有直接影响;
三、选型
介绍:
方式 | 零停机 | 生产流量测试 | 针对特定用户 | 机器资源成本 | 回滚时长 | 负面影响 | 实现复杂度 |
---|---|---|---|---|---|---|---|
全量发布 | ❌ | ❌ | ❌ | 低 | 慢 | 高 | 低 |
蓝绿发布 | ✅ | ❌ | ❌ | 高(双倍) | 快 | 中 | 中 |
滚动发布 | ✅ | ✅ | ✅ | 中(按需) | 慢 | 低 | 高 |
金丝雀发布 | ✅ | ✅ | ✅ | 中(按需) | 快 | 低 | 中 |
全链路发布 | ✅ | ✅ | ✅ | 中(按需) | 快 | 低 | 高 |
- 全量发布:只有实现复杂度比价低,没有其他优势
- 滚动发布:发布和回退时间比较缓慢,用户体验比较平滑
- 蓝绿发布:适合于对于资源预算比较充足的业务,或者是比较简单的单体应用,可以快速实现系统的整体变更
- 金丝雀和全链路灰度:适合需要针对特定用户或者人群进行现网请求验证的业务,可以显著减低风险
总结:
建议选择 金丝雀或者全链路灰度 进行服务的升级发布。
四、全链路灰度思路实例讲解
灰度发布架构
- 环境:springcloud-nacos-feign-loadbalancer(ribbon)
- 简单描述一下灰度发布,这里有微服务order集群和微服务user集群,每个集群未标红的是生产实例,标红的代表发布的灰度版本实例;可以看到网络流量是通过微服务网关为入口,流入微服务集群,中间有个loadbalancer负载均衡器处理版本本次流量执行那个版本的order服务,所以这里微服务网关就负责过滤流量管理灰度规则等,流量经过微服务网关后,可以看到灰度流量就指向了灰度版本,微服务order通过RPC调用微服务user时也通过loadbalancer也将灰度流量发到了微服务user的灰度实例上。
- 通过上面的图和对于灰度发布架构的描述,我们可以看出实现微服务全链路灰度发布的核心是负载均衡器。
灰度发布实现思路
- order、user服务设置版本release-1.0.0,灰度版本release-2.0.0
- 请求header加version :release-2.0.0 会执行灰度版本实例
- 改造loadbalancer负载均衡策略,根据流量url获取路由服务名order从注册中心获取order服务
- 根据流量header的version筛选order服务的版本
- 将筛选过后的order服务负载请求
- 流量从order服务通过feign到user服务也是通过loadbalancer负载均衡策略将流量请求到对应的版本
代码实现
注意
- springcloud2021之后版本的openfeign取消了内置ribbon,2021版本以后的可以使用loadbalancer
1.服务基础配置、依赖包
-
gateway、order、user 服务添加依赖包
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
- order、user服务配置文件加版本配置
spring.cloud.nacos.discovery.metadata.version=release-2.0.0
2.网关配置
-
过滤器
@Slf4j
@Configuration
public class GrayReactiveLoadBalancerClientFilter implements GlobalFilter, Ordered {private static final int LOAD_BALANCER_CLIENT_FILTER_ORDER = 100;//负载均衡工厂@Autowiredprivate LoadBalancerClientFactory clientFactory;@Overridepublic int getOrder() {return LOAD_BALANCER_CLIENT_FILTER_ORDER;}@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 获取请求的Route对象Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);URI url = route.getUri();if (url != null && "lb".equals(url.getScheme())) {ServerWebExchangeUtils.addOriginalRequestUrl(exchange, url);if (log.isTraceEnabled()) {log.trace(ReactiveLoadBalancerClientFilter.class.getSimpleName() + " url before: " + url);}return this.choose(exchange).doOnNext((response) -> {//校验是否有服务信息if (!response.hasServer()) {throw NotFoundException.create(true, "Unable to find instance for " + url.getHost());} else {//协议 默认httpString overrideScheme = null;String schemePrefix = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR);if (schemePrefix != null) {overrideScheme = schemePrefix;}//创建一个DelegatingServiceInstance 实例, 该实例包装了选定的ServiceInstance并指定了协议DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(response.getServer(), overrideScheme);//使用DelegatingServiceInstance 实例和原始URI来构建目标请求的URIURI uri = exchange.getRequest().getURI();URI requestUrl = this.reconstructURI(serviceInstance, uri);if (log.isTraceEnabled()) {log.trace("LoadBalancerClientFilter url chosen: " + requestUrl);}// 将构建的目标请求URI存储到ServerWebExchange的属性中提供给后续的过滤器exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, requestUrl);}}).then(chain.filter(exchange));} else {return chain.filter(exchange);}}private Mono<Response<ServiceInstance>> choose(ServerWebExchange exchange) {// 获取请求的Route对象Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);//要请求的服务名String serviceId = null;if (route != null) {// 获取Route对象中的serviceIdserviceId = route.getUri().getHost();}//通过serviceId,获取服务提供者集合ObjectProvider<ServiceInstanceListSupplier> lazyProvider = clientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class);//创建负载均衡对象GrayLoadBalancer loadBalancer = new GrayLoadBalancer(lazyProvider, serviceId);if (loadBalancer == null) {throw new NotFoundException("No loadbalancer available for " + serviceId);} else {//执行负载均衡的方法return loadBalancer.choose(this.createRequest(exchange));}}/*** 获取Request* @param exchange* @return*/private Request createRequest(ServerWebExchange exchange) {HttpHeaders headers = exchange.getRequest().getHeaders();Request<HttpHeaders> request = new DefaultRequest<>(headers);return request;}protected URI reconstructURI(ServiceInstance serviceInstance, URI original) {return LoadBalancerUriTools.reconstructURI(serviceInstance, original);}
}
- 实现负载均衡算法,负载均衡策略改造
@Slf4j
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {private ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;private String serviceId;public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider, String serviceId) {this.serviceId = serviceId;this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;}@Overridepublic Mono<Response<ServiceInstance>> choose(Request request) {//获取请求头信息HttpHeaders headers = (HttpHeaders) request.getContext();//校验服务提供者集合是否为空if (this.serviceInstanceListSupplierProvider != null) {//转换服务提供者集合实例ServiceInstanceListSupplier supplier = this.serviceInstanceListSupplierProvider.getIfAvailable();//执行负载return supplier.get().next().map(list -> getInstanceResponse(list, headers));}return null;}/*** 校验实体是否为空* @param instances 服务集合* @param headers 请求头信息* @return*/private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, HttpHeaders headers) {if (instances.isEmpty()) {return getServiceInstanceEmptyResponse();} else {return getServiceInstanceResponseByVersion(instances, headers);}}/*** 根据版本进行选择** @param instances 服务信息* @param headers 请求头信息* @return*/private Response<ServiceInstance> getServiceInstanceResponseByVersion(List<ServiceInstance> instances, HttpHeaders headers) {//获取请求头version的值String versionNo = headers.getFirst("version");log.info("getServiceInstanceResponseByVersion--versionNo:" + this.serviceId);Map<String, String> versionMap = new HashMap<>();versionMap.put("version", versionNo);//创建不可变的Set集合final Set<Map.Entry<String, String>> attributes =Collections.unmodifiableSet(versionMap.entrySet());//校验获取符合version版本的服务ServiceInstance serviceInstance = null;for (ServiceInstance instance : instances) {Map<String, String> metadata = instance.getMetadata();if (metadata.entrySet().containsAll(attributes)) {serviceInstance = instance;break;}}//校验服务是否为空if (ObjectUtils.isEmpty(serviceInstance)) {return getServiceInstanceEmptyResponse();}//返回符合服务return new DefaultResponse(serviceInstance);}private Response<ServiceInstance> getServiceInstanceEmptyResponse() {log.warn("No servers available for service: " + this.serviceId);return new EmptyResponse();}
}
3.消费者服务通过feign到生产者服务,负载配置
-
启动类添加: 加载负载均衡配置
@LoadBalancerClients(defaultConfiguration = GllobalLoadbanlancerConfig.class)
配置类:负载均衡配置类
public class GllobalLoadbanlancerConfig {@Beanpublic ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(Environment environment, LoadBalancerClientFactory loadBalancerClientFactory) {String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);return new GrayLoadBalancer(loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name);}
}
实现负载均衡算法,负载均衡策略改造
@Slf4jpublic class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {private ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;private String serviceId;public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider, String serviceId) {this.serviceId = serviceId;this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;}@Overridepublic Mono<Response<ServiceInstance>> choose(Request request) {// HttpHeaders headers = (HttpHeaders) request.getContext();RequestDataContext context = (RequestDataContext) request.getContext();RequestData clientRequest = context.getClientRequest();HttpHeaders headers = clientRequest.getHeaders();if (this.serviceInstanceListSupplierProvider != null) {//获取调用的服务信息列表ServiceInstanceListSupplier supplier = this.serviceInstanceListSupplierProvider.getIfAvailable();//执行负载分发return supplier.get().next().map(list -> getInstanceResponse(list, headers));}return null;}/*** 校验实体是否为空* @param instances 服务信息* @param headers 请求头信息* @return*/private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, HttpHeaders headers) {if (instances.isEmpty()) {return getServiceInstanceEmptyResponse();} else {return getServiceInstanceResponseByVersion(instances, headers);}}/*** 根据版本进行分发** @param instances 服务信息* @param headers 请求头信息* @return*/private Response<ServiceInstance> getServiceInstanceResponseByVersion(List<ServiceInstance> instances, HttpHeaders headers) {//获取请求头version的值String versionNo = headers.getFirst("version");log.info("getServiceInstanceResponseByVersion--versionNo:" + this.serviceId);Map<String, String> versionMap = new HashMap<>();versionMap.put("version", versionNo);//创建不可变的Set集合final Set<Map.Entry<String, String>> attributes =Collections.unmodifiableSet(versionMap.entrySet());//校验获取符合version版本的服务ServiceInstance serviceInstance = null;for (ServiceInstance instance : instances) {Map<String, String> metadata = instance.getMetadata();if (metadata.entrySet().containsAll(attributes)) {serviceInstance = instance;break;}}//校验服务是否为空if (ObjectUtils.isEmpty(serviceInstance)) {return getServiceInstanceEmptyResponse();}//返回符合服务return new DefaultResponse(serviceInstance);}private Response<ServiceInstance> getServiceInstanceEmptyResponse() {log.warn("No servers available for service: " + this.serviceId);return new EmptyResponse();}}
请求头信息带入
@Component
public class FeignRequestInterceptor implements RequestInterceptor {//相当于是一个前置操作 处理请求头信息,记录请求日志、实现认证授权@Overridepublic void apply(RequestTemplate requestTemplate) {//获取请求参数ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();//拿到请求HttpServletRequest request = attributes.getRequest();//获取请求头参数 当然调用上面这条代码的 request是一样的Enumeration<String> headerNames = attributes.getRequest().getHeaderNames();//将获取的请求中的所有参数while(headerNames.hasMoreElements()){//将key先获取String key=headerNames.nextElement();//通过key 获得 valueString value=request.getHeader(key);//将获取的参数 放到header头中采用键值 key:value 放到请求模板中requestTemplate.header(key,value);}}
}
五、探讨内容
- 灰度部署需要更新配置文件如何解决
apollo配置也支持灰度拉取配置,不同的服务端节点拉取不同版本的apollo配置
- 灰度部署需要更新改表结构如何解决
表删除和变更先保留老字段,等发布完成后再删除老字段
- 灰度场景,按照用户灰度、按照ip灰度如何实现
拦截器中配置相对应的策略