微服務的灰度發佈就該這樣設計~

大家好,我是不才陳某~

實際生產中如有需求變更,並不會直接更新線上服務,最通常的做法便是:切出線上的小部分流量進行體驗測試,經過測試後無問題則全面的上線。

這樣做的好處也是非常明顯,一旦出現了 BUG,能夠保證大部分的客戶端正常使用。

要實現這種平滑過渡的方式就需要用到本篇文章介紹到的全鏈路灰度發佈。

什麼是灰度發佈?

灰度發佈(又名金絲雀發佈)是指在黑與白之間,能夠平滑過渡的一種發佈方式。在其上可以進行 A/B testing,即讓一部分用戶繼續用產品特性 A,一部分用戶開始用產品特性 B,如果用戶對 B 沒有什麼反對意見,那麼逐步擴大範圍,把所有用戶都遷移到 B 上面來。灰度發佈可以保證整體系統的穩定,在初始灰度的時候就可以發現、調整問題,以保證其影響度。

爲什麼是全鏈路灰度發佈?

在陳某前面一篇文章有介紹到網關的灰度發佈實現,僅僅是實現了網關路由轉發的灰度發佈,如下圖:

如上圖,網關灰度發佈實現的是網關通過灰度標記路由到文章服務 B(灰度服務),至於從文章服務 B 到評論服務是通過 openFeign 內部調用的,默認無法實現灰度標記 grayTag 的透傳,因此文章服務 B 最終調用的是評論服務 A,並不是評論服務 B。

全鏈路灰度發佈需要實現的是:

  1. 網關通過灰度標記將部分流量轉發給文章服務 B

  2. 文章服務 B 能夠實現灰度標記 grayTag 的透傳,最終調用評論服務 B

經過以上分析,全鏈路灰度發佈需要實現兩個點:

  1. 網關路由轉發實現灰度發佈

  2. 服務內部通過 openFeign 調用實現灰度發佈(透傳灰度標記 grayTag)。

下面將以陳某的《Spring Cloud Alibaba 實戰》專欄中的服務爲例進行灰度發佈配置。

網關層的灰度路由轉發

本篇文章將使用 Ribbon+Spring Cloud Gateway 進行改造負載均衡策略實現灰度發佈。

實現思路如下:

  1. 在網關的全局過濾器中根據業務規則給流量打上灰度標記

  2. 將灰度標記放入請求頭中,傳遞給下游服務

  3. 改造 Ribbon 負載均衡策略,根據流量標記從註冊中心獲取灰度服務

  4. 請求路由轉發

第一個問題:根據什麼條件打上灰度標記?

這個需要根據實際的業務需要,比如根據用戶所在的地區、使用客戶端類型、隨機截取流量.....

這裏我將直接使用一個標記 grayTag,只要客戶端請求頭中攜帶了這個參數,並且設置爲 true,則走灰度發佈邏輯。

請求頭中攜帶:grayTag=true

第二個問題:爲什麼要在請求頭中添加灰度標記傳遞給下游服務?

這一步非常關鍵,實現灰度標記透傳給下游服務的關鍵,將灰度標記放在請求頭中,下游服務只需要從請求頭中獲取灰度標記便知道是否是灰度發佈,這個和令牌中繼一個原理。

第三個問題:灰度標記如何請求隔離?

Spring MVC 中的每個請求都是開啓一個線程進行處理,因此可以將灰度標記放置在 ThreadLocal 中進行線程隔離。

第四個問題:如何知道註冊中心的服務哪個是灰度服務?

Nacos 支持在服務中配置一些元數據,可以將灰度標記配置在元數據中,這樣就能區分哪些是灰度服務,哪些是正常服務。

第五個問題:如何針對特定的服務進行灰度發佈?

比如我的《Spring Cloud Alibaba 實戰》中涉及的一條調用鏈路如下圖:

需求:現在只對文章服務、評論服務進行灰度發佈,其他服務依然使用線上正在運行的服務

此時的調用關係就變成了下圖:

我們知道網關路由中配置的服務很多,如何只針對文章服務進行灰度發佈呢?

很簡單:只需要將自定義的 Ribbon 灰度發佈規則只對文章服務生效。

這裏涉及到 Ribbon 中的一個註解:@RibbonClients ,只需要在其中的 value 屬性指定需要生效的服務名稱,那麼此時網關中的配置如下:

