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

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

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

簡介

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

若是服務A要和服務B通話,可是殊不知道到哪裏找服務B如何處理?

換句話說, 若是咱們服務B在任意數量的集羣節點上運行了10個實例, 則有人須要跟蹤這10個實例。golang

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

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

大多數微服務框架爲服務發現提供一個或多個選項。 默認狀況下,Spring Cloud/Netflix OSS使用Netflix Eureka(支持Consul, etcd和ZooKeeper), 服務使用已知的Eureka實例來註冊本身,而後間歇性的發送心跳來確保Eureka實例知道它們依然活躍着。Consul提供了一個包含DNS集成的豐富的特徵集的選項已經變得愈來愈流行。 其餘流行的選項是分佈式和可複製key-value存儲的使用, 例如etcd中服務能夠註冊本身。Apache ZooKeeper也將會意識到這樣需求的一羣人。json

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

注意: 當咱們引用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節中,咱們轉向另一個可自由擴展的微服務中最重要的概念, 集中配置。

參考鏈接

相關文章
相關標籤/搜索