有贊服務註冊與發現架構演進

作者:飄石

部門:技術中臺 / 中間件

一、概述

近幾年,隨着有贊業務的快速發展,應用數目與實例規模在快速地增加。有讚的服務註冊與發現架構近幾年也一直在快速平穩地演進,以支撐業務的發展。本文主要介紹有贊近幾年服務註冊與發現架構的演進過程。

有讚的後臺業務應用主要是基於 Dubbo 框架開發的,因此,服務註冊與發現的方案也都離不開對 Dubbo 服務模型的支持。近幾年,Dubbo 社區也一直在演進服務註冊與發現解決方案,但有讚的演進路線跟 Dubbo 社區並不相同。有贊根據內部獨特的歷史背景以及未來規劃走出了具有自己特色的演進道路。

本文將分爲三個階段來介紹近幾年有贊服務註冊與發現架構的演進:接口級服務註冊與發現,接口級服務註冊與應用級服務發現,應用級服務註冊與發現。爲了聚焦,本文主要介紹 Dubbo 應用相關的服務註冊與發現,但實際上有讚的服務註冊與發現方案不僅僅支持 Dubbo 應用。

二、接口級服務註冊與發現

2.1 架構

接口級服務註冊與發現,也是開源社區 Dubbo 2.7 版本之前的標準方案,有贊 2018 年 ~ 2019 年期間主要處於這種架構階段。架構如下圖所示: ****模型上與 Dubbo 社區方案是一致的,註冊中心我們採用的是 Etcd v3。

接口級模型示例:

[
  {
    "interface":"com.youzan.java.demo.api.HelloService",
    "instances":[
      {
        "ip_address":"10.10.10.10",
        "port":5000,
        "protocol":"dubbo",
        "az":"qa",
        "weight":100,
        "labels":{
          "version":"stable"
        },
        "application":"java-demo",
        "methods":[
          "hello"
        ]
      }
    ]
  },
  {
    "interface":"com.youzan.java.demo.api.EchoService",
    "instances":[
      {
        "ip_address":"10.10.10.10",
        "port":5000,
        "protocol":"dubbo",
        "az":"qa",
        "weight":100,
        "labels":{
          "version":"stable"
        },
        "application":"java-demo",
        "methods":[
          "echo"
        ]
      }
    ]
  }
]

2.2 問題

接口級服務註冊與發現可以說是 Dubbo 框架獨有的模型,而業界主流的服務註冊與發現模型都是應用級的,如 K8S、Spring Cloud、Consul 等。相比應用級模型,接口級模型的主要問題是粒度太細,服務註冊與發現的開銷太高。根據有讚的服務註冊與發現數據統計,平均每個應用實例的接口註冊數量和訂閱數量爲幾十個,同時,該數量也在緩慢增長。粗略估算,在大規模場景中,接口級比應用級服務註冊與發現的成本要高 1~2 個數量級。接口級服務註冊與發現的弊端業界也基本達成了共識,Dubbo 社區從 Dubbo 2.7.5 開始支持應用級服務註冊與發現。

總結一下該架構存在的痛點:

三、接口級服務註冊與應用級服務發現

3.1 架構

這一階段服務註冊維持不變,服務發現轉變爲應用級別,有贊 2020 年期間主要處於這種架構階段,該架構屬於過渡階段架構。架構如下圖所示: 

服務發現方面主要有以下兩個變化:

下面將進行詳細介紹。

3.2 應用級服務發現解析

Istio Pilot 抽象並統一了服務發現模型,屏蔽掉了註冊中心具體實現細節,使得消費端應用完全不需要關注服務提供端應用是如何進行服務註冊的。Istio Pilot 中間層避免了海量客戶端直連註冊中心,大大降低了註冊中心的壓力;同時 Istio Pilot 是無狀態的,可以輕鬆擴縮容,大大提升了可伸縮能力。Istio Pilot 通過 xDS API 向所有 Sidecar 推送服務發現、配置等數據。服務發現 API 是 EDS(Endpoint Discovery Service),客戶端通過指定 Cluster 名稱(這裏可以認爲對應的是應用名)列表來訂閱對應的服務實例信息,當服務實例信息有更新時,Istio Pilot 推送給訂閱的客戶端。

我們引入 Istio Pilot 不僅僅是作爲服務發現中心,同時也是路由規則配置中心等。Istio Pilot 實際上是作爲有贊 Service Mesh 的控制面角色來使用的,我們的數據面是自研組件 Tether。由於有贊整體業務規模龐大,以及 Dubbo 模型的複雜度,是很難直接落地爲 Istio 社區那樣的 Service Mesh 形態的。因此,我們對 Istio Pilot 進行了大量擴展與適配,來滿足內部需求,以及逐步演進的目標。對於服務發現而言,我們擴展支持了 Etcd Registry,同時實現了接口模型到應用模型的轉換。爲了支持 Dubbo 的服務發現,使用原有的模型還不夠,因此我們通過 xDS 模型的擴展字段來支持 Dubbo 的元數據。