@RibbonClients(value ={
        //只對文章服務進行灰度發佈
        @RibbonClient(value = "article-server",configuration = GrayRuleConfig.class)
} )
@SpringBootApplication
public class GatewayApplication {
   
}

@RibbonClient 可以指定多個,這個註解有如下兩個屬性:

@RibbonClients 其中有一個屬性 defaultConfiguration,一旦使用這個屬性,那麼灰度發佈的策略對網關路由中配置的所有服務都將生效。

第六個問題:說了這麼多,具體如何實現?

網關中首先需要定義一個全局過濾器,僞代碼如下:

public class GlobalGrayFilter implements GlobalFilter{
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
         //① 解析請求頭,查看是否存在灰度發佈的請求頭信息,如果存在則將其放置在ThreadLocal中
        HttpHeaders headers = exchange.getRequest().getHeaders();
        if (headers.containsKey(GrayConstant.GRAY_HEADER)){
            String gray = headers.getFirst(GrayConstant.GRAY_HEADER);
            if (StrUtil.equals(gray,GrayConstant.GRAY_VALUE)){
                //②設置灰度標記
                GrayRequestContextHolder.setGrayTag(true);
            }
        }
       //③ 將灰度標記放入請求頭中
   ServerHttpRequest tokenRequest = exchange.getRequest().mutate()
    //將灰度標記傳遞過去
    .header(GrayConstant.GRAY_HEADER,GrayRequestContextHolder.getGrayTag().toString())
    .build();
            ServerWebExchange build = exchange.mutate().request(tokenRequest).build();
            return chain.filter(build);
    }
}

①處的代碼:從請求頭中獲取客戶端傳遞過來的灰度標記(這裏根據自己業務需要自行更改),判斷是否是灰度發佈

②處的代碼:GrayRequestContextHolder 則是自定義的 ThreadLocal 實現的線程隔離工具,用來存放灰度標記

③處的代碼:將灰度標記放置在請求頭中,傳遞給下游微服務,這裏是和令牌一個邏輯。

注意:這個全局過濾器一定要放在 OAuth2.0 鑑權過濾器之前,優先級要調高

全局過濾器中已經將灰度標記打上了,放置在 GrayRequestContextHolder 中,下面只需要改造 Ribbon 的負載均衡的策略去註冊中心選擇灰度服務。

創建 GrayRule,代碼如下:

/**
 * 灰度發佈的規則
 */
public class GrayRule extends ZoneAvoidanceRule {

    @Override
    public void initWithNiwsConfig(IClientConfig clientConfig) {
    }

    @Override
    public Server choose(Object key) {
        try {
            //從ThreadLocal中獲取灰度標記
            boolean grayTag = GrayRequestContextHolder.getGrayTag().get();
            //獲取所有可用服務
            List<Server> serverList = this.getLoadBalancer().getReachableServers();
            //灰度發佈的服務
            List<Server> grayServerList = new ArrayList<>();
            //正常的服務
            List<Server> normalServerList = new ArrayList<>();
            for(Server server : serverList) {
                NacosServer nacosServer = (NacosServer) server;
                //從nacos中獲取元素劇進行匹配
                if(nacosServer.getMetadata().containsKey(GrayConstant.GRAY_HEADER)
                        && nacosServer.getMetadata().get(GrayConstant.GRAY_HEADER).equals(GrayConstant.GRAY_VALUE)) {
                    grayServerList.add(server);
                } else {
                    normalServerList.add(server);
                }
            }
            //如果被標記爲灰度發佈,則調用灰度發佈的服務
            if(grayTag) {
                return originChoose(grayServerList,key);
            } else {
                return originChoose(normalServerList,key);
            }
        } finally {
            //清除灰度標記
            GrayRequestContextHolder.remove();
        }
    }

    private Server originChoose(List<Server> noMetaServerList, Object key) {
        Optional<Server> server = getPredicate().chooseRoundRobinAfterFiltering(noMetaServerList, key);
        if (server.isPresent()) {
            return server.get();
        } else {
            return null;
        }
    }
}

邏輯很簡單,如下:

  1. 獲取灰度標記

  2. 從 Nacos 註冊中心獲取灰度服務和正常服務

  3. 根據灰度標記去判斷,如果灰度發佈則選擇特定的灰度服務進行轉發

定義一個配置類,注入改造的灰度策略 GrayRule,如下:

/**
 * 灰度部署的負載規則配置類
 * 注意:這個類一定不要被Spring Boot 掃描進入IOC容器中,一旦掃描進入則對全部的服務都將生效
 */
