Go微服務 - 第七部分 - 服務發現和負載均衡

WalkerQiao發表於2018-05-22

第七部分: Go微服務 - 服務發現和負載均衡

本部分處理一個健全的微服務架構的兩個基本部分 - 服務發現和負載均衡 - 以及在2017年, 它們如何促進重要的非功能性需求的水平擴充套件。

簡介

負載均衡是很出名的概念了,但我認為服務發現需要更深入的理解, 我先從一個問題開始。

如果服務A要和服務B通話,但是卻不知道到哪裡找服務B如何處理?

換句話說, 如果我們服務B在任意數量的叢集節點上執行了10個例項, 則有人需要跟蹤這10個例項。

因此,當服務A需要與服務B通訊時,必須為服務A提供至少一個適當的IP地址或主機名(客戶端負載均衡), 或者服務A必須能夠委託地址解析和路由到第三方給一個已知的服務B的邏輯名(服務端負載均衡). 在微服務領域不斷變化的上下文中,這兩種方式都需要出現服務發現。在最簡單的形式中,服務發現只是為一個或多個服務註冊執行例項。

如果這對你來說聽起來像DNS服務, 它確實如此。區別在於服務發現用於叢集內部,讓微服務互相能找到對方,而DNS一般是更加靜態的、適用於外部路由,因此外部方可以請求路由到你的服務。此外,DNS服務和DNS協議通常不適合處理具有不斷變化微服務環境的拓撲結構,容器和節點來來往往,客戶端通常也不遵循TTL值、失敗監測等等。

大多數微服務框架為服務發現提供一個或多個選項。 預設情況下,Spring Cloud/Netflix OSS使用Netflix Eureka(支援Consul, etcd和ZooKeeper), 服務使用已知的Eureka例項來註冊自己,然後間歇性的傳送心跳來確保Eureka例項知道它們依然活躍著。Consul提供了一個包含DNS整合的豐富的特徵集的選項已經變得越來越流行。 其他流行的選項是分散式和可複製key-value儲存的使用, 例如etcd中服務可以註冊自己。Apache ZooKeeper也將會意識到這樣需求的一群人。

本文,我們主要處理Docker Swarm提供的一些機制(Docker in swarm mode),並展示我們在第五部分探索的服務抽象,以及它實際上如何為我們提供服務發現和服務端負載均衡的。另外,我們也會看看我們單元測試中使用gock模擬HTTP請求輸出的模擬, 因為我們再做服務間通訊。

注意: 當我們引用Docker Swarm的時候,我指的是以swarm mode執行Docker 1.12以上版本。"Docker Swarm"在Docker 1.12之後不再作為一個獨立的概念存在了。

兩種型別的負載均衡

在微服務領域,通常會區分上面提到的兩種型別的負載均衡:

  • 客戶端負載均衡.
  • 服務端負載均衡.

客戶端負載均衡
由客戶端查詢發現服務來獲取它們要呼叫服務的實際地址資訊(IP, 主機名, 埠號), 找到之後,它們可以使用一種負載均衡策略(比如輪詢或隨機)來選擇一個服務。此外,為了不必要讓每個即將到來的呼叫都查詢發現服務,每個客戶端通常都保持一份端點的本地快取,這些端點必須與來自發現服務的主資訊保持合理同步。 Spring Cloud中客戶端負載均衡的一個例子是Netflix Ribbon。類似的東西在etcd支援的go-kit生態中也存在。客戶端負載均衡的一些優點是具有彈性、分散性以及沒有中心瓶頸,因為每個服務消費者都自己保持有生產端的註冊。 缺點就是具有較高的內部服務複雜性,以及本地註冊可能會包含過時條目的風險。

clipboard.png

服務端負載均衡
這個模型中,客戶端依賴負載均衡,提供服務邏輯名來查詢它要呼叫服務的合適例項。這種操作模式通常稱為代理, 因為它既充當負載均衡器又充當反向代理。我認為它的主要優點就是簡單。 負載均衡器和服務發現機制一般都內建於你的容器編排器中,你無需關心安裝和管理這些元件。另外,客戶端(e.g. 我們的服務)不需要知道服務註冊 - 負載均衡器為我們負責這些。 依賴負載均衡器來路由所有呼叫可能降低彈性,並且負載均衡器在理論上來說可能成為效能的瓶頸。