應用級模型示例:

{
  "application":"java-demo",
  "instances":[
    {
      "ip_address":"10.10.10.10",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":[
        {
          "interface":"com.youzan.java.demo.api.HelloService",
          "methods":[
            "hello"
          ]
        },
        {
          "interface":"com.youzan.java.demo.api.EchoService",
          "methods":[
            "echo"
          ]
        }
      ]
    }
  ]
}

與前面提到的接口級模型對比,應用級模型大大降低了數據冗餘。

我們通過 Istio Pilot 實現了應用級的服務發現,這時我們面臨一個問題,原先 Dubbo 框架通過訪問的接口直接進行服務發現,現在需要將訪問的接口映射爲訪問的應用,然後以應用名進行服務發現訂閱。

Istio Pilot 會根據應用級的註冊信息在內存中構建一個接口到應用的反向映射,我們擴展了一個 Interface Mapping 接口,用於查詢哪些應用暴露了這個接口。映射信息如下所示:

[
  {
    "interface":"com.youzan.java.demo.api.HelloService",
    "providers":[
      "java-demo"
    ]
  },
  {
    "interface":"com.youzan.java.demo.api.EchoService",
    "providers":[
      "java-demo"
    ]
  }
]

前面我們提到,由 SDK 進行服務發現,對於多語言應用支持成本較高。在有贊,除了主流的 Dubbo RPC 應用,還有 Node Web 應用,PHP Web 應用,ZanPHP RPC 應用,這些應用都需要進行服務發現訪問其他後端應用。所以,我們將這些基礎能力下沉到 Sidecar Tether,所有消費端接入 Tether,由 Tether 進行服務發現、請求路由、負載均衡等。接入 Tether 也開啓了有讚的 Service Mesh 之路。

Dubbo 社區應用級服務發現方案中,爲了使 Dubbo 儘可能的兼容和融入業界已有的應用級服務發現解決方案,元數據是通過一種服務自省的方式來獲取的。對於接口到應用的映射,解決思路基本都是一致的。

3.3 優化

雖然當前的架構方案已經大大緩解了服務發現的壓力,但是仍有幾個優化點可以大大提升性能。下面簡單介紹一下。

3.3.1 服務發現延遲聚合推送

Dubbo 實例在啓動時,是一個接口一個接口註冊的,因爲我們將接口註冊數據轉換成了應用實例註冊數據,這也就意味着,每註冊一個接口,應用實例數據(dubbo_rpc_metadata)就會變動。如果每次變動就進行服務發現推送,那成本會很高,無論是對於 Istio Pilot 還是 Tether。根據統計,一個實例一般在幾秒鐘內會完成所有接口的註冊,因此,我們會對一段時間內註冊事件響應進行延遲處理。比如,如果實例註冊數據有變動,延遲 3s 再推送,如果 3s 時間到達之前期間又有變動,再延遲 3s,最長不超過 10s。該方案大概率地把一個實例的多個接口註冊事件聚合成了一次推送,同時,應用發佈過程一般是分批次進行的,每個批次會有多個實例同時啓動,該方案也有很大概率把多個實例的註冊事件聚合成一次推送。

3.3.2 服務發現預加載

最初 Tether 的服務發現是延遲加載的,即當應用的請求到達 Tether 後,如果還沒有訂閱過目標訪問應用,進行服務發現訂閱。剛啓動的時候,訪問不同應用的請求會陸陸續續到來,每個請求訪問一個本地服務發現數據不存在的應用時,就需要更新服務發現訂閱列表,發起新的 EDS 訂閱請求,會加大 Istio Pilot 的負載,同時會一定程度增加請求的 RT。一個應用的需要訪問的其他應用的列表是比較穩定的,我們稱之爲服務依賴列表。我們通過 Tether 定時上報最近一段時間(如 30 分鐘)訪問過的應用列表到 Istio Pilot 來實現服務發現預加載。Tether 啓動初始化階段拉取該應用最近訪問過的應用列表,然後一次性的完成服務發現訂閱,大大降低了應用剛啓動時首次請求的 RT。

3.3.3 客戶端接口與應用映射關係構建

