聊聊服務註冊與發現

服務發現,作爲互聯網從業人員,大家應該都不陌生,一個完善的服務集羣,服務發現是必不可少的功能之一。

最近一直想寫這個話題,也一直在構思,但不知道從何入手,或者說不知道寫哪方面。如果單純寫如何實現,這個未免太乏味枯燥了;而如果只是介紹現有成熟方案呢,卻達不到我的目的。想了很久,準備先從微服務的架構入手,切入 服務發現 要解決什麼問題,搭配常見的處理模式,最後介紹下現有的處理方案。

微服務服務於分佈式系統,是個分散式系統。服務部署跨主機、網段、機房乃至省市自治區。各個服務之間通過 RPC(remote procedure call) 進行調用。在架構上最重要的一環,就是服務發現。如果說服務發現是微服務架構的靈魂也當之無愧,試想一下,當一個系統被拆分成多個服務,且被大量部署的時候,有什麼能比 "找到" 想調用的服務在哪裏,以及能否正常提供服務重要呢?同樣的,有新服務啓動時,如何讓其他服務知道其存在呢?

微服務考驗的是治理大量服務的能力,包含多種服務,同樣也包含多個實例。

概念

服務發現是指使用一個註冊中心來記錄分佈式系統中的全部服務的信息,以便其他服務能夠快速的找到這些已註冊的服務。

服務發現之所以重要,是因爲它解決了微服務架構最關鍵的問題:如何精準的定位需要調用的服務 ip 以及端口。無論使用哪種方式來提供服務發現功能,大致上都包含以下三點:

整個過程很簡單。大致就是在服務啓動的時候,先去進行註冊,並且定時反饋本身功能是否正常。由服務發現機制統一負責維護一份正確或者可用的服務清單。因此,服務本身需要能隨時接受查下,反饋調用方服務所要的信息。

註冊模式

一整套服務發現機制順利運行,首先就得維護一份可用的服務列表。包含服務註冊與移除功能,以及健康檢查。服務是如何向註冊中心 "宣告" 自身的存在?健康檢查,是如何確認這些服務是可用的呢?

做法大致分爲兩類:

這時候,要確認服務是否正常運轉的健康檢查機制,就不能只依靠心跳,必須通過其它第三方的驗證 (ping),不斷的從外部來確認服務本身的健康狀態。

這些都是有助於協助註冊中心提高服務列表精確到的方法。能越精確的提高服務清單狀態的可靠性,整套微服務架構的可靠度就會更高。這些方法不是互斥的,在必要的時候,可以搭配使用。

發現模式

服務發現的發現機制主要包括三個方面:

服務發現機制的關鍵部分是註冊中心。註冊中心提供管理和查詢服務註冊信息的 API。當服務提供者的實例發生變更時(新增 / 刪除服務),服務註冊表更新最新的狀態列表,並將其最新列表以適當的方式通知給服務消費者。目前大多數的微服務框架使用 Netflix Eureka、Etcd、Consul 或 Apache Zookeeper 等作爲註冊中心。

爲了說明服務發現模式是如何解決微服務實例地址動態變化的問題,下面介紹兩種主要的服務發現模式:

客戶端模式與服務端模式,兩者的本質區別在於,客戶端是否保存服務列表信息。

客戶端發現模式

在客戶端模式下,如果要進行微服務調用,首先要進行的是到服務註冊中心獲取服務列表,然後再根據調用端本地的負載均衡策略,進行服務調用。

在上圖中,client 端提供了負載均衡的功能,其首先從註冊中心獲取服務提供者的列表,然後通過自身負載均衡算法,選擇一個最合理的服務提供者進行調用:

1、 服務提供者向註冊中心進行註冊,提交自己的相關信息

2、 服務消費者定期從註冊中心獲取服務提供者列表

3、 服務消費者通過自身的負載均衡算法,在服務提供者列表裏面選擇一個合適的服務提供者,進行訪問