clipboard.png

客戶端負載均衡和服務端負載均衡的圖非常相似,區別在於LB的位置。

注意:當我們使用swarm模式的Docker的服務抽象時, 例如上面的服務端的生產服務註冊實際上對作為開發者的你來說是完全透明的。也就是說,我們的生產服務甚至不會意識到它們在操作服務端負載均衡的上下文(或者甚至在容器編排的上下文中). Swarm模式的Docker負責我們全部的註冊、心跳、取消註冊。

在blog系列的第2部分中,我們一直在使用的例子域中, 我們可能想要請求accountservice,讓它從quotes-service獲取當前的隨機報價。 在本文中,我們將集中使用Docker Swarm的服務發現和負載均衡機制。如果你對如何整合基於Go語言的微服務和Eureka感興趣, 可以參考我2016年的一篇部落格。我還編寫了一個簡單的自用的整合Go應用和Eureka客戶端類庫,它包含有基本的生命週期管理。

消費服務發現資訊

假設你想構建一個定製的監控應用程式,並需要查詢每個部署服務的每個例項的/health端點(路由)。你的監控程式如何知道需要請求的ip和埠呢? 你需要掌握實際的服務發現細節。如果你使用的是Docker Swarm來作為服務發現和負載均衡的提供者,並且需要這些IP, 你如何才能掌握Docker Swarm為我們儲存的每個例項的IP地址呢? 對於客戶端解決,例如Eureka, 你只需要使用它的API來消費就可以了。然而,在依賴編排器的服務發現機制的情況中,這可能不那麼簡單了。我認為需要追求一個主要選擇, 以及一些次要選擇來考慮更具體的用例。

Docker遠端API

首先,我建議使用Docker的遠端API - 例如使用來自服務內的Docker API來查詢Swarm Manager的服務和例項資訊。畢竟,你正在使用容器編排器的內建服務發現機制,那也是你應該查詢的源頭。對於可移植性,這是一個問題, 你可以總是為你選擇的編排器選擇一個介面卡。 但是,應該說明的是,使用編排器的API也有一些注意事項 - 它將你的解決方案和特定容器API緊密的聯絡在一起, 你必須確保你的應用程式可以和Docker Manager進行對話, 例如,它們會意識到它們正在執行的一些上下文,使用Docker遠端API的確有些增加了服務複雜度。

替代方案(ALTERNATIVES)

  • 使用另外一個單獨的服務發現機制 - 即執行Netflix Eureka, Consul或類似的東西,並確保除了Docker Swarm模式的機制外,在這些服務發現機制中也可以發現可註冊/取消註冊的微服務。然後我們只需要使用發現服務的註冊/查詢/心跳等API即可。我不喜歡這個選項,因為它引入了更多複雜的東西到服務中,當Swarm模式的Docker可以或多或少透明的為我們處理這些裡邊的大部分的事情。我幾乎認為這是一種飯模式,如果除非你必須要這麼做,否則還是不要這樣了。
  • 應用特定的發現令牌 - 在這種方式中,服務想要廣播它們的存在,可以週期性的在一個訊息話題上post一個帶有IP, 服務名等等的發現令牌。消費者需要了解例項以及它們的IP, 可以訂閱這個話題(Topic), 並保持它自己的服務例項註冊即時更新。當我們在稍後的文章中看不使用Eureka的Netflix Turbine, 我們就會使用這個機制來向一個定製的Turbine發現外掛提供資訊。這種方式有點不同,因為它們不需要充分利用完整的服務登錄檔 - 畢竟,在這個特定的用例中,我們只關心特定的一組服務。

原始碼

請放心的切出本部分的程式碼: https://github.com/callistaen...

擴充套件和負載均衡

我們繼續本部分,看看如何擴充套件我們的accountservice, 讓它們執行到多個例項中,並且看我們是否能讓Docker Swarm自動為我們將請求負載均衡。

為了想要知道具體什麼例項真正的為我們提供服務,我們需要給Account新增一個欄位, 我們可以使用生產服務例項的IP地址填充它。開啟/accountservice/model/account.go檔案。