前面我們討論過,請求到來時,我們需要根據接口查詢對應的應用,那是不是每個接口的首次請求都需要通過 Istio Pilot 的 Interface Mapping 接口查詢呢?其實沒必要的,當我們拿到一個應用的服務發現數據時,本地可以根據該應用的服務元數據構建出來所有的接口到應用的映射關係。根據局部性原理,如果一個應用訪問了某個應用的一個接口,短時間內大概率也會訪問該應用的其他接口。通過該優化,實際上只會發生很少的 Interface Mapping 查詢請求。當然,應用的服務元數據都是在變化的,因此,我們也需要定期的異步刷新,異步刷新時,只會刷新最近有請求的接口,且我們實現了批量處理的接口以提升性能。對於一段時間內沒有訪問過的接口,當新請求到來時,會嘗試同步去請求 Interface Mapping。

3.3.4 接口元數據聚合分組

一般來說,真實生產環境中,一個應用的大部分實例的服務元數據是相同的,那麼也就沒必要爲每個應用實例關聯一份完整的元數據。在有讚的場景中,每個機房,每個應用一般最多有 2 個版本的服務元數據,主要出現在發佈過程中,如普通滾動發佈、灰度 / 藍綠髮布。因此,我們可以進行相應的優化。Istio Pilot 在推送服務發現數據前會對應用實例服務元數據進行聚合分組,以減少網絡帶寬 IO,以及客戶端解析開銷等。聚合示例:

{
  "application":"java-demo",
  "instances":[
    {
      "ip_address":"10.10.10.10",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":{
        "type":"metadata",
        "data":[
          {
            "interface":"com.youzan.java.demo.api.HelloService",
            "methods":[
              "hello"
            ]
          },
          {
            "interface":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          }
        ]
      }
    },
    {
      "ip_address":"10.10.10.20",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":{
        "type":"metadata_reference",
        "data":{
          "ip_address":"10.10.10.10",
          "port":5000
        }
      }
    }
  ]
}

應用實例10.10.10.10:500010.10.10.20:5000服務元數據完全一致,因此10.10.10.20:5000dubbo_rpc_metadata中並不需要保存完整的服務元數據信息,僅需要保存一個引用的應用實例信息即可。客戶端在構建時,會根據引用關係,關聯到正確的服務元數據信息。

該優化將服務發現推送的網絡 IO 降低到原來的 30% 以下。

3.4 問題

雖然該架構解決了服務發現的一些問題,但仍然有以下問題:

四、應用級服務註冊與發現

4.1 架構

這是有贊確定的長期架構,從 2021 年開始,有贊轉換到該架構。架構圖如下所示: 

此架構服務與前一階段架構相比,客戶端服務發現沒有變化,只有服務註冊有變化。

應用服務註冊通過 Sidecar Tether 來完成,可以主動調用 Tether 服務註冊接口,也可以由 Tether 主動獲取實例服務註冊信息進行註冊,目前我們優先支持了前一種方式。

Dubbo 啓動時,等待所有接口暴露完成,聚合成應用級的實例信息,發起一次服務註冊請求到 Tether,Tether 判斷應用部署環境,向 Istio Pilot 發起相應的註冊請求。對於 VM 部署應用,需要註冊完整的信息;對於 K8S 部署應用,僅需要註冊服務元數據信息即可,其他實例信息、標籤等可以由 Istio Pilot 根據 K8S Service 以及 Pod 信息獲得。有贊業務應用基本都實現了 K8S 部署,所以,這裏只介紹 K8S 部署應用的註冊流程。Istio Pilot 會將註冊請求中的服務元數據,以 CRD 的方式存儲到 K8S 中。Istio Pilot 在服務發現時,會根據 Service/Endpoints/Pod/ServiceMetadata 信息,生成完整的服務發現數據。此過程中,Istio Pilot 與客戶端之間的服務發現數據模型完全沒有變化,因此,客戶端對於該服務註冊的變動是完全無感知的。這樣,我們又平滑地演進到了新的架構。

可能有人會考慮到運行時動態暴露接口的場景,會對該註冊方案有影響。我們通過統計,發現沒有該使用場景,所有應用都可以在啓動的時候確定需要註冊的接口信息。同時,我們通過 Dubbo 框架約束,一個實例的註冊數據一旦完成服務註冊後是不能變化的。如果後續確實出現了該場景,我們後續會再對該場景進行支持,或者先使用老的註冊方式,畢竟 Istio Pilot 可以輕鬆支持多種註冊中心,而客戶端無感知。

4.2 服務元數據管理

服務元數據管理這一塊有些細節值得介紹一下。

初步考慮,可以將每個實例的服務元數據寫到到對應的 K8S Pod 註解裏。該方案每個實例註冊都需要寫一份完整的服務元數據,但事實上大部分實例的服務元數據都是相同的,或僅存在非常少數的幾個版本。因此,存在極大的優化空間。

