告別微服務:究竟是千軍易得還是一將難求
白小白:
初看這篇文章時,我實際上是有些猶豫要不要把他翻譯過來的。畢竟多數人,包括我們的團隊也都在採用和推崇微服務架構。這個時候談及“告別微服務”的話題,是否有些不合時宜。然而,文章中關於從單體到微服務再到單體的架構變遷過程,我認為對很多希望從微服務中獲得收益的團隊是很有意義的。文章中也涉及了很現實很落地的一些關於挑戰的應對以致妥協。正如此前火山哥的文章《 ofollow,noindex">微服務的4個設計原則和19個解決方案 》中所講的“實際上微服務也不是個萬金油”,回顧一下那篇文章中的原則,再看一下本文中的實踐過程,將對微服務架構的採用和取捨有更深切的認知。很多時候,不在於是否最好,而在於是否適合自己。
當然,這只是我選擇這篇文章理由之一,另一個理由是,我總感覺,作為文章中所舉的例子來說,似乎並非只有迴歸單體應用這一條路線,比如,我並未在文章中看到諸如API閘道器的相關描述--文中所講的訊息路由只解決了閘道器的一部分問題,就像是簡單的NGINX代理一樣--而API閘道器是微服務架構中非常關鍵的組成部分。此外,關於程式碼庫的拆分(畢竟這是作者的團隊決定迴歸單體結構的重要原因之一),此前葉婉婷的文章《 當持續整合遇上微服務:分治優於集中 》中所提到的“多個程式碼庫多個構建的方案”是否能夠具有參考意義?
所以,也希望有興趣的讀者在評論區留言討論一下,是否就作者的情況而言,必須和微服務說 Bye bye ?
除非與世隔絕,幾乎所有人都瞭解微服務是當下最炙手可熱的技術架構之一。因應這一趨勢,Segment(即作者所在的團隊)曾在早期將微服務做為最佳實踐,並且也確實在某些領域產生了實效,但在另一方面,正如本文將揭示的情況,微服務並非在任何情況下都普適的技術架構。
簡而言之,微服務是一種面向服務的軟體架構,在微服務的場景下,伺服器端應用程式是通過將許多用途單一、體積小巧的網路服務相結合來構建的。人們常常將微服務帶來的如下優勢掛在嘴邊:提高了模組化、減少了測試負擔、更好的功能組合、環境隔離和開發團隊自主性。作為反例的則是單體應用結構,在單體應用中,大量的功能駐留在一個服務中,該服務作為單一單元被測試、部署和擴充套件。
2017年初, 對微服務架構的採用,使得Segment產品的一個核心部分達到了臨界點。那場景就像我們從一棵名字叫做微服務的樹上掉下來,一路上撞到了每一根樹枝。小團隊非但沒有讓我們走得更快,反而讓我們陷入了複雜度爆棚的泥淖。這種架構的本質優勢成為了一種負擔。我們的交付速度驟降,缺陷率爆表。
最終,我們不得不安排 3名全職工程師將大部分時間花在保持系統正常執行上,以至於團隊沒有辦法取得更多有益的進展。此時,我們開始思考轉變。這篇文章講述了我們如何後退一步,並採納了一種與我們的產品需求和團隊需求很好匹配的方法。
微服務是有效的,至少曾經如此
你愛我麼?
愛過。
Segment的客戶資料基礎設施每秒攝取數十萬個事件,並將它們轉發給合作伙伴API,這些API我們稱之為“服務端節點”。這些節點有上百種,比如Google Analytics,Optimizely,或者一個定製的webhook。
倒退數年,當該產品最初推出時,其架構非常簡單。有一個API,它攝取事件並將它們轉發到分散式訊息佇列。事件可能是由Web或移動應用程式生成的JSON物件,其中包含有關使用者及其操作的資訊。像下面的樣子:
{
"type": "identify",
"traits": {
"name": "Alex Noonan",
"email": "[email protected]",
"company": "Segment",
"title": "Software Engineer"
}
"userId": "97980cfea0067"
}
當從佇列中轉發事件時,系統會檢查客戶管理設定,以決定哪些目標應該接收該事件。然後,事件被一個接一個地傳送到對應的API,這很有效,因為開發人員只需要將事件傳送到單個端點,即Segment的API即可,而不需要面對複雜的整合場景。Segment負責處理對每個目標端點的請求。
如果其中一個請求失敗了,有時我們會嘗試稍後重發該事件。對於有些失敗來說,重試是安全的,而另一些則不是。可重試的是那些原封不動就可以被目標節點接受的錯誤。例如,HTTP 500s、速率限制和超時。而像無效憑據或缺少必需欄位這類錯誤,是確定的不可能被目標節點接受的,因此也不會重試。
此時,一個佇列裡擠滿了涵蓋所有目標節點的事件,既有新的事件,也有嘗試多次的請求,從而將導致著名的“排頭阻塞”。這意味著,在這種情況下,如果某個目標節點響應減慢或速度下降,重試將淹沒佇列,導致跨越所有目標節點的延遲。
想象一下,目標節點X臨時出了點問題,導致每個請求都產生超時錯誤。這不僅會造成大量尚未到達目標節點X的請求積壓,而且每個失敗的事件都會被放回佇列中進行重試。雖然我們的系統會自動伸縮以響應增加的負載,但佇列深度的突然增加將超過我們的擴充套件能力,從而導致對最新事件的延遲。因為目標節點X有臨時的問題,導致所有目標節點的交付時間都會增加。客戶指望著系統的及時交付,因此我們無法承受在分發渠道上的任何等待時間的增加。
為了解決排頭阻塞問題,我們的團隊為每個目標建立了一個單獨的服務和佇列。這個新架構設定了一個額外的路由程序來接收入站事件並將事件副本分發給每個選定的目標。如果某一目標遇到問題,只有它的佇列會重試,其他目標則不會受到影響。這種微服務風格的體系結構將目標彼此隔離開來,當一個目標經常遇到問題時,這一點至關重要。
在上述的微服務場景下,由於每個目標API使用不同的請求格式,需要自定義程式碼來翻譯事件以匹配此格式。舉個簡單的例子,目標節點X需要在訊息中以traits.dob的形式傳送生日,而我們的API能接受的格式是 traits.birthday 。那麼節點X中的轉換程式碼看起來就是像下面這個樣子:
const traits = {}
traits.dob = segmentEvent.birthday
有許多晚近的目標節點採用了Segment的請求格式,使得一些轉換相對簡單。但是,有些轉換可能非常複雜,這取決於目標API的結構。例如,對於一些早期的介面雜亂無章的目標節點來說,我們將不得不在手工構建的XML訊息中插入引數來完成使命。
最初,當目標被劃分為不同的服務時,所有程式碼都放在一個程式碼庫中。在這種情況下,最讓人抓狂的就是,針對某一節點的測試失敗將導致針對所有節點的失敗。如果想要部署一個變更,我們還得花時間來修復此前失敗的測試,而對這個測試的修復完全與我們要做的變更無關。針對這個問題,我們決定將每個節點的程式碼放在各自單獨的程式碼庫中。所有節點都已劃分為單獨的微服務,這樣的設定也是順理成章的。
拆分成單獨的程式碼庫使我們能夠輕鬆地隔離目標測試。這種隔離允許開發團隊在維護目標節點時快速交付。
微服務和程式碼庫的擴容
隨著時間的推移,我們增加了50多個新的目標節點,這意味著50個新的程式碼庫。為了減輕開發和維護這些程式碼庫的負擔,我們建立了共享庫,以使跨目標的通用轉換和功能(如HTTP請求處理)更容易、更統一。
例如,如果我們希望從事件中獲得使用者的名稱,則在任何節點程式碼中都可以呼叫 event.name() 。共享庫將從事件中檢索屬性值 name 和 Name ,如果這些名稱不存在,則會檢查姓名裡“名”的部分,如屬性值 firstName、first_Name和FirstName。然後對“姓”同樣操作,檢查大小寫,並將兩者結合起來形成全名。
Identify.prototype.name = function() {
var name = this.proxy('traits.name');
if (typeof name === 'string') {
return trim(name)
}
var firstName = this.firstName();
var lastName = this.lastName();
if (firstName && lastName) {
return trim(firstName + ' ' + lastName)
}
}
共享庫使得新目標節點的構建快速進行。一組統一的共享功能所帶來的熟悉性使維護變得不那麼令人頭痛。
然而,一個新的問題開始出現。測試和部署對這些共享庫的變更同樣會影響到我們的所有目標節點。這需要大量的時間和精力來進行維護。對共享庫作出的變更,將導致我們必須測試和部署幾十個服務,這裡存在一定的風險。在時間緊迫的情況下,工程師只會將這些庫的較新版本引入在目標節點的codebase裡。
隨著時間的推移,這些共享庫的版本在不同的目標codebase之間開始出現差異。在目的碼庫之間減少定製化所獲得的巨大好處開始逆轉。因為最終,所有目標都使用了這些共享庫的不同版本。我們本可以構建一些工具來自動滾動更新,但此時,不僅開發人員的生產力受到了影響,而且我們開始遇到微服務體系結構中的其他問題。
比如,每個服務都有一個不同的負載模式。有些服務每天只處理幾個事件,而另一些服務則每秒處理數千個事件。對於處理少量事件的目標,每當負載意外激增時,運維團隊就必須手動擴充套件服務以滿足需求。
雖然我們已經實現了自動伸縮,但每個服務都有一個所需的CPU和記憶體資源的配置組合,這使得自動伸縮的調優與其實說是一門科學更不如說是一門藝術。
目標節點的數量繼續快速增長,基本上平均每月增加3個節點,這意味著更多的程式碼庫、更多的佇列和更多的服務。在我們的微服務體系結構中,我們的運維開銷隨每個新增的目標節點線性增加。因此,我們決定後退一步,重新考慮整個分發渠道的架構。
丟棄微服務和佇列
首要工作是將現在超過140個服務合併成一個服務。管理所有這些服務的開銷對我們的團隊來說是一個巨大的負擔。隨時待命的工程師通常會被傳呼來處理負載高峰,這讓我們夜不能寐。
然而,此時的架構將面臨單一服務的挑戰。當每個目標都有一個單獨的佇列時,需要設定專門的Worker程序檢查佇列的工作狀態,這增加了節點服務的複雜性,讓人感覺很煩。這是我們採用Centrifuge的初衷。Centrifuge將取代我們所有的單獨佇列,並負責將事件傳送到唯一的單體服務。
考慮到我們現在只有一個服務,將所有目的碼移動到一個程式碼庫中是有意義的,當然,這也意味著將所有不同的依賴項和測試合併到一個程式碼庫中。這會很亂。
對於120個單獨的依賴項中的每一個,我們都努力為所有目標提供一個統一版本。當遷移目標的過程中,我們會檢查它使用的依賴項,並將它們更新為最新版本。我們修復了目標節點中的任何與新版本不同的地方。
通過這種轉換,我們不再需要跟蹤依賴之間的版本差異。我們所有的目標都使用相同的版本,這大大降低了整個程式碼庫的複雜性。現在,維護目標節點變得更省時,風險也更小。
我們還需要一個測試套件,使我們能夠快速、輕鬆地執行所有的目標測試。上文我們也講過,在對共享庫的更新過程中,測試是主要的麻煩之一。
幸運的是,目標節點的測試都有相似的結構。這些結構中包含了基本的單元測試,以驗證我們的自定義轉換邏輯是正確的,並將對合作夥伴的端點執行HTTP請求,以驗證事件是否如預期的那樣出現在目標節點中。
回想一下,將每個節點的codebase分離為單獨程式碼庫的最初動機是隔離測試失敗。然而,事實證明,隔離帶來的優勢是一種假象。發出HTTP請求的測試仍然以某種頻率失敗。由於目的地被分割成自己的程式碼庫,所以沒有人有動力去清理失敗的測試。這種不良的衛生狀況導致了技術債務的不斷惡化。通常情況下,一個本應只需一兩個小時的小變更 最終需要幾天到一週的時間才能完成。
構建彈性測試套件
在測試執行期間,對目標端點的出站HTTP請求是測試失敗的主要原因。而有一些不相關的問題,如過期的憑據,本不應導致測試的失敗。從經驗資料還可以知道,一些目標端點比其他端點慢得多,有些目標甚至要花費5分鐘進行測試。這樣,對於超過140個目標,我們的測試套件可能需要一個小時的執行時間。
為了解決這兩個問題,我們建立了流量記錄儀。流量記錄儀建立在yakbak之上,負責記錄和儲存目標節點的測試流量。每當第一次執行測試時,任何請求及其相應的響應都會記錄到檔案中。在隨後的測試執行中,將回放檔案中的請求和響應,而不是請求目標的端點。這些檔案被簽入程式碼庫,以便測試在每次變更中都是一致的。現在測試套件不再依賴於網路層的HTTP請求,測試變得更有彈性,這是遷移到單個程式碼庫的必備條件。
我記得在集成了流量記錄儀之後,我第一次為每個目標執行測試。完成所有140多個目標的測試只花了幾毫秒的時間。而在過去,對於一個目標可能就需要幾分鐘才能完成。這感覺就像做夢一樣。
為什麼單體架構有效了?
一旦所有目標的程式碼駐留在一個程式碼庫中,它們就可以合併到一個服務中。因為所有目標節點都在一個統一的服務中進行維護,我們的開發人員生產力大大提高了。我們不再需要部署140多個服務來更改一個共享庫。工程師可以在幾分鐘內部署服務。
改進的速度可以證明這一點。2016年,當我們仍採用微服務架構的時候,我們對我們的共享庫進行了32次改進。就在今年,我們進行了46次改進。在過去的6個月裡,我們的共享庫比2016年全年做了更多的改進。
這一變化也有益於我們的運維情況。由於每個目標都整合在一個統一服務中,我們得以建立一個更好的配置組合來應對CPU和記憶體密集型目標節點的需要,這使得按需擴充套件變得非常容易。大型資源池可以吸收負載中的峰值,我們再也不需要為處理少量負載的激增而疲於奔命。
缺點
從微服務體系結構向單一體系結構的轉變從整體上來說是巨大的改進,然而,也產生了一些不足:
故障隔離變得困難了。 如果某個目標中引入了一個bug,導致該服務崩潰,則該服務將對所有目標節點崩潰(一般我們稱之為單點失敗)。我們具備全面的自動化測試,但也僅此而已。我們目前正在研究一種更有力的方法,在保持目標節點單體式架構的前提下預防單點失敗。
記憶體中的快取效率較低。 以前,每個節點有一個單獨的服務,一些低流量節點只有少數幾個程序,這意味著記憶體快取將得以充分利用。現在,快取在3000多個程序中分散得很細,因此被命中的可能性要小得多。是可以用Redis這樣的方法來解決這個問題,但這樣一來又將面臨另一個需要考慮伸縮的方面。最後,我們選擇接受了這種效率的損失,畢竟這將帶來巨大的運維效益。
更新依賴項的版本可能會破壞多個目標節點。 雖然統一的程式碼庫解決了以前我們所遇到的依賴關係混亂的問題,但這也意味著如果我們想對某目標使用庫的最新版本,就需要更新其他不相關的目標節點來適配這一新的版本。然而,在我們看來,現有方案簡單性就值回了票價。而且使用我們的全面自動化測試套件,我們可以快速地發現新的依賴版本的究竟在何處不相容目標節點。
結語
我們最初的微服務架構在一段時間是有效的,並且通過將目標的彼此隔離來解決分發渠道中的即時效能問題。然而,那時我們系統尚沒有形成規模。當需要大量更新時,我們缺乏測試和部署微服務的適當工具。結果,我們的開發人員生產力迅速下降。
轉向單體應用架構可以使我們擺脫運維問題,同時顯著地提高了開發人員的生產力。當然,這一轉變並不容易,如果想讓這一架構持續起作用,還有很多事情需要考慮:
我們需要一個堅不可摧的測試套件來應對一個統一的程式碼庫。 如果沒有這一點,我們的處境就會與我們當初決定拆散它們時的情況一樣。過去,不斷失敗的測試損害了我們的生產力,我們不希望這種情況再次發生。
我們接受了單體架構固有的缺陷,並確保對這些缺陷良好應對。 我們也必須適應這種變化所帶來的一些犧牲。
在決定採用微服務架構還是單體架構時,有不同的考慮因素。在我們基礎設施的某些部分,微服務執行良好,但我們的服務端節點的樣例則很好的說明了這種流行趨勢實際上會損害生產力和效能。最終結果是,我們選擇了單體架構。
Stephen Mathieson, Rick Branson, Achille Roussel, Tom Holmes和更多人促成了向單體應用的轉變。特別感謝Rick Branson幫助審查和編輯這篇文章的每一個階段。
原文釋出時間為:2018-09-21
本文作者:Alexandra Noonan