全鏈路灰度發布:Spring Cloud + Nacos 實踐
灰度發布, 也叫金絲雀發布。是指在黑與白之間,能夠平滑過渡的一種發布方式。AB test就是一種灰度發布方式,讓一部分用戶繼續用A,一部分用戶開始用B,如果用戶對B沒有什么反對意見,那么逐步擴大范圍,把所有用戶都遷移到B上面來。
灰度發布可以保證整體系統的穩定,在初始灰度的時候就可以發現、調整問題,以保證其影響度,而我們平常所說的金絲雀部署也就是灰度發布的一種方式。
具體到服務器上,實際操作中還可以做更多控制,譬如說,給最初更新的10臺服務器設置較低的權重、控制發送給這10臺服務器的請求數,然后逐漸提高權重、增加請求數。一種平滑過渡的思路, 這個控制叫做“流量切分”。
組件版本說明
我們這項目已經練習了兩年半了使用的版本不是很新,我這里的Demo也會使用這個版本,有感情了,使用新版本的朋友自己調整一下就行,實現思路是一樣的只是這些框架源碼可能會有變化。
- spring-boot: 2.3.12.RELEASE
- spring-cloud-dependencies: Hoxton.SR12
- spring-cloud-alibaba-dependencies: 2.2.9.RELEASE
核心組件說明
- 注冊中心: Nacos
- 網關: SpringCloudGateway
- 負載均衡器: Ribbon (使用SpringCloudLoadBalancer實現也是類似的)
- 服務間RPC調用: OpenFeign
灰度發布代碼實現
要實現Spring Cloud項目灰度發布技術方案有很多,重點在于服務發現,怎么將灰度流量只請求到灰度服務,這里我們會使用Nacos作為注冊中心和配置中心,核心就是利用Nacos的Metadata設置一個version值,在調用下游服務是通過version值來區分要調用那個版本,這里會省略一些流程,文章末尾提供了源碼地址需要自提。
圖片
代碼設計結構
這個是demo項目,結構都按最簡單的來。
spring-cloud-gray-example // 父工程
kerwin-common // 項目公共模塊
kerwin-gateway // 微服務網關
kerwin-order // 訂單模塊
order-app // 訂單業務服務
kerwin-starter // 自定義springboot starter模塊
spring-cloud-starter-kerwin-gray // 灰度發布starter包 (核心代碼都在這里)
kerwin-user // 用戶模塊
user-app // 用戶業務服務
user-client // 用戶client(Feign和DTO)
核心包spring-cloud-starter-kerwin-gray結構介紹
圖片
入口Spring Cloud Gateway實現灰度發布設計(一些基礎信息類在下面)
在請求進入網關時開始對是否要請求灰度版本進行判斷,通過Spring Cloud Gateway的過濾器實現,在調用下游服務時重寫一個Ribbon的負載均衡器實現調用時對灰度狀態進行判斷。
存取請求灰度標記Holder(業務服務也是使用的這個)
使用ThreadLocal記錄每個請求線程的灰度標記,會在前置過濾器中將標記設置到ThreadLocal中。
public class GrayFlagRequestHolder {
/**
* 標記是否使用灰度版本
* 具體描述請查看 {@link com.kerwin.gray.enums.GrayStatusEnum}
*/
privatestaticfinal 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)) {
returntrue;
}
returnfalse;
}
/**
* 校驗自定義灰度版本請求頭判斷是否需要調用灰度版本
*/
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))) {
returntrue;
}
}
returnfalse;
}
/**
* 校驗自定義灰度版本IP數組判斷是否需要調用灰度版本
*/
private boolean checkGrayIPList(ServerHttpRequest request) {
List<String> grayIPList = grayGatewayProperties.getGrayIPList();
if (CollectionUtils.isEmpty(grayIPList)) {
returnfalse;
}
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)) {
returntrue;
}
returnfalse;
}
/**
* 校驗自定義灰度版本城市數組判斷是否需要調用灰度版本
*/
private boolean checkGrayCiryList(ServerHttpRequest request) {
List<String> grayCityList = grayGatewayProperties.getGrayCityList();
if (CollectionUtils.isEmpty(grayCityList)) {
returnfalse;
}
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)) {
returntrue;
}
returnfalse;
}
/**
* 校驗自定義灰度版本用戶編號數組(我們系統不會在網關獲取用戶編號這種方法如果需要可以自己實現一下)
*/
private boolean checkGrayUserNoList(ServerHttpRequest request) {
List<String> grayUserNoList = grayGatewayProperties.getGrayUserNoList();
if (CollectionUtils.isEmpty(grayUserNoList)) {
returnfalse;
}
returnfalse;
}
@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;
}
}
自定義Ribbon負載均衡路由(業務服務也是使用的這個)
「灰度Ribbon負載均衡路由抽象類:」 這里提供了兩個獲取服務列表的方法,會對GrayFlagRequestHolder 中存儲的當前線程灰度狀態枚舉進行判斷,如果枚舉值為GrayStatusEnum.ALL則響應全部服務列表不區分版本,如果枚舉值為GrayStatusEnum.PROD則返回生產版本的服務列表,如果枚舉值為GrayStatusEnum.GRAY則返回灰度版本的服務列表,版本號會在GrayVersionProperties 中配置,通過服務列表中在Nacos的metadata中設置的version和GrayVersionProperties的版本號進行匹配出對應版本的服務列表。
public abstractclass 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) {
returnnew ArrayList<>();
}
List<Server> reachableServers = lb.getReachableServers();
return getGrayServers(reachableServers);
}
/**
* 所有已知的服務器,可訪問和不可訪問,并對灰度標識進行判斷
*/
public List<Server> getAllServers() {
ILoadBalancer lb = getLoadBalancer();
if (lb == null) {
returnnew 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;
}
}
「自定義輪詢算法實現GrayRoundRobinRule:」 代碼篇幅太長了這里只截取代碼片段,我這里是直接拷貝了Ribbon的輪詢算法,將里面獲取服務列表的方法換成了自定義AbstractGrayLoadBalancerRule 中的方法,其它算法也可以通過類似的方式實現。
圖片
業務服務實現灰度發布設計
自定義SpringMVC請求攔截器
自定義SpringMVC請求攔截器獲取上游服務的灰度請求頭,如果獲取到則設置到GrayFlagRequestHolder 中,之后如果有后續的RPC調用同樣的將灰度標記傳遞下去。
@SuppressWarnings("all")
publicclass 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));
}
returntrue;
}
@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請求攔截器
自定義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){
returnnull;
}
for (GrayStatusEnum value : values()) {
if(value.val.equals(val)){
return value;
}
}
returnnull;
}
}
- 網關灰度配置信息類
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.gateway")
publicclass 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() {
returnnew GrayGatewayBeginFilter();
}
@Bean
public GrayGatewayAfterFilter grayGatewayAfterFilter() {
returnnew GrayGatewayAfterFilter();
}
@Bean
public GrayGatewayExceptionHandler grayGatewayExceptionHandler(){
returnnew GrayGatewayExceptionHandler();
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(value = WebMvcConfigurer.class)
static class GrayWebMvcAutoConfiguration {
/**
* Spring MVC 請求攔截器
* @return WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer webMvcConfigurer() {
returnnew 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() {
returnnew GrayFeignRequestInterceptor();
}
}
}
項目運行配置
這里我會啟動五個服務,一個網關服務、一個用戶服務V1版本、一個訂單服務V1版本、一個用戶服務V2版本、一個訂單服務V2版本,來演示灰度發布效果。
PS:Nacos的命名空間我這里叫spring-cloud-gray-example可以自己創建一個也可以換成自己的命名空間,源碼里面配置都是存在的,有問題看源碼就行。
配置Nacos全局配置文件(common-config.yaml)
所有服務都會使用到這個配置:
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
圖片
配置網關Nacos配置文件(gateway-app.yaml)
kerwin:
tool:
gray:
gateway:
## 是否開啟灰度發布功能
enabled:true
## 自定義灰度版本請求頭
grayHeadKey:gray
## 自定義灰度版本請求頭匹配值
grayHeadValue:gray-996
## 使用灰度版本IP數組
grayIPList:
-'127.0.0.1'
## 使用灰度版本城市數組
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.versinotallow=V1,用戶服務V2版本配置為-Dserver.port=7202 -Dspring.cloud.nacos.discovery.metadata.versinotallow=V2,訂單服務配置類似,配置好后點Apply。
圖片
最后啟動好的服務信息。
圖片
灰度效果演示
源碼中的user-app提供了一個獲取用戶信息的接口并且會攜帶當前服務的端口和版本信息,order-app服務提供了一個獲取訂單信息的接口,會去遠程調用user-app獲取訂單關聯的用戶信息,并且也會攜帶當前服務的端口和版本信息響應。
場景一(關閉灰度開關:不區分調用服務版本)
關閉灰度開關有兩個配置可以實現:
1、在項目啟動之前修改Nacos全局配置文件中的kerwin.tool.gray.load 配置是否加載灰度自動配置類,只要配置不為true就不會加載整個灰度相關類
圖片
2、關閉網關灰度開關,修改網關Nacos配置文件中的kerwin.tool.gray.gateway.enabled ,只要配置不為true就不會進行灰度判斷。
調用演示
這里調用不一定就是Order服務版本為V1 User服務版本也為V1,也有可能Order服務版本為V1 User服務版本也為V2.
- 第一次調用,Order服務版本為V1,User服務版本也為V1。
圖片
- 第二次調用,Order服務版本為V2,User服務版本也為V2。
圖片
場景二(開啟灰度開關:只調用生產版本)
修改網關Nacos配置文件中的kerwin.tool.gray.gateway.enabled 設置為true,其它灰度IP數組和城市數組配置匹配不上就行,這樣怎么調用都是V1版本,因為在GrayVersionProperties版本配置中設置的生產版本就是為V1灰度版本為V2。
圖片
圖片
場景三(開啟灰度開關:通過請求頭、ip、城市匹配調用灰度版本)
這里通過請求頭測試,攜帶請求頭gray=gray-996訪問網關那么流量就會都進入灰度版本V2。