type Account struct {
    Id string `json:"id"`
    Name string  `json:"name"`
    
    // NEW
    ServedBy string `json:"servedBy"`
}

然後在提供account服務的GetAccount方法中為account新增ServedBy屬性。

func GetAccount(w http.ResponseWriter, r *http.Request) {
    // Read the 'accountId' path parameter from the mux map
    var accountId = mux.Vars(r)["accountId"]

    // Read the account struct BoltDB
    account, err := DBClient.QueryAccount(accountId)

    account.ServedBy = getIP()      // NEW, add this line
    ...
}

// ADD THIS FUNC
func getIP() string {
    addrs, err := net.InterfaceAddrs()
    if err != nil {
        return "error"
    }
    for _, address := range addrs {
        // check the address type and if it is not a loopback the display it
        if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
            if ipnet.IP.To4() != nil {
                return ipnet.IP.String()
            }
        }
    }
    panic("Unable to determine local IP address (non loopback). Exiting.")
}

我們使用getIP()獲取機器IP,然後填充給ServedBy。在真正的專案中,getIP函式應該放在具體的工具包中,這樣每個微服務需要獲取非回送IP地址(non-loopback ip address)的時候都可以使用它。

然後使用copyall.sh重新構建並部署accountservice服務。

./copyall.sh

等待幾秒鐘,然後輸入下面命令:

> docker service ls
ID            NAME             REPLICAS  IMAGE
yim6dgzaimpg  accountservice   1/1       someprefix/accountservice

使用curl訪問如下:

> curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.255.0.5"}

很好,我們已經看到響應中包含有容器的IP地址了。下面我們對這個服務進行擴充套件。

> docker service scale accountservice=3
accountservice scaled to 3

等幾秒鐘,然後再執行docker service ls進行檢視,得到下面的內容:

> docker service ls
ID            NAME             REPLICAS  IMAGE
yim6dgzaimpg  accountservice   3/3       someprefix/accountservice

上面表示accountservice被複制了3份。然後再進行curl多次請求account, 看看我們是否每次都得到不一樣的ip地址呢。

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.22"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.255.0.5"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.18"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.22"}

在10.0.0.22處理完當前請求之前,我們可以看到4次呼叫分別在三個例項之內迴圈。這種使用Docker Swarm服務抽象的容器編排提供的負載均衡是非常有吸引力的,因為它把基於負載均衡(例如Netflix Ribbon)的客戶端的複雜性去掉了, 並且我們可以負載均衡而無需依賴服務發現機制來為我們提供能呼叫的IP地址列表。此外,從Docker Swarm 1.3不會路由任何流量到那些沒有報告它們自己是健康的節點上, 前提是實現了健康檢查。這就非常重要,當你需要將規模變大或變小的時候,特別是你的服務非常複雜的時候,可能需要超過幾百毫秒來啟動我們當前需要的accountservice。

FOOTPRINT AND PERFORMANCE WHEN SCALING

有趣的是,如果我們將accountservice例項從1個擴充套件為4個的時候如何影響延遲和CPU/記憶體使用的。當Swarm模式的負載均衡器輪詢我們請求的時候是不是有實質性的開銷?

docker service scale accountservice=4

等待幾秒,讓所有事情就緒。

在負載測試時CPU和記憶體使用情況

使用每秒1000個請求來執行Gatling測試。

CONTAINER                                    CPU %               MEM USAGE / LIMIT       
accountservice.3.y8j1imkor57nficq6a2xf5gkc   12.69%              9.336 MiB / 1.955 GiB 
accountservice.2.3p8adb2i87918ax3age8ah1qp   11.18%              9.414 MiB / 1.955 GiB 
accountservice.4.gzglenb06bmb0wew9hdme4z7t   13.32%              9.488 MiB / 1.955 GiB 
accountservice.1.y3yojmtxcvva3wa1q9nrh9asb   11.17%              31.26 MiB / 1.955 GiB

非常好,我們四個例項幾乎完全享有相同的工作負載, 我們看到另外3個新例項記憶體保持在10M以內, 鑑於這樣的情況,每個例項應該不需要服務超過250個請求/s。

效能

