天天看點

測試環境多泳道的實踐

作者:閃念基因

一、背景

優鮮非常重視測試環境治理,提高開發和測試人員的使用效率。從 2018 年就開始了測試環境治理之路,也有幸的見證了其中幾個階段,最早期,2018 年初測試環境當時幾台虛機,把需要測試的服務部署上去,經常發生搶占問題,剛部署服務分支又被别人覆寫,測試完流程才發現代碼部署不正确,造成效率非常低下;

2018 年年中開始對測試環境進行治理,初始化了十幾套域名和機器,用域名隔離環境,各業務線通過釋出系統搶占環境後使用,這樣暫時緩解了代碼被覆寫的問題,但是搶占問題依舊十分嚴重;

2020 年開啟了第二波整治,借助 Docker 的快速擴充能力開始對測試環境新一輪的治理,通過自研的環境管理系統(以下簡稱阿拉丁系統)可以拉起一套完全隔離的全鍊路環境,資源隔離好,但是随着全鍊路環境的增多,任何一個全鍊路環境問題可能都需要開發去檢視,給開發和測試也增加了維護成本。目前,我們正着手新一輪的測試環境治理工作,緻力于提高測試環境使用效率。

二、多泳道介紹

2.1、什麼是多泳道

多泳道結構就是借鑒了遊泳比賽的概念,所有運動員在一個泳池内,劃分了比賽賽道,誰超出賽道就算犯規。那麼抽象到設計中就是,一套完整的服務鍊路就是一個泳道,而請求資料就是運動員,在自己泳道中任意流轉,不會幹擾其他泳道的資料。再進一步抽象,分離出主泳道和分支泳道概念,目的是把通用的服務集中在主泳道,分支泳道部署變動服務,并且依賴主泳道的通用服務,内部通過實體或者邏輯隔離請求資料。

2.2、多泳道的目标

一方面是保證測試環境穩定,提高測試效率。聯想到測試環境,大多數反應是測試環境不穩定,主要由以下原因造成,一方面測試環境部署的是開發中代碼,代碼的不穩定;一方面測試環境存在髒資料;一方面是對測試環境的不重視,缺少監控等;另一方面的測試的機器資源沒法和線上對齊等因素,導緻測試環境必然是不穩定的。是以多泳道方案提出了主泳道和分支泳道概念,主泳道則部署穩定代碼和線上代碼保持一緻,保證了代碼的穩定性,然後開發人員隻用專心維護一套全鍊路環境,降低了維護成本,提高了主泳道的穩定性。

另一方面解決搶占問題,提高開發效率。在多業務線多需求同時開發時,都需要各自一套環境隔離,互相不造成影響,但測試環境的機器是有限,就會造成大家互相搶占,或者共用同一環境,造成測試效率降低。是以多泳道方案提出一種規範,收回開發人員部署主泳道權限,由系統定期自動部署最新穩定代碼,而開發人員隻在分支泳道部署變動服務,其餘全部依賴主泳道中服務,減少部署量和資源浪費,由于分支泳道非常輕量,可以非常快捷建立和銷毀。

2.3、多泳道的價值收益

經過多業務小組驗證,在測試環境建立時間方面至少提升 10 倍,原來建立一套測試環境需要拷貝底層資料庫,拉取全鍊路所有服務等,耗時大概 3 小時,但是泳道方案拉起一個分支泳道隻需幾分鐘。由于分支泳道的快捷性,可以建立更多的分支泳道給不同業務組或者同業務組不同需求。總結一下,就是更快更多更穩定。

三、技術方案

3.1、系統架構

測試環境多泳道的實踐

架構主要包括三部分,網關層,RPC 層和資料層。

  1. 網關層主要負責環境識别與環境辨別注入,前台通過測試域名隔離環境,例如:b2.missfresh.net/xx,請求到網關後解析出環境辨別 b2,然後植入 HTTP Header 中,往下透傳。
  2. RPC 層主要負責服務發現與選擇,環境辨別透傳等。通過服務發現找到對應環境下服務,再通過自定義路由政策選擇指定服務執行,并把環境辨別繼續透傳給下遊。
  3. 資料層主要負責測試環境資料隔離與共用。