public class GrayRuleConfig {
    @Bean
    public GrayRule grayRule(){
        return new GrayRule();
    }
}

注意:這個 GrayRuleConfig 不能被掃描進入 IOC 容器,一旦掃描進入則全局生效

因爲不僅僅網關需要用到這個灰度發佈策略,凡是涉及到 OpenFeign 調用的微服務如果需要配置灰度發佈都需要用到,因此這裏陳某定義了一個公用的 gray-starter。

經過上述步驟網關的灰度發佈則已經配置完成,此時只需要通過 @RibbonClients 指定對應哪個服務灰度發佈。

openFeign 透傳灰度標記

上面在介紹網關的灰度發佈配置時,是將灰度標記(grayTag=true)放在了請求頭中,因此在下游服務中需要做的就只是從請求頭中將灰度標記取出來,然後將其存入 GrayRequestContextHolder 上下文中。

這樣一來下游服務中的 GrayRule 則能從 GrayRequestContextHolder 獲取到灰度標記,從註冊中心獲取灰度服務進行調用了。

問題來了:如何從請求頭中取出灰度標記?

在介紹 OAuth2.0 相關知識時,曾經出過一篇文章:實戰!openFeign 如何實現全鏈路 JWT 令牌信息不丟失?

其中介紹了令牌中繼的解決方案,使用的是 openFeign 的請求攔截器去配置請求頭信息。

如上圖:openFeign 在調用時並不是用的原先的 Request,而是內部新建了一個 Request,其中複製了請求的 URL、請求參數一些信息,但是請求頭並沒有複製過去,因此 openFeign 調用會丟失請求頭中的信息。

但是可以通過實現 RequestInterceptor 將原先的請求頭給複製過去,代碼如下:

@Component
@Slf4j
public class FeignRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        HttpServletRequest httpServletRequest = RequestContextUtils.getRequest();
        Map<String, String> headers = getHeaders(httpServletRequest);
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            //② 設置請求頭到新的Request中
            template.header(entry.getKey(), entry.getValue());
        }
    }

    /**
     * 獲取原請求頭
     */
    private Map<String, String> getHeaders(HttpServletRequest request) {
        Map<String, String> map = new LinkedHashMap<>();
        Enumeration<String> enumeration = request.getHeaderNames();
        if (enumeration != null) {
            while (enumeration.hasMoreElements()) {
                String key = enumeration.nextElement();
                String value = request.getHeader(key);
                //將灰度標記的請求頭透傳給下個服務
                if (StrUtil.equals(GrayConstant.GRAY_HEADER,key)&&Boolean.TRUE.toString().equals(value)){
                    //① 保存灰度發佈的標記
                    GrayRequestContextHolder.setGrayTag(true);
                    map.put(key, value);
                }
            }
        }
        return map;
    }
}

①處的代碼:從請求頭中獲取灰度發佈的標記,設置到 GrayRequestContextHolder 上下文中

②處的代碼:將這個請求頭設置到新的 Request 中,繼續向下遊服務傳遞。

其實配置一下 RequestInterceptor 就已經完成了,關於灰度發佈策略只需要複用網關的 GrayRule

注意:也需要使用 @RibbonClients 註解去標註文章服務調用的哪些服務需要灰度發佈。

代碼如下:

@RibbonClients(value = {
        //指定對comments這個服務開啓灰度部署
        @RibbonClient(value = "comments",configuration = GrayRuleConfig.class)
})
public class ArticleApplication {}

Nacos 中服務如何做灰度標記

其實很簡單,分爲兩種:

1、在配置文件中指定,如下:

spring:
  cloud:
    nacos:
      discovery:
        metadata:
          ## 灰度標記
          grayTag: true

2、在 Nacos 中動態的指定灰度標記

配置完成之後,在客戶端請求的時候只需要攜帶 grayTag=true 這個請求頭即可調用灰度服務。

總結

微服務中全鏈路灰度發佈方案其實很簡單,重要的就是灰度打標,整體流程如下:

  1. 網關中通過全局過濾器實現灰度打標,將灰度標記放入請求頭中傳遞給下游服務

  2. 網關通過自定義的負載均衡策略,從註冊中心獲取灰度服務,進行轉發

  3. 在 openFeign 調用時需要從請求頭中獲取灰度標記,放入上下文中

  4. openFeign 調用同樣是根據自定義的負載均衡策略從註冊中心獲取灰度服務,進行調用

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WuUjk_-gneGWLfyxLJuv2w