首先,Gatling引用一個例項:

clipboard.png

然後,Gatling引用4個例項:

clipboard.png

區別不是很大 - 但是不應該啊 - 所有四個服務例項畢竟都執行在同樣的虛擬主機Docker Swarm節點, 並且共用相同的底層硬體(例如我的筆記本)。如果我們給Swarm新增更多視覺化例項,它們可以利用未使用主機OS的資源, 那麼我們會看到更大的延遲減少,因為它將被分離到不同的邏輯CPU等上來處理負載。然而,我們看到效能的稍微增加,平均大概百分之95/99。我們可以完全得出一個結論, 在這個特定的場景中,Swarm模式負載均衡對效能沒有什麼負面影響。

帶出Quote服務

還記得我們在第5部分部署的Java實現的quote服務嗎? 讓我們將它也擴充套件多個,然後從accountservice裡邊呼叫它,使用quotes-service名。 新增這個的目的是展示服務發現和負載均衡有多透明, 我們唯一需要做的就是要知道我們要呼叫服務的邏輯服務名。

我們將編輯/goblog/accountservice/model/account.go檔案,因此我們的響應會包含一個quote。

type Account struct {
    Id string `json:"id"`
    Name string  `json:"name"`
    ServedBy string `json:"servedBy"`
    Quote Quote `json:"quote"`         // NEW
}

// NEW struct
type Quote struct {
    Text string `json:"quote"`
    ServedBy string `json:"ipAddress"`
    Language string `json:"language"`
}

注意,上面我們使用json tag來將來自quotes-service輸出的欄位對映到我們位元組結構體的quote欄位,它包含有quote, ipAddress和servedBy欄位。

繼續編輯/goblog/accountservice/service/handler.go。我們將田間一個簡單的getQuote函式,執行一個HTTP呼叫,請求http://quotes-service:8080/api/quote, 這個請求會返回一個quote值,然後我們用它來產生新的結構體Quote。 我們在GetAccount()方法中呼叫它。

首先,我們處理下連線: Keep-Alive問題,它會導致負載均衡問題,除非我們明確的恰當配置Go語言的client。在handlers.go中,在GetAccount函式上面新增如下程式碼:

var client = &http.Client{}

func init() {
    var transport http.RoundTripper = &http.Transport{
        DisableKeepAlives: true,
    }
    client.Transport = transport
}

init函式會確保任何有client例項發出的HTTP請求都具有適當的報頭, 確保基於負載均衡的Docker Swarm都能正常工作。接下來,在GetAccount函式中,新增一個包級別的getQuote函式。

func getQuote() (model.Quote, error) {
    req, _ := http.NewRequest("GET", "http://quotes-service:8080/api/quote?strength=4", nil)
    resp, err := client.Do(req)

    if err == nil && resp.StatusCode == 200 {
        quote := model.Quote{}
        bytes, _ := ioutil.ReadAll(resp.Body)
        json.Unmarshal(bytes, &quote)
        return quote, nil
    } else {
        return model.Quote{}, fmt.Errorf("Some error")
    }
}

沒有什麼特別的。 引數strength=4是quotes-service API特有的,可以用於使它能或多或少的消耗CPU。使用這個請求還有一些問題,我們返回了一個一般化的error。

我們將在GetAccount函式中呼叫新的getQuote函式, 如果沒有發生錯誤的話,將它的返回值的Quote屬性賦給Account例項。

// Read the account struct BoltDB
account, err := DBClient.QueryAccount(accountId)
account.ServedBy = getIP()

// NEW call the quotes-service
quote, err := getQuote()
if err == nil {
    account.Quote = quote
}
所有的錯誤檢查是我在Go語言中最不喜歡的事情之一,雖然它能產生很安全的程式碼,也可以更清楚的表達程式碼的意圖。

不產生HTTP請求的單元測試

如果我們執行/accountservice/service/handlers_test.go的單元測試, 它就會失敗。 test下面的GetAccount函式現在會嘗試發起一個HTTP請求來獲取著名的引言, 但是既然沒有quote-service運營在特定的URL(我猜想它不能解決任何事), 測試就不能通過。