邏輯結構主要分為主泳道和分支泳道。

  1. 主泳道部署全鍊路穩定代碼,作為公共環境,承載其他環境預設服務,保證請求鍊路通暢。
  2. 分支泳道隻需部署改動服務,未改動服務使用主泳道中服務,比如底層的商品,庫存等服務,發号器,推送等元件,目的是減少公共服務的維護成本,提高使用效率等。

根據以上流程,需要對元件做一些改動:

  1. 網關新增環境辨別注入。測試環境使用開源的流量網關(Kong),然後額外定制一個插件,解析域名并把環境辨別注入到 HTTP Header 中,往下透傳。
  2. 鍊路辨別的透傳。使用開源的鍊路追蹤系統(Pinpoint),新增或者增強鍊路透傳插件,把鍊路辨別透傳下去。選擇 Pinpoint 是因為它以 JavaAgent 方式嵌入到服務中,對服務是無感覺的,可以結合部署系統做到無感覺更新,比使用 SDK 方式更友好。
  3. 服務的感覺。共用一套 Zookeeper,保證各泳道服務被及時發現,各泳道服務注冊時帶上環境辨別。
  4. 服務的選擇。使用 Dubbom 新增的路由政策,根據服務自身環境辨別和鍊路中的環境辨別做比對選擇。
  5. 資料存儲。不同的需求需要隔離級别不同,如果多環境共用底層資料,則代碼中使用域名配置資料庫,由 DNS 服務指向同一套資料庫,例如:配置 b2.mysql.missfresh.net 與 b15.mysql.missfresh.net 域名指向同一執行個體 IP;如果多套環境隔離底層資料,則 MySQL,Redis 需要封裝一套 SDK,通過環境辨別把資料寫到不同的庫或者執行個體,RocketMQ 則需要封裝一套 SDK,通過環境辨別把消息發往不同的隊列,ElasticSearch 則可以在上一層封裝一套網關,通過網關的路由功能轉發到不同索引或者執行個體。

下面給大家分解下各個子產品的隔離方案。

3.2、服務隔離

進行服務隔離有兩個方向,實體隔離與邏輯隔離。

方案1、實體隔離可以通過部署多套 Zookeeper 隔離,讓 Consumer 隻在目前 Zookeeper 中擷取 Provider 清單,然後調用,達到環境隔離效果。

測試環境多泳道的實踐

方案2、邏輯隔離可以通過辨別 Provider 與 Consumer,再通過自定義負載均衡算法,讓 Consumer 調用指定的 Provider 服務,達到環境隔離的效果。

測試環境多泳道的實踐

實體隔離的優勢是隔離性好,弊端是每套環境都需要一套 Zookeeper,這樣在快速建立環境時會影響效率,還有個弊端是為了達到 3.1 架構圖中綠線的流程,需要讓主泳道的請求再扭轉回分支泳道,主泳道的服務就必須監聽所有分支泳道的 Zookeeper,這樣才能監聽分支環境服務的存活情況,但這樣會造成主泳道非常臃腫,代碼實作也非常複雜。是以,我們選擇了邏輯隔離的方案,在 Provider 注冊時添加一個辨別,在 Consumer 擷取 Provider 清單後,通過自定義負載均衡算法,找出指定環境 Provider 并調用。

方案确定後,我們需要做的就是對 Dubbom 進行一些改造。第一步,在容器中注入環境辨別,可以通過環境變量,也可以通過本地配置檔案,目的是讓服務啟動後能感覺到目前容器屬于哪個泳道。第二步,在 Provider 注冊時擷取目前環境辨別,然後在 ServiceConfig.doExportUrls() 生成注冊連結時添加一個參數(zone)用來辨別目前環境。生成的注冊連結如下:

dubbo://127.0.0.1:10080/com.missfresh.xxxxService?anyhost=true&application=mryx&bean.name=ServiceBean:com.missfresh.xxxxService:1.0&default.dispatcher=message&default.service.filter=notice&default.threadpool=fixed&default.threads=300&default.timeout=1000&dubbo=2.0.2&interface=com.missfresh.mpush.xxxxService&logger=slf4j&methods=xxxx&pid=1®istry=127.0.0.1:2181&revision=1.0.0&side=provider×tamp=1622925206798&version=1.0&zone=b2
           

第三步,再實作路由算法,算法邏輯不難,Consumer 通過目前環境辨別找到對應的 Provider 即可。需要注意的是,Dubbom 既可以通過 Router 方式也可以通過 LoadBalance 方式實作路由政策,差別在于 Route 在 LoadBalance 更上一層,控制力度更大,比如在多注冊中心的場景下,Route 方式可以路由到不同注冊中心,而 LoadBalance 方式隻能選擇經過 Router 篩選過後的 Provider 清單,并不能動态選擇多注冊中心,這種差別也會用于後續的同城雙活方案,是以選擇了 Router 去實作。

測試環境多泳道的實踐

3.3、消息隔離

先介紹下 RocketMQ 的結構,實體結構包括 NameServer 與 Broker,而邏輯結構包括 Topic 與 Queue,Topic 包含多個 Queue,Queue 又分布在不同 Broker 上保證高可用。針對實體與邏輯結構可以設計出不同隔離方案,下面有三種隔離方案供參考。

測試環境多泳道的實踐

3.3.1、Queue 隔離

Queue 的隔離思想是讓每個泳道使用指定的 Queue,例如泳道 b1 隻在 queue1 和 queue2 上收發消息,泳道 b2 隻在 queue3 和 queue4 上收發消息。實作這種效果,需要重寫 Producer 與 Consumer 的負載均衡算法,配置設定指定的 Queue 進行收發消息。弊端就是每個泳道環境需要擴容 Queue,提高消費能力就比較棘手,而且随着泳道增多,Queue 數量也需要動态增加。

測試環境多泳道的實踐

3.3.2、Broker 隔離

Broker 隔離的思想與 Queue 隔離思想類似,也是讓每個泳道使用指定的 Broker,通過 Broker 實體級别隔離消息,然後各泳道的收發消息都在指定泳道。實作這種效果需要标記 Broker 與 Producer 和 Consumer 屬于哪個環境,可以通過命名規則實作,再重寫 Producer 與 Consumer 的負載均衡算法,把 Broker 與 Producer 和 Consumer 做親和處理即可。弊端是每個環境需要部署 Broker。

測試環境多泳道的實踐

3.3.3、消息網關隔離

消息網關方案的思想是通過加一層代理去屏蔽 Queue 的選擇等,用戶端隻需帶上環境辨別收發消息即可,由網關決定消息去向與消費邏輯。弊端在于需要為測試環境開發一套網關系統,周期比較長。

測試環境多泳道的實踐

綜上,最終選擇了 Broker 隔離方案。一方面是 Queue 擴充性的考慮,另一方面是為測試環境建構一個消息網關周期較長,開發成本高,最後也可以提前驗證同城雙活的方案,因為同城雙活方案的部署結構與 Broker 隔離結構一緻,用 Broker 隔離兩地機房中的消息,并且兩機房間也需要互相兜底消費。

方案确定後,剩下就是對 MQ-SDK 進行一些改造。第一步,規範 Broker 名稱與 Producer,Consumer 的執行個體名稱,讓名稱中帶上環境辨別,規則代碼如下:

/**

 * 根據目前環境生成唯一辨別

 * @return {目前環境}-{是否基準環境}-{PID}-{自增保證唯一}

 */

