高負載微服務系統的誕生過程

junck364 8年前發布 | 45K 次閱讀 微服務

我將告訴大家我們是如何開始一個高負載微服務項目的。在講述我們的經歷之前,先讓我們簡單地自我介紹一下。

簡單地說,我們從事視頻輸出方面的工作——我們提供實時的視頻。我們負責“NTV-Plus”和“Match TV”頻道的視頻平臺。該平臺有30萬的并發用戶,每小時輸出300TB的內容。這是一個很有意思的任務。那么我們是如何做到的呢?

這背后都有哪些故事?這些故事都是關于項目的開發和成長,關于我們對項目的思考。總而言之,是關于如何提升項目的伸縮能力,承受更大的負載,在不宕機和不丟失關鍵特性的情況下為客戶提供更多的功能。我們總是希望能夠滿足客戶的需求。當然,這也涉及到我們是如何實現這一切,以及這一切是如何開始的。

在最開始,我們有兩臺運行在Docker集群里的服務器,數據庫運行在相同機器的容器里。沒有專用的存儲,基礎設施非常簡單。

我們就是這樣開始的,只有兩臺運行在Docker集群里的服務器。那個時候,數據庫也運行在同一個集群里。我們的基礎設施里沒有什么專用的組件,十分簡單。

我們的基礎設施最主要的組件就是Docker和TeamCity,我們用它們來交付和構建代碼。

在接下來的時期——我稱其為我們的發展中期——是我們項目發展的關鍵時期。我們擁有了80臺服務器,并在一組特殊的機器上為數據庫搭建了一個單獨的專用集群。我們開始使用基于 CEPH 的分布式存儲,并開始思考服務之間的交互問題,同時要更新我們的監控系統。

現在,讓我們來看看我們在這一時期都做了哪些事情。Docker集群里已經有數百臺服務器,微服務就運行在它們上面。這個時候,我們開始根據數據總線和邏輯分離原則將我們的系統拆分成服務子系統。當微服務越來越多時,我們決定拆分我們的系統,這樣維護起來就容易得多(也更容易理解)。

(點擊放大圖像)

這張圖展示的是我們系統其中的一小部分。這部分系統負責視頻剪切。半年前,我在“RIT++”也展示過類似的圖片。那個時候只有17個綠色的微服務,而現在有28個綠色的微服務。這些服務只占我們整個系統的二十分之一,所以可以想象我們系統大致的規模有多大。

深入細節

服務間的通信是一件很有趣的事情。一般來說,我們應該盡可能提升服務間通信效率。我們使用了 protobuf ,我們認為它就是我們需要的東西。

它看起來是這樣的:

微服務的前面有一個負載均衡器。請求到達前端,或者直接發送給提供了JSON API的服務。protobuf被用于內部服務之間的交互。

protobuf真是一個好東西。它為消息提供了很好的壓縮率。現如今有很多框架,只要使用很小的開銷就能實現序列化和反序列化。我們可以將其視為有條件的請求類型。

但如果從微服務角度來看,我們會發現,微服務之間也存在某種私有的協議。如果只有一兩個或者五個微服務,我們可以為每個微服務打開一個控制臺,通過它們來訪問微服務,并獲得響應結果。如果出現了問題,我們可以對其進行診斷。不過這在一定程度上讓微服務的支持工作變得復雜。

在一定時期內,這倒不是什么問題,因為并沒有太多的微服務。另外,Google發布了 gRPC 。在那個時候,gRPC滿足了所有我們想做的事情。于是我們逐漸遷移到gRPC。于是我們的技術棧里出現了另一個組件。

實現的細節也是很有趣的。gRPC默認是基于HTTP/2的。如果你的環境相對穩定,應用程序不怎么發生變更,也不需要在機器間遷移,那么gRCP對于你來說就是個不錯的東西。另外,gRPC支持很多客戶端和服務器端的編程語言。

