這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
第七節: 服務發現和負載平衡
原文地址
轉載請註明原文及翻譯地址
這篇文章將關注兩個微服務架構的重要部分:服務發現和負載平衡.和他們是如何協助我們2017年經常要求的橫向擴充容量的
簡介
負載平衡和出名.服務發現需要一些解釋,從一個問題開始:
"服務A如何請求服務B,如果不知道怎麼找到B"
換句話說,如果你有10個服務B在隨機的叢集節點上運行,有人要記錄這些執行個體,所以當A需要和B聯絡時,至少一個IP地址或者主機名稱可以用(使用者負載平衡),或者說,服務A必須能從第三方得到服務B的邏輯名字(伺服器負載平衡).在微服務架構下,這兩種方法都需要服務發現這一功能.簡單來說,服務發現就是一個各種服務的註冊器
如果這聽起來像dns,確實是.不同是,這個服務發現用在你叢集的內部,協助服務找到彼此.然而,dns通常更靜態,是協助外部來請求你的服務.同時,dns伺服器和dns協議不適合控制微服務多變的環境,容器和節點經常增加和減少.
大部分為服務架構提供一個或多個選擇給服務發現.預設下,spring cloud/netflix OSS用netflix eureka(同時支援consul, etcd, zooKeeper),每個服務會在eureka執行個體中註冊,之後發送heartbeats來讓eureka知道他們還在工作.另一個有名的是consul,他提供很多功能還包括整合的DNS.其他有名的選擇使用索引值對儲存註冊服務,例如etcd.
這裡,我們主要看一下Swarm中的機制.同時,我們看一下用unit test(gock)類比http請求,因為我們要做服務到服務的溝通.
兩種負載平衡
為服務實現中,我們把負載平衡分為兩種:
- 用戶端:用戶端自己請求一個探索服務來得到地址(iP, 主機名稱,連接埠).從這裡面,他們可以隨機或者round-robin方法來選擇一個地址.為了不用每次都從探索服務裡提取,每個用戶端會儲存一些緩衝,同時隨著探索服務更新.用戶端負載平衡在spring cloud生態裡的例子是netflix ribbon.在go-kit中相似的是etcd.用戶端負載平衡的優勢是去除中心化,沒有中心的瓶頸,因為每個服務儲存他們自己的註冊器.缺點是內部服務複雜化和本地註冊器包含不良路徑的風險.
- 伺服器段:這個模型中,用戶端依賴負載平衡器來找到想請求服務的名字.這個模型通常成為代理模式,因為它的作用可以使負載平衡也可以是反向 Proxy.這邊的有點是簡單,負載平衡和服務發現機制通常包含在容器部署裡,你不需要安裝和管理這些部分.同樣,我們的服務不需要知道服務註冊器,負載平衡器會協助我們.所有的請求都通過複雜均衡器將會使他成為瓶頸.
當我們用docker swarm的服務,伺服器端真正的服務(producer service)註冊是完全透明給開發人員的.也就是說,我們的服務不知道他們在伺服器端負載平衡下運行,docker swarm完成整個註冊/heartbeat/解除註冊.
使用服務發現資訊
假設你想建立一個定製的監控應用,需要請求所有部署的服務的/health路徑,你的監控應用怎樣知道這些IP和連接埠.你需要得到服務要求的細節.對於swarm儲存這些資訊,你怎樣得到他們.對於用戶端的方法,例如eureka,你可以直接用api,然而,對於依賴於部署的服務發現,這不容易,我可以說有一個方法來做,同時有好多方法針對於不同的情形.
docker遠程api
我推薦用docker遠程api,用docker api在你的服務中來向swarm manager請求其他服務的資訊.畢竟,如果你用你的容器部署的內建服務發現機制,這也是你應該請求的地方.如果有問題,別人也能寫一個適配器給你的部署.然而,用部署api也有限制:你緊緊以來容器的api,你也要確定你的應用可以和docker manager交流.
其他方案
- 用其他的服務發現機制-netflix eureka, consul等.用這些服務的api來註冊/查詢/heartbeat等.我不喜歡這種方式,因為這讓我們的服務更複雜,而且swarm也可以做這些事.我認為這是反設計模式的,所以一般情況不要做.
- 具體應用的token發現:這種方法下,每個服務發送他們自己的token,帶有IP,服務名等.使用者可以訂閱這些服務,同時更新他們的註冊器.我們看netflix turbine without eureka,我們會用這種機制.這種方法因為不用註冊所有服務而稍有不同,畢竟,這種情況下我們只關心一部分服務.
代碼
git checkout P7
擴充和負載平衡
我們看一下能否啟動多個accountservice執行個體實現擴充同時看我們swarm自動做到負載平衡請求.
為了知道哪個執行個體回複我們的請求,我們加入一個新的Account結構,我們可以輸出ip地址.開啟account.go
type Account struct { Id string `json:"id"` Name string `json:"name"` //new ServedBy string `json:"servedBy"}
開啟handlers.go,加入GetIp()函數,讓他輸出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()函數應該用一些utils包,因為這些可以重複用,當我們需要判斷一個運行服務的non-loopback ip地址.
重新編譯和部署我們的服務
> ./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 lsID NAME REPLICAS IMAGEyim6dgzaimpg accountservice 3/3 someprefix/accountservice
現在有三個執行個體,我們curl幾次,看一看得到的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"}
我們看到四次請求用round-robin的方法分給每一個執行個體.這種swarm提供的服務很好,因為它很方便,我們也不需要像用戶端探索服務那樣從一堆ip地址中選擇一個.而且,swarm不會把請求發送給那些擁有healthcheck方法,卻沒有報告他們健康的節點.當你擴容和縮減很頻繁時,同時你的服務很複雜,需要比accountservice啟動多很多的時間的時候,這將會很重要.
效能
看一看擴容後的延遲和cpu/記憶體使用量吧.會不會增加?
> docker service scale accountservice=4
cpu和記憶體使用量率
gatling測試(1k req/s)
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
我們的四個執行個體平分這些工作,這三個新的執行個體用低於10mb的記憶體,在低於250 req/s情況下.
效能
一個執行個體的gatling測試
四個執行個體的gatling測試
區別不大,本該這樣.因為我們的四個執行個體也是在同一個虛擬機器硬體上啟動並執行.如果我們給swarm分配一些主機還沒用的資源,我們會看到延遲下降的.我們看到一點小小的提升,在95和99平均延遲上.我們可以說,swarm負載平衡沒有對效能有負面影響.
加入quotes
記得我們的基於java的quotes-service麼?讓我們擴容他並且從accountservice請求他,用服務名quotes-service.目的是看一看我們只知道名字的時候,服務發現和負載平衡好不好用.
我們先修改一下account.go
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標籤來轉換名稱,從quote到text,ipAddress到ServedBy.
更改handler.go.我們加一個簡單的getQuote函數來請求http://quotes-service:8080/api/quote,傳回值用來輸出新的Quote結構.我們在GetAccount函數中請求他.
首先,我們處理串連,keep-alive將會有負載平衡的問題,除非我們更改go的http用戶端.在handler.go中,加入:
var client = &http.Client{}func init() { var transport http.RoundTripper = &http.Transport{ DisableKeepAlives: true, } client.Transport = transport}
init方法確保發送的http請求有合適的頭資訊,能使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.如果請求錯誤,返回一個錯誤.
我們從GetAccount函數中請求getQuote函數,把Account執行個體返回的值附給Quote.
// 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}
unit testing發送的http請求
如果我們跑handlers_test.go的unit test,我們會失敗.GetAccount函數會試著請求一個quote,但是這個URL上沒有quotes的服務.
我們有兩個辦法來解決這個問題
1) 提取getQuote函數為一個interface,提供一個真的和一個假的方法.
2) 用http特定的mcking架構處理髮送的請求同時返回一個寫好的答案.內建的httptest包可以幫我們開啟一個內建的http伺服器用於unit test.但是我喜歡用第三方gock架構.
在handlers_test.go中,在TestGetAccount(t *testing)加入init函數.這會使我們的http用戶端執行個體被gock擷取
func inti() { gock.InterceptClient(client)}
gock DSL提供很好地控制給期待的外部http請求和回複.在下面的例子中,我們用New(), Get()和MatchParam()來讓gock期待http://quotes-service:8080/api/quote?strength=4 Get 請求,回複http 200和json字串.
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()確保我們的test會停止http擷取,因為gock.New()會開啟http擷取,這可能會是後來的測試失敗.
然我們斷言返回的quote
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.")})
跑測試
是指跑一下accountservice下所有的測試
重新部署用./copyall.sh,試著curl
> 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
> 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
對於spring boot的quotes-service來說,需要15-30s,不像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的ip地址有兩個.如果我們沒有關閉keep-alive,我們可能只會看到一個quote-service執行個體
總結
這篇我們接觸了服務發現和負載平衡和怎樣用服務名稱來請求其他服務
下一篇,我們會繼續微服務的知識點,中心化配置.