這裏優化的主要思路是,當某個實例註冊元數據時,可以先檢查一下對應版本的服務元數據是否已存在,如果已存在,則不需要再寫入了。那如何確定服務元數據的版本呢?根據 K8S Pod 的 Labels。因爲相同 Labels 的 Pod 實例的鏡像、配置等都是相同的,則他們的服務能力、服務元數據也必定一致。爲什麼呢?因爲,Labels 相同的 Pod 都是由同一個 ReplicaSet 創建的,按照雲原生的理念它們的服務能力必定是一致的。既然每個 ReplicaSet 產生的 Pod 它們的服務註冊元數據是一致的,那是不是該 ReplicaSet 創建的 Pod 的元數據可以寫入到 ReplicaSet 的註解裏。這個方案是沒問題的。但是我們基於 K8S API Server 權限最小化的考慮,沒有采用該方案,畢竟 ReplicaSet 是 K8S 核心資源,最好不要放開權限。我們採用 CRD 單獨保存元數據的方式,即 ServiceMetadata CRD。一個 ServiceMetadata 資源裏,管理一個應用的多個版本的服務元數據。

服務元數據模型如下所示:

{
  "subset_metadata":[
    {
      "subset_id":"1",
      "selector":{
        "pod-template-hash":"f845f5775",
        "app":"java-demo",
        "zone":"qa"
      },
      "dubbo_rpc_metadata":{
        "interfaces":[
          {
            "name":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          }
        ]
      }
    },
    {
      "subset_id":"2",
      "selector":{
        "pod-template-hash":"67bd5c9db9",
        "app":"java-demo",
        "zone":"qa"
      },
      "dubbo_rpc_metadata":{
        "interfaces":[
          {
            "name":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          },
          {
            "name":"com.youzan.java.demo.api.HelloService",
            "methods":[
              "hello"
            ]
          }
        ]
      }
    }
  ]
}

當註冊請求達到 Istio Pilot 時,Istio Pilot 通過本地 K8S Client Cache 查詢 ServiceMetadata 中是否包含對應版本的服務元數據(通過對應實例的 Pod Labels 與 ServiceMetadata 所有版本的 Selector 進行匹配),如果存在,直接返回即可。不存在,向 K8S API Server 發起請求更新 ServiceMetadata,寫入對應版本的服務元數據。如果 K8S API Server 返回版本衝突錯誤,說明有其他註冊請求修改了 ServiceMetadata,則需要客戶端重試,重試時大概率會通過 K8S Client Cache 發現對應版本的服務元數據已經存在。這裏我們可以發現,通過該優化,一個 ReplicaSet 下的所有 Pod 實例只需要寫一次服務元數據,即使有寫衝突,概率也是很低的。並且,一個 Deployment 創建新版本的實例集時,都是會分多個批次創建新的實例集的 Pod 的,取決於 MaxSurge 參數,衝突也僅會出現在第一個批次。由於大部分場景都不需要直接跟 K8S API Server 直接交互,大大降低了服務註冊的開銷。

老的版本的元數據如何刪除呢?因爲每個版本的元數據和 ReplicaSet 是一一對應的,所以,只要實現一個 ReplicaSet 的自定義 Controller,當 ReplicaSet 對象被刪除時,刪除對應的版本的元數據即可。關聯關係我們採用 K8S 的 Selector 機制來處理。

4.3 多機房服務發現

請求路由時,我們一般都有本機房路由優先原則,即如果本機房內有對應的服務實例,請求路由到本機房的實例。一般來說,每個機房內的應用部署是完備的,很少需要進行跨機房訪問。如果有比較大的故障,一般也會切掉整個機房的流量到其他機房。但也有如下特殊情況:

多機房服務發現的支持,一般有三種思路:

有讚的多機房服務發現採用的是客戶端支持方案。架構圖如下所示: 

應用服務註冊只註冊到本機房註冊中心,Istio Pilot 只對接本機房註冊中心,Tether 對接多個機房的 Istio Pilot,默認情況下只訪問本機房 Istio Pilot,當因本機房應用實例不存在或異常,需要將部分流量或全部流量切至其他機房實例時,Tether 再訪問其他機房 Istio Pilot,獲取其他機房的服務實例,進行後續的路由調度。該方案即實現了多機房服務發現的可伸縮能力,也避免了大部分場景下不必要的服務發現開銷。

五、總結

本文介紹了有贊近幾年服務註冊與發現架構演進過程,主要包括三個階段:接口級服務註冊與發現、接口級服務註冊與應用級服務發現、應用級服務註冊與發現。雖然這期間整體架構變化比較大,但是我們做到了平穩、平滑地演進,期間上層業務應用無感知,所有功能由 Dubbo 框架、Service Mesh 等基礎組件來支持。雖然,有讚的演進之路不一定適合其他公司場景,但也希望能爲大家提供一種思路。

感謝閱讀。

參考資料

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