現在,我們從微服務角度來看待這個問題。從一方面來看,gRPC是一個好東西,但從另一方面來看,它也有不足之處。當我們開始對日志進行標準化(這樣就可以將它們聚合到一個獨立的系統里)時,我們發現,從gRPC中抽取日志非常麻煩。

于是,我們決定開發自己的日志系統。它解析消息,并將它們轉成我們需要的格式。這樣我們才可以獲得我們想要的日志。還有一個問題,添加新的微服務會讓服務間的依賴變得更加復雜。這是微服務一直存在的問題,這也是除版本問題之外的另一個具有一定復雜性的問題。

于是,我們開始考慮使用JSON。在很長的一段時間里,我們無法相信,在使用了緊湊的二進制協議之后會轉回使用JSON。有一天,我們看到一篇文章,來自 DailyMotion 的一個家伙在文章里提到了同樣的事情:“我們知道該如何使用JSON,每個人都可以使用JSON。既然如此,為什么還要自尋煩惱呢?”

于是,我們逐漸從gRPC轉向我們自己實現的JSON。我們保留了HTTP/2,它與JSON組合起來可以帶來更快的速度。

現在,我們具備了所有必要的特性。我們可以通過cURL訪問我們的服務。我們的QA團隊使用 Postman ,所以他們也感覺很滿意。一切都變得簡單起來。這是一個有爭議性的決定,但卻為我們帶來了很多好處。

JSON唯一的缺點就是它的緊湊性不足。根據我們的測試結果,它與MessagePack之間有30%的差距。不過對于一個支持系統來說,這不算是個大問題。

況且,我們在轉到JSON之后還獲得了更多的特性,比如協議版本。有時候,當我們在新版本的協議上使用protobuf時,客戶端也必須改用protobuf。如果你有數百個服務,就算只有10%的服務進行了遷移,這也會引起很大的連鎖反應。你在一個服務上做了一些變更,就會有十多個服務也需要跟著改動。

因此,我們就會面臨這樣的一種情況,一個服務的開發人員已經發布了第五個、第六個,甚至第七個版本,但生產環境里仍然在運行第四個版本,就因為其他相關服務的開發人員有他們自己的優先級和截止日期。他們無法持續地更新他們的服務,并使用新版本的協議。所以,新版本的服務雖然發布了,但還派不上用場。然后,我們卻要以一種很奇怪的方式來修復舊版本的bug,這讓支持工作變得更加復雜。

最后,我們決定停止發布新版本的協議。我們提供協議的基礎版本,可以往里面添加少量的屬性。服務的消費者開始使用JSON schema。

標準看起來是這樣的:

我們沒有使用版本1、2和3,而是只使用版本1和指向它的schema。

這是從我們服務返回的一個典型的響應結果。它是一個內容管理器,返回有關廣播的信息。這里有一個消費者schema的例子。

最底下的字符串最有意思,也就是"required"那塊。我們可以看到,這個服務只需要4個字段——id、content、date和status。如果我們使用了這個schema,那么消費者就只會得到這樣的數據。

它們可以被用在每一個協議版本里,從第一個版本到后來的每一個變更版本。這樣,在版本之間遷移就容易很多。在我們發布新版本之后,客戶端的遷移就會簡單很多。

下一個重要的議題是系統的穩定性問題。這是微服務和其他任何一個系統都需要面臨的問題(在微服務架構里,我們可以更強烈地感覺到它的重要性)。系統總會在某個時候變得不穩定。

如果服務間的調用鏈只包含了一兩個服務,那么就沒有什么問題。在這種情況下,你看不出單體和分布式系統之間有多大區別。但當調用鏈里包含了5到7個調用,那么問題就會接踵而至。你根本不知道為什么會這樣,也不知道能做些什么。在這種情況下,調試會變得很困難。在單體系統里,你可以通過逐步調試來找出錯誤。但對于微服務來說,網絡不穩定性或高負載下的性能不穩定性也會對微服務造成影響。特別是對于擁有大量節點的分布式系統來說,這些情況就更加顯而易見了。