public static String genInstanceName() {

    String instanceName = String.valueOf(UtilAll.getPid()) + SPLIT + COUNT.incrementAndGet();

    instanceName = (Boolean.TRUE.toString().equalsIgnoreCase(benchmark) ? "1" : "0") + SPLIT + instanceName;

    instanceName = (StringUtils.isNotEmpty(zone) ? zone : DEFAULT_ZONE) + SPLIT + instanceName;

    return instanceName;

}

           

最後生成每個執行個體的 ClientId 效果:

測試環境多泳道的實踐

第二步,重寫 Producer 負載均衡算法。實作 MessageQueueSelector 接口 select 方法,從所有的隊列中選出指定的隊列,然後在發送的時候指定負載政策即可。例如目前環境是 b2,則把所有 Broker 名稱是 b2 字首的隊列全傳回。主要代碼如下:

protected List<MessageQueue> groupByZone(List<MessageQueue> mqs) {

    // 優先從鍊路中擷取環境辨別

    String zone = Extractor.getGray();




    List<MessageQueue> localQueueList = new ArrayList<>(mqs.size());

    List<MessageQueue> benchmarkQueueList = new ArrayList<>(mqs.size());

    for (MessageQueue messageQueue : mqs) {

        String[] brokerNameArray = messageQueue.getBrokerName().split(MryxConfig.SPLIT);

        String queuePrefix = brokerNameArray[0];

        if (zone.equalsIgnoreCase(queuePrefix)) {

            // 目前環境隊列

            localQueueList.add(messageQueue);

        } else if (brokerNameArray.length > 2 && RocketMQConfig.IS_BENCHMARK.equals(brokerNameArray[1])) {

            // 基準環境隊列

            benchmarkQueueList.add(messageQueue);

        }

    }

    

    if (!localQueueList.isEmpty()) {

        return localQueueList;

    }

    if (!benchmarkQueueList.isEmpty()) {

        return benchmarkQueueList;

    }

    return mqs;

}

           

第三步,重寫 Consumer 負載均衡算法。實作 AllocateMessageQueueStrategy 接口的 allocate 方法,從所有的隊列中選擇指定的隊列消費。例如目前環境是 b2,則隻消費所有 Broker 名稱是 b2 字首的隊列,達到消費隔離目的。主要代碼如下:

@Override

public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll, List<String> cidAll) {

    // 根據messageQuery中brokerName的環境辨別分組

    Map<String/*machine zone */, List<MessageQueue>> mr2Mq = new TreeMap<>();

    for (MessageQueue mq : mqAll) {

        String brokerMachineZone = machineRoomResolver.brokerDeployIn(mq);

        mr2Mq.putIfAbsent(brokerMachineZone, new ArrayList<>());

        mr2Mq.get(brokerMachineZone).add(mq);

    }




    // 根據clientId的環境辨別分組

    Map<String/*machine zone */, List<String/*clientId*/>> mr2c = new TreeMap<>();

    // 基準環境的clientId

    List<String> benchmarkClientIds = new ArrayList<>();

    for (String cid : cidAll) {

        String consumerMachineZone = machineRoomResolver.consumerDeployIn(cid);

        mr2c.putIfAbsent(consumerMachineZone, new ArrayList<>());

        mr2c.get(consumerMachineZone).add(cid);

        if (machineRoomResolver.consumerIsBenchmark(cid)) {

            benchmarkClientIds.add(cid);

        }

    }




    List<MessageQueue> allocateResults = new ArrayList<>();

    // 1、比對同機房的隊列

    String currentMachineZone = machineRoomResolver.consumerDeployIn(currentCID);

    List<MessageQueue> mqInThisMachineZone = mr2Mq.remove(currentMachineZone);

    List<String> consumerInThisMachineZone = mr2c.get(currentMachineZone);

    if (mqInThisMachineZone != null && !mqInThisMachineZone.isEmpty()) {

        allocateResults.addAll(allocateMessageQueueStrategy.allocate(consumerGroup, currentCID, mqInThisMachineZone, consumerInThisMachineZone));

    }

    

    // 尋找沒有比對上zone的MessageQueueList

    for (String machineZone : mr2Mq.keySet()) {

        if (mr2c.containsKey(machineZone)) {

            continue;

        }

        // 2、如果存在基準環境consumer,則把沒有消費者的messageQueue配置設定給基準環境

        if (!benchmarkClientIds.isEmpty()) {

            if (machineRoomResolver.consumerIsBenchmark(currentCID)) {

                allocateResults.addAll(allocateMessageQueueStrategy.allocate(consumerGroup, currentCID, mr2Mq.get(machineZone), benchmarkClientIds));

            }

        } else {

            // 3、如果沒有基準環境,則沒有消費者的messageQueue再次配置設定給consumer

            allocateResults.addAll(allocateMessageQueueStrategy.allocate(consumerGroup, currentCID, mr2Mq.get(machineZone), cidAll));

        }

    }

    return allocateResults;

}

           