客戶端發現模式的優缺點如下:

目前來說,大部分服務發現的實現都採取了客戶端模式(sofapbrpc、brpc 等)。

服務端發現模式

在服務端模式下,調用方直接向服務註冊中心進行請求,服務註冊中心再通過自身負載均衡策略,對微服務進行調用。這個模式下,調用方不需要在自身節點維護服務發現邏輯以及服務註冊信息。

在服務端模式下:

1、 服務提供者向註冊中心進行服務註冊

2、 註冊中心提供負載均衡功能

3、 服務消費者去請求註冊中心,由註冊中心根據服務提供列表的健康情況,選擇合適的服務提供者供服務消費者調用

現代容器化部署平臺(如 Docker 和 Kubernetes)就是服務端服務發現模式的一個例子,這些部署平臺都具有內置的服務註冊表和服務發現機制。容器化部署平臺爲每個服務提供路由請求的能力。服務客戶端向路由器(或者負載均衡器)發出請求,容器化部署平臺自動將請求路由到目標服務一個可用的服務實例。因此,服務註冊,服務發現和請求路由完全由容器化部署平臺處理。

服務端發現模式的特點如下:

實現方案

file

以文件的形式實現服務發現,這是一個比較簡單的方案。其基本原理就是將服務提供者的信息 (ip:port) 寫入文件中,服務消費者加載該文件,獲取服務提供者的信息,根據一定的策略,進行訪問。

需要注意的是,因爲以文件形式提供服務發現,服務消費者要定期的去訪問該文件,以獲得最新的服務提供者列表,這裏有個小優化點,就是可以有個線程定時去做該任務,首先去用該文件的最後一次修改時間跟服務上一次讀取文件時候存儲的修改時間做對比,如果時間一致,表明文件未做修改,那麼就不需要重新做加載了,反之,重新加載文件。

文件方式實現服務發現,其特點顯而易見:

zookeeper

ZooKeeper 是一個集中式服務,用於維護配置信息、命名、提供分佈式同步和提供組服務。

zookeeper 樹形結構

zookeeper 是一個樹形結構,如上圖所示。

使用 zookeeper 實現服務發現的功能,簡單來講,就是使用 zookeeper 作爲註冊中心。服務提供者在啓動的時候,向 zookeeper 註冊其信息,這個註冊過程其實就是實際上在 zookeeper 中創建了一個 znode 節點,該節點存儲了 ip 以及端口等信息,服務消費者向 zookeeper 獲取服務提供者的信息。服務註冊、發現過程簡述如下:

服務註冊

假設我們服務提供者的服務名稱爲 services, 首先在 zookeeper 上創建一個 path /services, 在服務提供者啓動時候,向 zookeeper 進行註冊,其註冊的原理就是創建一個路徑,路徑爲 / services/$ip:port,其中 ip:port 爲服務提供者實例的 ip 和端口。如下圖所示,我們現在 services 實例有三個,其 ip:port 分別爲 192.168.1.1:1234、192.168.1.2:1234、192.168.1.3:1234 和 192.168.1.4:1234,如下圖所示:

健康檢查

zookeeper 實現了一種 TTL 的機制,就是如果客戶端在一定時間內沒有向註冊中心發送心跳,則會將這個客戶端摘除。

獲取服務提供者的列表

前面有提過,zookeeper 實際上是一個樹形結構,那麼服務消費者是如何獲取到服務提供者的信息呢?最重要的也是必須的一點就是 知道服務提供者信息的父節點路徑。以上圖爲例,我們需要知道

/services

通過 zookeeper client 提供的接口 getchildren(path) 來獲取所有的子節點。

感知服務上線與下線

zookeeper 提供了 “心跳檢測” 功能,它會定時向各個服務提供者發送一個請求(實際上建立的是一個 socket 長連接),如果長期沒有響應,服務中心就認爲該服務提供者已經“掛了”,並將其剔除,比如 192.168.1.2 這臺機器如果宕機了,那麼 zookeeper 上的路徑 / services / 下就會只剩下 192.168.1.1:1234, 192.168.1.2:1234,192.168.1.4:1234。如下圖所示:

服務下線

假設此時,重新上線一個實例,其 ip 爲 192.168.1.5,那麼此時 zookeeper 樹形結構如下圖所示:

服務上線

服務消費者會去監聽相應路徑(/services),一旦路徑上的數據有任務變化(增加或減少),zookeeper 都會通知服務消費方服務提供者地址列表已經發生改變,從而進行更新。

實現

下面是服務提供者在 zookeeper 註冊中心註冊時候的核心代碼:

int ZKClient::Init(const std::string& host, int timeout,
                          int retry_times) {
  host_ = host;
  timeout_ = timeout;
  retry_times_ = retry_times;


  hthandle_ = zookeeper_init(host_.c_str(), GlobalWatcher, timeout_,
                             NULL, this, 0);
  return (hthandle_ != NULL) ? 0 : -1;
}

int ZKClient::CreateNode(const std::string& path,
                                const std::string& value,
                                int type) {
  int flags;
  if (type == Normal) {
    flags = 0;
  } else if (type == Ephemeral) {
    flags = ZOO_EPHEMERAL;
  } else {
    return -1;
  }

  int ret = zoo_exists(hthandle_, path.c_str(), 0, NULL);
  if (ret == ZOK) {
    return -1;
  }
  if (ret != ZNONODE) {
    return -1;
  }

  ret = zoo_create(hthandle_, path.c_str(), value.c_str(), value.length(),
                   &ZOO_OPEN_ACL_UNSAFE, flags, NULL, 0);
  return ret == ZOK ? 0 : -1;
}


int main() {
  std::string ip; // 當前服務ip
  int port; // 當前服務的端口
  std::string path = "/services/" + ip + ":" + std::to_string(port);
  
  ZKClient zk;
  zk.init(...);
  
  //初始化zk客戶端
  zk.CreateNode(path, "", Ephemeral);
  
  ...
  return 0
}

上面是服務提供者所做的一些操作,其核心功能就是:

在服務啓動的時候,使用 zookeeper 的客戶端,創建一個臨時 (Ephemeral) 節點

從代碼中可以看出,創建 znode 的時候,指定了其 node 類型爲 Ephemeral,這塊非常重要,在 zookeeper 中,如果 znode 類型爲 Ephemeral,表明,在服務提供者跟註冊中心斷開連接的時候,這個節點會自動消失,進而註冊中心會通知服務消費者重新獲取服務提供者列表。


下面是服務消費者的核心代碼:

int ZKClient::Init(const std::string& host, int timeout,
                          int retry_times) {
  host_ = host;
  timeout_ = timeout;
  retry_times_ = retry_times;


  hthandle_ = zookeeper_init(host_.c_str(), GlobalWatcher, timeout_,
                             NULL, this, 0);
  return (hthandle_ != NULL) ? 0 : -1;
}

int ZKClient::GetChildren(const std::string& path,
                                 const std::function<int(const std::vector<std::tuple<std::string, std::string>>)>& on_change,
                                 std::vector<std::tuple<std::string, std::string>>* children) {
  std::lock_guard<std::recursive_mutex> lk(mutex_);
  
  int ret = zoo_get_children(handle, path, 1, children); // 通過來獲取子節點
  if (ret == ZOK) {
   node2children_[path] = std::make_tuple(on_change, *children); // 註冊事件
  }
  return ret;

}

int main() {
  ZKClient zk;
  zk.Init(...);
  
  std::vector<std::string> children
  // 設置回調通知,即在某個path下子節點發生變化時,進行通知
  zk.GetChildren("/services", callback, children);
  
  ...
  return 0;
}

對於服務消費者來說,其需要有兩個功能:

其中第一點可以通過

zoo_get_children(handle, path, 1, children);