在一開始,我們采用了傳統的辦法。我們監控所有的東西,查看問題和問題的發生點,然后嘗試盡快修復它們。我們將微服務的度量指標收集到一個獨立的數據庫里。我們使用 Diamond 來收集系統度量指標。我們使用 cAdvisor 來分析容器的資源使用情況和性能特征。所有的結果都被保存到 InfluxDB ,然后我們在Grafana里創建儀表盤。

于是,我們現在的基礎設施里又多了三個組件。

我們比以往更加關注所發生的一切。我們對問題的反應速度更快了。不過,這并沒有阻止問題的出現。

奇怪的是,微服務架構的主要問題出在那些不穩定的服務上。它們有的今天運行正常,明天就不行,而且有各種各樣的原因。如果服務出現超載,而你繼續向它發送負載,它就會宕機一段時間。如果它在一段時間不提供服務,負載就會下降,然后它就又活過來了。這類系統很難維護,也很難知道到底出了什么問題。

最后,我們決定把這些服務停掉,而不是讓它們來回折騰。我們因此需要改變服務的實現方式。

我們做了一件很重要的事情。我們對每個服務接收的請求數量設定了一個上限。每個服務知道自己可以處理多少個來自客戶端的請求(我們稍后會詳細說明)。如果請求數量達到上限,服務將拋出503 Service Unavailable異常。客戶端知道這個節點無法提供服務,就會選擇另一個節點。

當系統出現問題時,我們就可以通過這種方式來減少請求時間。另外,我們也提升了服務的穩定性。

我們引入了第二種模式—— 回路斷路器 (Circuit Breaker)。我們在客戶端實現了這種模式。

假設有一個服務A,它有4個可以訪問的服務B的實例。它向注冊中心索要服務B的地址:“給我這些服務的地址”。它得到了服務B的4個地址。服務A向第一個服務B的實例發起了請求。第一個服務B實例正常返回響應。服務A將其標記為可訪問:“是的,我可以訪問它”。然后,服務A向第二個服務B實例發起請求,不過它沒有在期望的時間內得到響應。我們禁用了這個實例,然后向下一個實例發起請求。下一個實例因為某些原因返回了不正確的協議版本。于是我們也將其禁用,然后轉向第四個實例。

總得來說,只有一半的服務能夠為客戶端提供服務。于是服務A將會向能夠正常返回響應的兩個服務發起請求。而另外兩個無法滿足要求的實例被禁用了一段時間。

我們通過這種方式來提升性能的穩定性。如果服務出現了問題,我們就將其關閉,并發出告警,然后嘗試找出問題所在。

因為引入了回路斷路器模式,我們的基礎設施里又多了一個組件—— Hystrix

Hystrix不僅實現了回路斷路器模式,它也有助于我們了解系統里出現了哪些問題:

(點擊放大圖像)

圓環的大小表示服務與其他組件之間的流量大小。顏色表示系統的健康狀況。如果圓環是綠色的,那么說明一切正常。如果圓環是紅色的,那么就有問題了。

如果一個服務應該被停掉,那么它看起來是這個樣子的。圓環是打開的。

我們的系統變得相對穩定。每個服務至少都有兩個可用的實例,這樣我們就可以選擇停掉其中的一個。不過,盡管是這樣,我們仍然不知道我們的系統究竟發生了什么問題。在處理請求期間如果出現了問題,我們應該怎樣才能知道問題的根源是什么呢?

這是一個標準的請求:

這是一個處理鏈條。用戶發送請求到第一個服務,然后是第二個。從第二個服務開始,鏈條將請求發送到第三個和第四個服務。

然后一個分支不明原因地消失了。在經歷了這類場景之后,我們嘗試著提升這種場景的可見性,于是我們找到了Appdash。Appdash是一個跟蹤服務。

