服务上线后由于bug修复、扩容、或者发现了更好的方法进行了重构等原因,总免不了需要发布新版本,进行系统变更升级。服务变更过程本身也是引起服务不可用的重要原因。为了尽量降低可能出现故障而造成的损失,比较流行的思路是采用灰度发布策略,逐步增加流量导入新版本服务实例上,直至将所有流量切到新版本,下线旧版本。在文章 基于eureka注册中心实现服务无损优雅下线和升级 中,介绍了服务实例无损升级的方法,但是没有介绍逐步引流的方法。同时,spring cloud gateway作为整个系统的入口,在spring cloud gateway上实施流量管控策略,也是顺利成章。本文就尝试介绍基于spring cloud gateway的灰度发布方法。
1 部署和发布
在大家的一般印象中,服务的部署和发布是一体的,服务部署后就发布上线使用了,服务发布就是通过部署实现的。在考虑服务变更安全的时候,则可以严格将部署和发布分成两个不同的阶段。部署是指将服务程序和配置拷贝到目标机器,并启动程序运行。发布则是服务正式接入线上流量,处理用户请求。
常见的发布方式主要有:
1.1 滚动发布(Rolling Update)
逐个(或小批量)上线服务实例,每上线一个(或小批量)实例,则观察是否正常,正常后再上线下线一个(或小批量)实例,异常则进行回滚。这种方式着眼于逐步增加服务实例,而不是着眼于接入流量。如果系统总共N个实例,已上线M个实例,则新版本接入的流量为M/N
1.2 灰度发布(Gray Release或Dark Launch),又名金丝雀发布(Canary Deployment)
灰度发布与滚动发布相比,区别在于它更加考虑的是逐步将流量引入到新版本实例中,而不是发布了多少个新版本实例,下线了多少个旧版本实例。比如先将10%的流量引入新版本,然后增加到20%,然后50%,80%,100%等等,直到所有流量到引入到新版本中。
1.3 蓝绿发布(Blue Green Deployment)
蓝绿发布则是同时部署两套系统,当确保新版本系统已正常工作时,则将流量切到新版本系统(听上去有点不太靠谱)。
2 不同服务发布场景的灰度发布方式
服务需要进行变更(发布)的几个主要原因:
- 修复bug:这种情况服务的接口和功能都不变
- 重构:原先的功能使用新的方式实现,这种情况服务的接口和功能也不变
- 功能升级:这种情况服务实例提供了新的功能,往往新增了新的接口
- 扩容或缩容:这种情况服务实例的接口和功能也都不变,只是新增或减少了实例,导致流量在服务实例间重新分配
2.1 通过gateway的权重路由实现灰度发布
spring cloud gateway提供了一个Weight Route Predicate
,它支持将流量按照不同比例路由到不同的目的地。这种方式特别适合进行A/B
测试。比如同一个服务有A、B两个版本,分别注册为服务app-serer-a,app-server-b,那么可以如下配置gateway的route:
yaml
spring:
cloud:
gateway:
routes:
- id: app-server-a
uri: lb://app-server-a
predicates:
- Path=/app-server/**
- Weight=app-server, 50
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
- id: app-server-a
uri: lb://app-server-b
predicates:
- Path=/app-server/**
- Weight=app-server, 50
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
如上所示,A、B两个版本的流量各占50%,我们可以通过配置中心(如apollo、spring cloud config等)动态修改生效上述的权重配置,从而调整A、B两个版本的流量占比
如果A、B两个版本的接口可以通过接口的路径进行区分,则可以如下配置gateway的route:
yaml
spring:
cloud:
gateway:
routes:
- id: app-server-a
uri: lb://app-server/v1
predicates:
- Path=/app-server/**
- Weight=app-server, 50
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
- id: app-server-a
uri: lb://app-server/v2
predicates:
- Path=/app-server/**
- Weight=app-server, 50
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
2.2 通过loadbalancer的weightedServiceInstanceListSupplier实现
通过对不同的服务实例配置不同的权重值,loadbalancer在做负载均衡时,按照权重所占比例将流量负载均衡到不同的服务实例,原理请参考:自定义你的spring-cloud-loadbalancer负载均衡策略。假设版本A的所有实例的权重之和为80,版本B的所有实例的权重之和为20,那么版本A承载的流量站80%,版本B承载的流量占20%。通过动态调整两个版本的权重占比,就能调整两个版本的流量占比了。这种方式比较适合新旧版本的接口和功能都不变的变更场景。
这种方式gateway的route配置进行普通的配置即可:
yaml
spring:
cloud:
gateway:
routes:
- id: app-server
uri: lb://app-server
predicates:
- Path=/app-server/**
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
每个服务实例配置自己的weight值:
yaml
eureka:
instance:
metadata-map:
weight: 80
然后在gatway上配置服务的负载均衡策略为weightedServiceInstanceListSupplier
yaml
spring:
cloud:
loadbalancer:
clients:
app-server:
configurations: weighted
2.3 通过loadbalancer的hintBasedServiceInstanceListSupplier实现
loadbalancer的hintBasedServiceInstanceListSupplier会检查请求的X-SC-LB-Hint
头,将请求发送给metadata-map.hint
值与请求X-SC-LB-Hint
头值相同的服务实例,原理请参考:自定义你的spring-cloud-loadbalancer负载均衡策略。这样如果服务实例的eureka.instance.metadata-map.hint
配置为实例的版本号,就可以通过在请求的X-SC-LB-Hint
头中设置为相应的版本号,从而控制流量导入不同的版本服务实例。这种方式尤其适合于版本升级了新的功能,提供了新的接口,可以通过在请求头中指定版本号让新版本的请求只由新版本的实例处理。
这种方式gateway的route配置进行普通的配置即可:
yaml
spring:
cloud:
gateway:
routes:
- id: app-server
uri: lb://app-server
predicates:
- Path=/app-server/**
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
每个服务实例配置自己的版本值:
yaml
eureka:
instance:
metadata-map:
hint: v1
同时在gateway上添加以下配置和代码:
java
public class AppLoadBalancerClientConfiguration {
@Bean
public ServiceInstanceListSupplier hintBasedServiceInstanceListSupplier(ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder().withBlockingDiscoveryClient().withCaching().withHints()
.build(context);
}
}
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "app-server", configuration = AppLoadBalancerClientConfiguration.class)
})
public class AppClient1Application {
public static void main(String[] args) {
SpringApplication.run(AppClient1Application.class, args);
}
}
2.4 自定义loadbalancer的ServiceInstanceListSupplier,根据服务版本控制流量分配
上面的几种方法虽然可以实现特定情况下的流量在不同版本的服务实例间分配,但是都不太完美。灰度发布应该将新增接口(功能)调用全部导入新版本的服务实例,而保持不变的接口(功能)流量则在新旧版本实例间按设定的比例分配,然后逐渐调整新旧比例值,直到所有流量都迁移到新版本中。同时服务的名字需要保持不变,用户也不用感知到版本的变化(比如,不需要在请求中添加特殊的头部,用来标识需要调用的服务版本,实际调用哪个版本应该由系统对用户隐藏)。
为实现上述目的,我们可以在gateway中添加如下route配置:
yaml
spring:
cloud:
gateway:
routes:
- id: app-server-v2
uri: lb://app-server
predicates:
- Path=/app-server/v2/**
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
- AddRequestHeader=X-SC-LB-Hint,v2
- id: app-server-v1
uri: lb://app-server
predicates:
- Path=/app-server/**
filters:
- RewritePath=/app-server(?<segment>/?.*), $\{segment}
上面的route配置目的为:新增接口(假设全部的新增接口的路径前缀统一为v2)匹配app-server-v2路由,匹配后添加请求头X-SC-LB-Hint
,值为v2
,目的是让loadbalancer
基于hintBasedServiceInstanceListSupplier
的思路将请求都转发给v2版本的服务实例。保持不变的接口的路由配置则也保持不变,自定义的loadbalancer策略会根据配置比例将请求分发给不同版本的服务实例。
然后实现自定义的ServiceInstanceListSupplier
。它实现的功能为:如果请求中存在X-SC-LB-Hint
头,则从服务实例列表中查找所有的metadata-map.hint
值与请求X-SC-LB-Hint
头值相同的实例,然后返回找到的实例列表。如果请求中不存在在X-SC-LB-Hint
头,则根据配置的服务不同版本的流量分配比例,计算当前请求应该分配给哪个版本的服务实例,然后从服务实例列表中查找所有的metadata-map.hint
值等于选中版本号的服务实例,然后返回找到的实例列表。如果上面两种方式找到的服务实例列表均为空,则返回所有的服务实例。代码如下:
java
import com.google.common.base.Splitter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.HintRequestContext;
import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.RequestDataContext;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.cloud.loadbalancer.core.DelegatingServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
public class WeightedVersionServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier {
private static final Log LOG = LogFactory.getLog(WeightedVersionServiceInstanceListSupplier.class);
private final LoadBalancerProperties properties;
private final LinkedHashMap<Integer, String> rangeIndexes = new LinkedHashMap<>();
private final List<Double> ranges = new ArrayList<>();
private final Splitter splitter = Splitter.on(",").omitEmptyStrings().trimResults();
private final Random random = new Random();
private boolean validVersionWeight = false;
public WeightedVersionServiceInstanceListSupplier(ServiceInstanceListSupplier delegate,
ReactiveLoadBalancer.Factory<ServiceInstance> factory) {
super(delegate);
this.properties = factory.getProperties(getServiceId());
parseWeightedVersionConfig(this.properties);
}
@Override
public Flux<List<ServiceInstance>> get() {
return delegate.get();
}
@Override
public Flux<List<ServiceInstance>> get(Request request) {
return delegate.get(request).map(instances -> filteredByHintAndWeightedVersion(instances, getHint(request.getContext())));
}
/**
* 从 LoadBalancerProperties 中提取并解析 version-weight配置
* @param properties properties
*/
private void parseWeightedVersionConfig(LoadBalancerProperties properties) {
String versionWeights = properties.getHint().getOrDefault("version-weight", "");
if (versionWeights == null || versionWeights.isEmpty()) {
validVersionWeight = false;
return;
}
LinkedHashMap<String, Integer> weights = new LinkedHashMap<>();
LinkedHashMap<String, Double> normalizedWeights = new LinkedHashMap<>();
// version-weight: v1,80,v2,20
List<String> segments = splitter.splitToList(versionWeights);
int len = segments.size() & 0xFFFF_FFFE;
for (int i = 0; i < len; i += 2) {
try {
String version = segments.get(i);
Integer weight = Integer.valueOf(segments.get(i + 1));
weights.put(version, weight);
} catch (NumberFormatException e) {
validVersionWeight = false;
return;
}
}
// 权重总和
int weightsSum = 0;
for (Integer weight : weights.values()) {
weightsSum += weight;
}
// 权重占比
int index = 0;
for (Map.Entry<String, Integer> entry : weights.entrySet()) {
String version = entry.getKey();
Integer weight = entry.getValue();
Double nomalizedWeight = weight / (double) weightsSum;
normalizedWeights.put(version, nomalizedWeight);
rangeIndexes.put(index, version);
index = index + 1;
}
// version随机数区间
ranges.add(0.0);
List<Double> values = new ArrayList<>(normalizedWeights.values());
for (int i = 0; i < values.size(); i++) {
Double currentWeight = values.get(i);
Double previousRange = ranges.get(i);
Double range = previousRange + currentWeight;
ranges.add(range);
}
// 保证最后一个值大于1
ranges.set(values.size(), 1.1D);
validVersionWeight = true;
}
private String getHint(Object requestContext) {
if (requestContext == null) {
return null;
}
String hint = null;
if (requestContext instanceof RequestDataContext) {
hint = getHintFromHeader((RequestDataContext) requestContext);
}
if (!StringUtils.hasText(hint) && requestContext instanceof HintRequestContext) {
hint = ((HintRequestContext) requestContext).getHint();
}
return hint;
}
private String getHintFromHeader(RequestDataContext context) {
if (context.getClientRequest() != null) {
HttpHeaders headers = context.getClientRequest().getHeaders();
if (headers != null) {
return headers.getFirst(properties.getHintHeaderName());
}
}
return null;
}
/**
* 从实例列表中根据hint值过滤实例
* @param instances instances
* @param hint hint
* @return
*/
private List<ServiceInstance> filteredByHint(List<ServiceInstance> instances, String hint) {
List<ServiceInstance> filteredInstances = new ArrayList<>();
if (StringUtils.hasText(hint)) {
for (ServiceInstance serviceInstance : instances) {
if (serviceInstance.getMetadata().getOrDefault("hint", "").equals(hint)) {
filteredInstances.add(serviceInstance);
}
}
}
return filteredInstances;
}
private List<ServiceInstance> filteredByWeightedVersion(List<ServiceInstance> instances) {
List<ServiceInstance> filteredInstances = new ArrayList<>();
if (validVersionWeight) {
String hint = getHintFromWeightedVersion();
if (hint != null && !hint.isEmpty()) {
filteredInstances = filteredByHint(instances, hint);
}
}
return filteredInstances;
}
/**
* 计算版本号
* @return 版本号
*/
private String getHintFromWeightedVersion() {
double r = random.nextDouble();
// 循环所有区间,看当前随机数落入哪个区间
String selectedVersion = "";
for (int i = 0; i < ranges.size() - 1; i++) {
// 确定落入区间
if (r >= ranges.get(i) && r < ranges.get(i + 1)) {
selectedVersion = rangeIndexes.get(i);
break;
}
}
return selectedVersion;
}
private List<ServiceInstance> filteredByHintAndWeightedVersion(List<ServiceInstance> instances, String hint) {
// 从header中获取hint并过滤
List<ServiceInstance> filteredInstances = filteredByHint(instances, hint);
if (filteredInstances.size() > 0) {
return filteredInstances;
}
// 根据版本权重计算hint并过滤
filteredInstances = filteredByWeightedVersion(instances);
if (filteredInstances.size() > 0) {
return filteredInstances;
}
// 没有符合条件的实例,返回所有的
return instances;
}
}
然后将自定义WeightedVersionServiceInstanceListSupplier
注册到loadbalancer中
java
public class AppLoadBalancerClientConfiguration {
@Bean
public ServiceInstanceListSupplier weightedVersionServiceInstanceListSupplier(ConfigurableApplicationContext context) {
DelegateCreator creator = (context, delegate) -> {
LoadBalancerClientFactory loadBalancerClientFactory = context.getBean(LoadBalancerClientFactory.class);
return new WeightedVersionServiceInstanceListSupplier(delegate, loadBalancerClientFactory);
};
return ServiceInstanceListSupplier.builder().withBlockingDiscoveryClient().with(creator).withCaching()
.build(context);
}
}
@LoadBalancerClients(defaultConfiguration = AppLoadBalancerClientConfiguration.class)
@SpringBootApplication
public class AppClient1Application {
public static void main(String[] args) {
SpringApplication.run(AppClient1Application.class, args);
}
}
最后,服务实例的metadata-map.hint
配置相应的版本号:
yaml
eureka:
instance:
metadata-map:
hint: v2