來獲取列表,那麼如何在服務提供者列表發生變化時得到通知呢?這就用到了 zookeeper 中的 watcher 機制。

watcher 目的是在 znode 以某種方式發生變化時得到通知。watcher 僅被觸發一次。如果您想要重複通知,您將需要重新註冊觀察者。讀取操作(例如 exists、get_children、get_data)可能會創建監視。

okeeper 中的 watcher 機制,不在本文的討論範圍內,有興趣的讀者,可以去查閱相關書籍或者資料。

下面,我們對使用 zookeeper 作爲註冊中心,服務提供者和消費者需要做的操作進行下簡單的總結:

etcd

Etcd 是基於 Go 語言實現的一個 KV 結構的存儲系統,支持服務註冊與發現的功能,官方將其定義爲一個可信賴的分佈式鍵值存儲服務,主要用於共享配置和服務發現。其特點如下:

服務註冊

每一個服務器啓動之後,會向 Etcd 發起註冊請求,同時將自己的基本信息發送給 etcd 服務器。服務器的信息是通過 KV 鍵值進行存儲。key 是用戶真實的 key, value 是對應所有的版本信息。keyIndex 保存 key 的所有版本信息,每刪除一次都會生成一個 generation,每個 generation 保存了這個生命週期內從創建到刪除中間的所有版本號。

更新數據時,會開啓寫事務。

健康檢查

在註冊時,會初始化一個心跳週期 ttl 與租約週期 lease。服務器需要在心跳週期之內向 etcd 發送數據包,表示自己能夠正常工作。如果在規定的心跳週期內,etcd 沒有收到心跳包,則表示該服務器異常,etcd 會將該服務器對應的信息進行刪除。如果心跳包正常,但是服務器的租約週期結束,則需要重新申請新的租約,如果不申請,則 etcd 會刪除對應租約的所有信息。

在 etcd 中,並不是在磁盤中刪除對應的 keyValue 信息,而是對其進行標記刪除。

再次需要做個說明,因爲筆者是從事 c++ 開發的,現在線上業務用的 zookeeper 來作爲註冊中心實現服務發現功能。上半年的時候,也曾想轉到 etcd 上,但是 etcd 對 c++ 並不友好,筆者用了將近兩週時間各種調研,編譯,發現竟然不能將其編譯成爲一個靜態庫...

需要特別說明的是,用的是 etcd 官網推薦的 c++ 客戶端 etcd-cpp-apiv3

縱使 etcd 功能再強大,不能支持 c++,算是一個不小的損失。對於筆者來說,算是個遺憾吧,希望後續能夠支持。

下面是 etcd c++ client 不支持靜態庫,作者以及其他使用者的反饋,以此作爲本章節的結束。

etcd 官網指定的 c++client 作者回復使用者反饋

結語

微服務架構模式下,服務實例動態配置,因此服務消費者需要動態瞭解到服務提供者的變化,所以必須使用服務發現機制。

服務發現的關鍵部分是註冊中心。註冊中心提供註冊和查詢功能。目前業界開源的有 Netflix Eureka、Etcd、Consul 或 Apache Zookeeper,大家可以根據自己的需求進行選擇。

服務發現主要有兩種發現模式:客戶端發現和服務端發現。客戶端發現模式要求客戶端負責查詢註冊中心,獲取服務提供者的列表信息,使用負載均衡算法選擇一個合適的服務提供者,發送請求。服務端發現模式,客戶端每次都請求註冊中心,由註冊中心內部選擇一個合適的服務提供者,並將請求轉發至該服務提供者,需要注意的是 當一個請求過來的時候,註冊中心內部獲取服務提供者列表和使用負載均衡算法

這個世界沒有完美的架構和模式,不同的場景都有適合的解決方案。我們在調研決策的時候,一定要根據實際情況去權衡對比,選擇最適合當前階段的方案,然後通過漸進迭代的方式不斷完善優化方案。

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