它看起來是這個樣子的:

可以這么說,我們只是想嘗試一下,看看它是否適合我們。將它用在我們的系統里是一件很容易的事情,因為我們那個時候使用的是Go語言。Appdash提供了一個開箱即用的包。我們認為Appdash是一個好東西,只是它的實現并不是很適合我們。

高負載微服務系統的誕生過程

于是,我們決定使用 Zipkin 來代替 Appdash 。Zipkin是由推ter開源的。它看起來是這個樣子的:

(點擊放大圖像)

我認為這樣會更清楚一些。我們可以從中看到一些服務,也可以看到我們的請求是如何通過請求鏈的,還可以看到請求在每個服務里都做了哪些事情。一方面,我們可以看到服務的總時長和每個分段的時長,另一方面,我們完全可以添加描述服務內容的信息。

我們可以在這里添加一些與數據庫的調用、文件系統的讀取、緩存的訪問有關的信息,這樣就可以知道請求里哪一部分使用了最多的時間。TraceID可以幫助我們做到這一點。稍后我會介紹更多細節。

我們就是通過這種方式知道請求在處理過程中發生了什么問題,以及為什么有時候無法被正常處理。剛開始一切都正常,然后突然間,其中的一個出現了問題。我們稍作排查,就知道出問題的服務發生了什么。

不久前,一些廠商推出了一個跟蹤系統的標準。為了簡化系統的實現,主要的幾個跟蹤系統廠商在如何設計客戶端API和客戶端類庫上達成了一致。現在已經有了 OpenTracing 的實現,支持幾乎所有的主流開發語言。現在就可以使用它了。

我們已經有辦法知道那些突然間崩潰的服務。我們可以看到其中的某部分在垂死掙扎,但是不知道為什么。光有環境信息是不夠的,

我們還需要日志。是的,這應該成為標準的一部分,它就是Elasticsearch、Logstash和Kibana(ELK)。不過我們對它們做了一些改動。

我們并沒有將大量的日志直接通過forward傳給Logstash,而是先傳給 syslog ,讓它把日志聚合到構建機器上,然后再通過forward導入到 ElasticsearchKibana 。這是一個很標準的流程,那么巧妙的地方在哪里呢?

巧妙的是,我們可以在任何可能的地方往日志里加入Zipkin的TraceID。

這樣一來,我們就可以在Kibana儀表盤上看到完整的用戶請求執行情況。也就是說,一旦服務進入生產環境,就為運營做好了準備。它已經通過了自動化測試,如果有必要,QA可以再進行手動檢查。它應該沒有什么問題。如果它出現了問題,那說明有一些先決條件沒有得到滿足。日志里詳細地記錄了這些先決條件,通過過濾,我們可以看到某個請求的跟蹤信息。我們因此可以快速地查出問題的根源,為我們節省了很多時間。

我們后來引入了動態調試模式。現在的日志數量還不是很大,大概只有100 GB到150 GB,我記不太清楚具體數字了。不過,這些日志是在正常的日志模式下生成的。如果我們添加更多的細節,那么日志就可能變成TB級別的,處理起來就很耗費資源。

當我們發現某些服務出現問題,就打開調試模式(通過一個API),看看發生了什么事情。有時候,我們找到出現問題的服務,在不將它關閉的情況下打開調試模式,嘗試找出問題所在。

最后,我們在ELK端查找問題。我們還對關鍵服務的錯誤進行聚合。服務知道哪些錯誤是關鍵性的,哪些不是關鍵性的,然后將它們傳給 Sentry

Sentry能夠智能地收集錯誤日志,并形成度量指標,還會進行一些基本的過濾。我們在很多服務上使用了Sentry。我們從單體應用時期就開始使用它了。

那么最有趣的問題是,我們是如何進行伸縮的?這里需要先介紹一些概念。我們把每個機器看成一個黑盒。