我們可以有兩種可選策略用在這, 給定單元測試一個上下文。

  • 將getQuote函式提取為一個介面,提供一種真實實現和一種模擬實現, 就像我們在第四部分,為Bolt客戶端那樣做的一樣。
  • 利用HTTP特定的模擬框架來攔截我們將要發出的請求,並返回一個提前確定的響應。

內建httptest包可以為我們開啟一個嵌入的HTTP伺服器, 可以用於單元測試,但是我更喜歡第三方gock框架,它更加簡潔也便於使用。

func init() {
    gock.InterceptClient(client)
}

上面我們新增了一個init函式。這樣可以確保我們的http client例項會被gock劫走。

gock DSL為期望發出的HTTP請求和響應提供了細粒度的控制。 在下面的示例中,我們使用New(), Get()和MatchParam()來告訴gock期望http://quotes-service:8080/api/quote?strength=4 GET請求並響應HTTP 200, 並硬編碼響應body。

在TestGetAccount函式上面新增如下程式碼:

func TestGetAccount(t *testing.T) {
        defer gock.Off()
        gock.New("http://quotes-service:8080").
                Get("/api/quote").
                MatchParam("strength", "4").
                Reply(200).
                BodyString(`{"quote":"May the source be with you. Always.","ipAddress":"10.0.0.5:8080","language":"en"}`)

defer gock.Off()確保在當前測試完成後關閉HTTP的劫獲, 既然gock.New()會返回http劫獲, 這樣可能會讓後續測試失敗。

下面讓我們斷言期望返回的quote。 在TestGetAccount測試最裡邊的Convey塊中新增新的斷言:

Convey("Then the response should be a 200", func() {
    So(resp.Code, ShouldEqual, 200)

    account := model.Account{}
    json.Unmarshal(resp.Body.Bytes(), &account)
    So(account.Id, ShouldEqual, "123")
    So(account.Name, ShouldEqual, "Person_123")
    
    // NEW!
    So(account.Quote.Text, ShouldEqual, "May the source be with you. Always.")
})

執行測試

> go test ./...
?       github.com/callistaenterprise/goblog/accountservice    [no test files]
?       github.com/callistaenterprise/goblog/accountservice/dbclient    [no test files]
?       github.com/callistaenterprise/goblog/accountservice/model    [no test files]
ok      github.com/callistaenterprise/goblog/accountservice/service    0.011s

部署並在Swarm上執行

同樣我們使用copyall.sh指令碼來重新構建和部署。 然後通過curl呼叫account路由:

> curl $ManagerIP:6767/accounts/10000
  {"id":"10000","name":"Person_0","servedBy":"10.255.0.8","quote":
      {"quote":"You, too, Brutus?","ipAddress":"461caa3cef02/10.0.0.5:8080","language":"en"}
  }

然後將quotes-service擴充套件成兩個例項。

> docker service scale quotes-service=2

等待一段時間,大概15-30秒,因為Spring Boot的服務沒有Go語言的服務啟動快。 然後再使用curl呼叫幾次, 結果可能如下所示:

{"id":"10000","name":"Person_0","servedBy":"10.255.0.15","quote":{"quote":"To be or not to be","ipAddress":"768e4b0794f6/10.0.0.8:8080","language":"en"}}
{"id":"10000","name":"Person_0","servedBy":"10.255.0.16","quote":{"quote":"Bring out the gimp.","ipAddress":"461caa3cef02/10.0.0.5:8080","language":"en"}}
{"id":"10000","name":"Person_0","servedBy":"10.0.0.9","quote":{"quote":"You, too, Brutus?","ipAddress":"768e4b0794f6/10.0.0.8:8080","language":"en"}}

我們可以看到servedBy很好的在accountservice例項中迴圈。 我們也可以看到quote的ipAddress欄位也有兩個不同的IP. 如果我們已經禁用了keep-alive行為的話, 我們可能看到同樣的accountservice服務保持同樣的quotes-service來提供服務。

總結

在本節內容中,我們接觸到了微服務上下文中的服務發現和負載均衡的概念, 以及實現了呼叫其他服務,只需要提供服務邏輯服務名即可。

在第8節中,我們轉向另外一個可自由擴充套件的微服務中最重要的概念, 集中配置。

參考連線

相關文章