第四步,主流程通暢後,還需要考慮一下特殊情況,由于分支泳道非全鍊路,是以分支泳道可能上遊的 Producer 未部署,或者下遊的 Consumer 未部署,為了保證鍊路通暢,就需要一套主泳道兜底邏輯,如果分支泳道 Producer 未部署,則由主泳道根據鍊路辨別發送到對應 Broker 中,圖 3.3.2 中橙色的線;如果分支泳道 Consumer 未部署,則由主泳道消費,保證鍊路通暢,圖 3.3.2 中綠色的線。以上,就是消息隔離的方案。

3.4、存儲隔離

存儲隔離主要使用實體隔離方案,實作比較簡單,例如代碼中用域名指向資料庫位址,容器建立時候可以在配置 host 指定真是資料庫位址;還有一種方案是代碼中配置的一個變量,由容器配置環境變量,最後在代碼中讀取變量時替換成真實位址。邏輯隔離方案可能會涉及到修改業務代碼,工作繁瑣,業務穩定性也存在問題,是以很少使用這種方案

由于不同業務對測試環境資料隔離性需求不同,一些團隊隻想維護一份底層資料,有些團隊則需要資料隔離跑自動化測試等。目前,泳道還是采用的底層共用資料存儲,好處是每次新建立分支用不到不用再建立資料庫和同步資料,大大提高了環境申請和銷毀效率。對于自動化測試等需要資料隔離的,我們則另外部署一套全鍊路環境。

四、挑戰

1.泳道方案依賴 Pinpoint 鍊路跟蹤系統的透傳功能,線上程池場景會丢失鍊路資訊,經過多方調研,組内同學通過位元組碼加強的方式得以解決。

2.元件更新,由于改造了 Dubbom,MQ-client 等元件,需要統一元件,其中涉及到元件遷移,相容性等問題,我們深度參與業務方改造,借助自動化測試,驗證更新元件後的穩定性。

五、未來展望

1.測試環境的自動化。目前申請或者銷毀新泳道還不夠自動化,需要一個環境管理面闆,羅列出已存在哪些泳道正在被誰使用,各泳道需要部署了哪些服務,服務的健康狀态等;通過工單系統自動申請或者銷毀泳道環境。這樣對環境管理就非常清晰,提高大家使用效率。

2.目前泳道環境底層存儲未隔離,隻對開發人員開放,因為測試人員對資料隔離比較敏感,是以,我們也正在對 MySQL,Redis 等元件進一步改造,通過環境辨別路由到不同的庫或者執行個體,到達資料隔離的效果。

六、總結

多泳道方案已在測試環境運作一段時間,也遇到一些問題,經過探索形成了自有的一套解決方案。該解決方案結合了我們自有的元件,并在同城雙活的方案基礎實作,是以某些隔離方案并非針對測試環境最優的,但是比較适合我們的。希望對大家有一些啟發,同時也歡迎大家一起探讨。

來源-微信公衆号:每日優鮮技術團隊

出處:https://mp.weixin.qq.com/s/bBZZjdC5dX8rMUFAhMw_Mg