這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
第七部分: 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生態中也存在。用戶端負載平衡的一些優點是具有彈性、分散性以及沒有中心瓶頸,因為每個服務消費者都自己保持有生產端的註冊。 缺點就是具有較高的內部服務複雜性,以及本地註冊可能會包含過時條目的風險。
服務端負載平衡
這個模型中,用戶端依賴負載平衡,提供服務邏輯名來查詢它要調用服務的合適執行個體。這種操作模式通常稱為代理, 因為它既充當負載平衡器又充當反向 Proxy。我認為它的主要優點就是簡單。 負載平衡器和服務發現機制一般都內建於你的容器編排器中,你無需關心安裝和管理這些組件。另外,用戶端(e.g. 我們的服務)不需要知道服務註冊 - 負載平衡器為我們負責這些。 依賴負載平衡器來路由所有呼叫可能降低彈性,並且負載平衡器在理論上來說可能成為效能的瓶頸。
用戶端負載平衡和服務端負載平衡的圖非常相似,區別在於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 FUNCfunc 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 lsID NAME REPLICAS IMAGEyim6dgzaimpg 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=3accountservice scaled to 3
等幾秒鐘,然後再執行docker service ls進行查看,得到下面的內容:
> docker service lsID NAME REPLICAS IMAGEyim6dgzaimpg 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引用一個執行個體:
然後,Gatling引用4個執行個體:
區別不是很大 - 但是不應該啊 - 所有四個服務執行個體畢竟都運行在同樣的虛擬機器主機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 structtype 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, "e) 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 BoltDBaccount, err := DBClient.QueryAccount(accountId)account.ServedBy = getIP()// NEW call the quotes-servicequote, 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節中,我們轉向另外一個可自由擴充的微服務中最重要的概念, 集中配置。
參考串連
- 英文第7部分
- Eureka
- Consul
- Etcd
- ZooKeeper
- Go語言構建微服務
- eeureka
- Turbine AMQP Plugin
- gock
- 專題首頁
- 下一節