我們有一個編排系統,最開始使用 Nomad 。確切地說,應該是 Ansible 。我們自己編寫腳本,但光是這些還不能滿足要求。那個時候,Nomad的某些版本可以簡化我們的工作,于是我們決定遷移到Nomad。

同時還使用了 Consul ,將它作為服務發現的注冊中心。還有Vault,用于存儲敏感數據,比如密碼、秘鑰和其他所有不能保存在Git上的東西。

這樣,所有的機器幾乎都變得一模一樣。每個機器上都安裝了 Docker ,還有 ConsulNomad 代理。總的來說,每一個機器都處于備用狀態,可以在任何時候投入使用。如果不用了,我們就讓它們下線。如果你構建了云平臺,你就可以先準備好機器,在高峰期時將它們打開,在負載下降時將它們關閉。這會節省大量的成本。

后來,我們決定從 Nomad 遷移到 KubernetesConsul 也因此成為了集中式的配置系統。

這樣一來,部分棧可以進行自動伸縮。那么我們是怎么做的呢?

第一步,我們對內存、CPU和網絡進行限制。

我們分別將這三個元素分成三個等級,砍掉其中的一部分。例如,

R3-C2-N1,我們已經限定只給某個服務一小部分網絡流量、多一點點的CPU和更多的內存。這個服務真的很耗費資源。

我們在這里使用了助記符,我們的決策服務可以設置很多的組合值,這些值看起來是這樣的:

事實上,我們還有C4和R4,不過它們已經超出了這些標準的限制。標準看起來是這樣的:

下一步開始做一些預備工作。我們先確定服務的伸縮類型。

獨立的服務最容易伸縮,它可以進行線性地伸縮。如果用戶增長了兩倍,我們就運行兩倍的服務實例。這就萬事大吉了。

第二種伸縮類型:服務依賴了外部的資源,比如那些使用了數據庫的服務。數據庫有它自己的容量上限,這個一定要注意。你還要知道,如果系統性能出現衰退,就不應該再增加更多的實例,而且你要知道這種情況會在什么時候發生。

第三種情況是,服務受到外部系統的牽制。例如,外部的賬單系統。就算運行了100個服務實例,它也沒辦法處理超過500個請求。我們要考慮到這些限制。在確定了服務類型并設置了相應的標記之后,是時候看看它們是如何通過我們的構建管道的。

我們在CI服務器上運行了一些單元測試,然后在測試環境運行集成測試,我們的QA團隊會對它們做一些檢查。在這之后,我們就進入了預生產環境的負載測試。

如果是第一種類型的服務,我們使用一個實例,并在這個環境里運行它,給它最大的負載。在運行了幾輪之后,我們取其中的最小值,將它存入 InfluxDB ,將它作為該服務的負載上限。

如果是第二種類型的服務,我們逐漸加大負載,直到出現了性能衰退。我們對這個過程進行評估,如果我們知道該系統的負載,那么就比較當前負載是否已經足夠,否則,我們就會設置告警,不會把這個服務發布到生產環境。我們會告訴開發人員:“你們需要分離出一些東西,或者加進去另一個工具,讓這個服務可以更好地伸縮。”

因為我們知道第三種類型服務的上限,所以我們只運行一個實例。我們也會給它一些負載,看看它可以服務多少個用戶。如果我們知道賬單系統的上限是1000個請求,并且每個服務實例可以處理200個請求,那么就需要5個實例。

我們把這些信息都保存到了InfluxDB。我們的決策服務開始派上用場了。它會檢查兩個邊界:上限和下限。如果超出了上限,那么就應該增加服務實例。如果超出下限,那么就減少實例。如果負載下降(比如晚上的時候),我們就不需要這么多機器,可以減少它們的數量,并關掉一部分機器,省下一些費用。

整體看起來是這樣的:

每個服務的度量指標表明了它們當前的負載。負載信息被保存到InfluxDB,如果決策服務發現服務實例達到了上限,它會向Nomad和Kubernetes發送命令,要求增加服務實例。有可能在云端已經有可用的實例,或者開始做一些準備工作。不管怎樣,發出要求增加新服務實例的告警才是關鍵所在。

一些受限的服務如果達到上限,也會發出相關的告警。對于這類情況,我們除了加大等待隊列,也做不了其他什么事情。不過最起碼我們知道我們很快就會面臨這樣的問題,并開始做好應對措施。

這就是我想告訴大家有關伸縮性方面的事情。除了這些,還有另外一個東西—— Gitlab CI

我們一般是通過TeamCity來開發服務的。后來,我們意識到,所有的服務都有一個共性,這些服務都是不一樣的,并且知道自己該如何部署到容器里。要生成這么的項目真的很困難,不過如果使用yml文件來描述它們,并把這個文件與服務放在一起,就會方便很多。雖然我們只做了一些小的改變,不過卻為我們帶來了非常多的可能性。

現在,我想說一些一直想對自己說的話。

關于微服務開發,我建議在一開始就使用 編排系統 。可以使用最簡單的編排系統,比如Nomad,通過nomad agent -dev命令啟動一個編排系統,包括Consul和其他東西。

我們仿佛是在一個黑盒子工作。你試圖避免被綁定到某臺特定的機器上,或者被附加到某臺特定機器的文件系統上。這些事情會讓你開始重新思考。

在開發階段, 每個服務至少需要兩個實例 ,如果其中一個出現問題,就可以關掉它,由另一個接管繼續服務。

還有一些有關架構的問題。在微服務架構里, 消息總線 是一個非常重要的組件。

假設你有一個用戶注冊系統,那么如何以最簡單的方式實現它呢?對于注冊系統來說,需要創建賬戶,然后在賬單系統里創建一個用戶,并為他創建頭像和其他東西。你有一組服務,其中的超級服務收到了一個請求,它將請求分發給其他服務。經過幾次之后,它就知道該觸發哪些服務來完成注冊。

不過,我們可以使用一種更簡單、更可靠、更高效的方式來實現。我們使用一個服務來處理注冊,它注冊了一個用戶,然后發送一個事件到消息總線,比如“我已經注冊了一個yoghurt,ID是……”。相關的服務會收到這個事件,其中的一個服務會在賬單系統里創建一個賬戶,另一個服務會發送一封歡迎郵件。

不過,系統會因此失去強一致性。這個時候你沒有超級服務,也不知道每個服務的狀態。不過,這樣的系統很容易維護。

現在,我再說一些之前提到過的問題。 不要試圖修復 出問題的服務。如果某些服務實例出現了問題,將它找出來,然后把流量定向到其他服務實例(可能是新增的實例)上,然后再診斷問題。這樣可以顯著提升系統的可用性。

通過 收集度量指標 來了解系統的狀態自然不在話下。

不過要注意,如果你對某個度量指標不了解,不知道怎么使用它,或者它對你來說沒有什么意義,就不要收集它。因為有時候,這樣的度量指標會有數百萬個。你在這些無用的度量指標上面浪費了很多資源和時間。這些是無效的負載。

如果你認為你需要某些度量指標,那么就收集它們。如果不需要,就不要收集。

如果你發現了一個問題,不要急著去修復。在很多情況下, 系統會對此作出反應 。當系統需要你采取行動的時候,它會給你發出告警。如果它不要求你在半夜跑去修復問題,那么它就不算是一個告警。它只不過是一種警告,你可以在把它當成一般的問題來處理。

感謝郭蕾對本文的審校。

給InfoQ中文站投稿或者參與內容翻譯工作,請郵件至editors@cn.infoq.com。也歡迎大家通過新浪微博(@InfoQ,@丁曉昀),微信(微信號: InfoQChina )關注我們。

 

來自:http://www.infoq.com/cn/articles/birth-process-of-high-load-micro-service-system

 

 本文由用戶 junck364 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!