本文翻譯自Alexandra Noonan 的 Goodbye Microservices: From 100s of problem children to 1 superstar。
本文的內容是描述 Segment 的架構如何從 「單體應用」 -> 「微服務」 -> 「140+ 微服務」 -> 「單體應用」 的一個歷程。
翻譯比較粗糙,如有疏漏,請不吝指教。
- 注:下文說的
目的地
就是對應的不同的資料平臺(例如Google Analytics, Optimizely)
除非你生活在石器時代,不然你一定知道「微服務」是當世最流行的架構。我們Segment早在2015年就開始實踐這一架構。這讓我們在一些方面上吃了不少甜頭,但很快我們發現:在其他場景,他時不時讓我們吃了苦頭。
簡而言之,微服務是一個面向服務的軟體架構,每一個服務端的程式都是朝著一個單一目標構建的(每一個服務會佔據較小的面積)。微服務的主要宣傳點在於:模組化優化,減少測試負擔,更好的功能組成,環境獨立,而且開發團隊是自治的(因為每一個服務的內部邏輯是自洽且獨立的)。而另一頭的單體應用:「巨大無比且難以測試,而且服務只能作為一個整理來伸縮(如果你要提高某一個服務的效能,只能把伺服器整體提高)」
2017 早期我們陷入了僵局,複雜的微服務樹讓我們的開發效率驟減,並且每一個開發小組都發現自己每次實現都會陷入巨大的複雜之中,此時,我們的缺陷率也迅速上升。
最終,我們不得不用三個全職工程師來維護每一個微服務系統的正常執行。這次我們意識到改變必須發生了,本文會講述我們如何後退一步,讓團隊需要和產品需求完全一致的方法。
為什麼微服務可行曾經可行?
Segment 的客戶資料基礎設施吸收每秒成百上千個事件,將每一個夥伴服務的API 請求結果一個個返回給對應的服務端的「目的地」。而「目的地」有上百種類別,例如Google Analytics, Optimizely,或者是一些自定義的webhook。
幾年前,當產品初步釋出,當時架構很簡單。僅僅是一個接收事件並且轉發的訊息佇列。在這個情況下,事件是由Web或移動應用程式生成的JSON物件,例子如下:
{
"type": "identify",
"traits": {
"name": "Alex Noonan",
"email": "anoonan@segment.com",
"company": "Segment",
"title": "Software Engineer"
},
"userId": "97980cfea0067"
}
複製程式碼
事件是從佇列中消耗的,客戶的設定會決定這個事件將會傳送到哪個目的地。這個事件被紛紛傳送到每個目的地的API,這很有用,開發人員只需要將他們的事件傳送到一個特定的目的地——也就是Segment 的API,而不是你自己實現幾十個專案整合。
如果一個請求失敗了,有時候我們會稍後重試這個事件。一些失敗的重試是安全的,但有些則不。可重試的錯誤可能會對事件目的地不造成改變,例如:50x錯誤,速率限制,請求超時等。不可重試的錯誤一般是這個請求我們確定永遠都不會被目的地接受的。例如:請求包含無效的認證亦或是缺少必要的欄位。
此時,一個簡單的佇列包含了新的事件請求以及若干個重試請求,彼此之間事件的目的地縱橫交錯,會導致的結果顯而易見:隊頭阻塞。意味著在這個特定的場景下,如果一個目的地變慢了或者掛掉了,重試請求將會充斥這個佇列,從而整個請求佇列會被拖慢。
想象下我們有一個 目的地 X
遇到一個臨時問題導致每一個請求都會超時。這不僅會產生大量尚未到達目的地 X
的請求,而且每一個失敗的事件將會被送往重試的佇列。即便我們的系統會根據負載進行彈性伸縮,但是請求佇列深度突然間的增長會超過我們伸縮的能力,結果就是新的時間推送會延遲。傳送時間到每一個目的地的時間將會增加因為目的地X 有一個短暫的停止服務(因為臨時問題)。客戶依賴於我們的實時性,所以我們無法承受任何程度上的緩慢。
為了解決這個隊頭阻塞問題,我們團隊給每一個目的地都分開實現了一個佇列,這種新架構由一個額外的路由器程式組成,該程式接收入站事件並將事件的副本分發給每個選定的目標。現在如果一個目的地有超時問題,那麼也僅僅是這個佇列會進入阻塞而不會影響整體。這種「微服務風格」的架構分離把目的地彼此分開,當一個目的地老出問題,這種設計就顯得很關鍵了。
個人Repo 的例子
每一個目的地的API 的請求格式都不同,需要自定義的程式碼去轉換事件來匹配格式。一個簡單的例子:還是目的地X
,有一個更新生日的介面,作為請求內容的格式欄位為 dob
,API 會對你要求欄位為 birthday
,那麼轉換程式碼就會如下:
const traits = {}
traits.dob = segmentEvent.birthday
複製程式碼
許多現代的目的地終點都用了Segment 的請求格式,所以轉換會很簡單。但是,這些轉換也可能會十分複雜,取決於目的地API 的結構。
起初,目的地分成幾個拆分的服務的時候,所有的程式碼都會在一個repo 裡。一個巨大的挫折點就是一個測試的失敗常常會導致整個專案測試無法跑通。我們可能會為此付出大量的時間只是為了讓他像之前一樣正常執行通過測試。為了解決這個問題,我們把每一個服務都拆分成一個單獨的repo,所有的目的地的測試錯誤都只會影響自己,這個過渡十分自然。
拆分出來的repo 來隔離開每一個目的地會讓測試的實現變得更容易,這種隔離允許開發團隊快速開發以及維護每一個目的地。
伸縮微服務和Repo 們
隨著時間的偏移,我們加了50多個新的目的地,這意味著有50個新的repo。為了減輕開發和維護這些codebase 的負擔,我們建立一個共享的程式碼庫來做實現一些通用的轉換和功能,例如HTTP 請求的處理,不同目的地之間程式碼實現更具有一致性。
例如:如果我們要一個事件中使用者的名字,event.name()
可以是任何一個目的地裡頭的呼叫。共享的類庫會去嘗試判斷event 裡的 name 或者 Name 屬性,如果沒有,他會去查 first name,那麼就回去查詢first_name 和 FirstName,往下推:last name 也會做這樣的事情。然後吧first name 和last name 組合成full name.
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)
}
}
複製程式碼
共享的程式碼庫讓我們能快速完成新的目的地的實現,他們之間的相似性帶給我們一致性的實現而且維護上也讓我們減少了不少頭疼的地方。
儘管如此,一個新的問題開始發生並蔓延。共享庫程式碼改變後的測試和部署會影響所有的目的地。這開始讓我們需要大量時間精力來維護它。修改或者優化程式碼庫,我們得先測試和部署幾十個服務,這其中會帶來巨大的風險。時間緊迫的時候,工程師只會在某個特定的目的地去更新特定版本的共享庫程式碼。
緊接著,這些共享庫的版本開始在不同的目的碼庫中發生分歧。微服務起初帶給我們的種種好處,在我們給每一個目的地都做了定製實現後開始反轉。最終,所有的微服務都在使用不同版本的共享庫——我們本可以用自動化地釋出最新的修改。但在此時,不僅僅是開發團隊在開發中受阻,我們還在其他方面遇到了微服務的弊端。
這額外的問題就是每一個服務都有一個明確的負載模式。一些服務每天僅處理寥寥幾個請求,但有的服務每秒就要處理上千個請求。對於處理事件較少的目的地,當負載出現意外峰值時,運維必須手動伸縮服務以滿足需求。
當我們實現了自動伸縮的實現,每個服務都具有所需CPU和記憶體資源的明顯混合,這讓我們的自動伸縮配置與其說是科學的,不如說更具有藝術性(其實就是蒙的)。
目的地的數量極速增長,團隊以每個月三個(目的地)的速度增長著,這意味著更多的repo,更多的佇列,更多的服務。我們的微服務架構的運維成本也是線性地增長著。因此,我們決定退後一步,重新考慮整個流程。
深挖微服務以及佇列
這時列表上第一件事就是如何鞏固當前超過140個服務到一個服務中,管理所有服務的帶來的各種成本成了團隊巨大的技術債務。運維工程師幾乎無眠,因為隨時出現的流量峰值必須讓工程師隨時上線處理。
儘管如此,當時把專案變成單一服務的架構是一個巨大的挑戰。要讓每一個目的地擁有一個分離的佇列,每一個 worker程式需要檢查檢查每一佇列是否執行,這種給目的地服務增加一層複雜的實現讓我們感到了不適。這是我們「離心機」的主要靈感來源,「離心機」將替換我們所有的個體佇列,並負責將事件傳送到一個單體服務。
譯者注: 「離心機」其實就是Segment 製作的一個事件分發系統。 相關地址
搬到一個單體Repo
所以我們開始把所有的目的地程式碼合併到了一個repo,這意味著所有的依賴和測試都在一個單一的repo 裡頭了,我們知道我們要面對的,會是一團糟。
120個依賴,我們都提交了一個特定的版本讓每一個目的地都相容。當我們搬完了目的地,我們開始檢查每一個對應的程式碼是否都是用的最新的依賴。我們保證每一個目的地在最新的依賴版本下,都能正確執行。
這些改變中,我們再也不用跟蹤依賴的版本了。所有目的地都使用同一版本,這顯著地減小了codebase 的程式碼複雜度。維護目的地變得快捷而且風險也變小了。
另一方面我們也需要測試能簡單快速地執行起來,之前我們得出的結論之一就是:「不去修改共享庫檔案主要的阻礙就是得把測試都跑一次。」
幸運的是,目的地測試都有著相似的架構。他們都有基礎的單元測試來驗證我們的自定義轉換邏輯是否正確,而且也能驗證HTTP 的返回是否符合我們的期望值。
回想起我們的出新是分離每一個目的地的codebase 到各自的repo 並且分離各自測試的問題。儘管如此,現在看來這個想法是一個虛假的優勢。HTTP 請求的傳送仍然以某種頻率失敗著。因為目的地分離到各自的repo,所以大家也沒有動力去處理這類失敗的請求。這也讓我們走進了某種令人沮喪的惡性迴圈。本應只需幾個小時的小改動常常要花上我們幾天甚至一週的時間。
構建一個彈性測試套件
給目的地傳送的HTTP 請求失敗是我們主要的失敗測試原因,過期憑證等無關的問題不應該使測試失敗。我們從中也發現一些目的地的請求會比其他目的地慢不少。一些目的地的測試得花上5 分鐘才能跑完,我們的測試套件要花上一小時時間才能全部跑完。
為了解決這個問題,我們製作了一個「Traffic Recorder」,「Traffic Recorder」是一個基於yakbak 實現的工具,用於記錄並且儲存一些請求。無論何時一個測試在他第一次跑的時候,對應的請求都會被儲存到一個檔案裡。後來的測試跑的時候,就會複用裡頭的返回結果。同時這個請求結果也會進入repo,以便在測試中也是一致的。這樣一來,我們的測試就不再依賴於網路HTTP請求,為了接下來的單一repo 鋪好了路。
記得第一次整合「Traffic Recorder」後,我們嘗試跑一個整體的測試,完成 140+ 目的地的專案整體測試只需幾毫秒。這在過去,一個目的地的測試就得花上幾分鐘,這快得像魔術一般。
為何單體應用可行
只要每個目的地都被整合到一個repo,那麼他就能作為一個單一的服務執行。所有目的地都在一個服務中,開發團隊的效率顯著提高。我們不因為修改了共享庫而部署140+ 個服務,一個工程師可以一分鐘內重新完成部署。
速度是肉眼可見地被提升了,在我們的微服務架構時期,我們做了32個共享庫的優化。再變成單體之後我們做了46個,過去6個月的優化甚至多過2016年整年。
這個改變也讓我們的運維工程師大為受益,每一個目的地都在一個服務中,我們可以很好進行服務的伸縮。巨大的程式池也能輕鬆地吸收峰值流量,所以我們也不用為小的服務突然出現的流量擔驚受怕了。
壞處
儘管改變成單體應用給我們帶來巨大的好處,儘管如此,以下是壞處:
- 故障隔離很難,所有東西都在一個單體應用執行的時候,如果一個目的地的bug 導致了服務的崩潰,那麼這個目的地會讓所有的其他的目的地一起崩潰(因為是一個服務)。我們有全面的自動化測試,但是測試只能幫你一部分。我們現在在研究一種更加魯棒的方法,來讓一個服務的崩潰不會影響整個單體應用。
- 記憶體快取的效果變低效了。之前一個服務對應一個目的地,我們的低流量目的地只有少量的程式,這意味著他的記憶體快取可以讓很多的資料都在熱快取中。現在快取都分散給了3000+個程式所以快取命中率大大降低。最後,我們也只能在運維優化的前提下接受了這一結果。
- 更新共享庫程式碼的版本可能會讓幾個目的地崩潰。當把專案整合的到一起的時候,我們解決過之前的依賴問題,這意味著每個目的地都能用最新版本的共享庫程式碼。但是接下來的共享庫程式碼更新意味著我們可能還需要修改一些目的地的程式碼。在我們看來這個還是值得的,因為自動化測試環節的優化,我們可以更快的發現新的依賴版本的問題。
結論
我們起初的微服務架構是符合當時的情況的,也解決了當時的效能問題還有目的地之間孤立實現。儘管如此,我們沒有準備好服務激增的改變準備。當需要批量更新時,我們缺乏適當的工具來測試和部署微服務。結果就是,我們的研發效率因此出現了滑坡。
轉向單體結構使我們能夠擺脫運維問題,同時顯著提高開發人員的工作效率。我們並沒有輕易地進行這種轉變,直到確信它能夠發揮作用。
- 我們需要靠譜的測試套件來讓所有東西都放到一個repo。沒有它,我們可能最終還是又把它拆分出去。頻繁的失敗測試在過去損害了我們的生產力,我們不希望再次發生這種情況。
- 我們接受一些單體架構的固有的壞處而且確保我們能最後得到一個好的結果。我們對這個犧牲是感到滿意的。
在單體應用和微服務之間做決定的時候,有些不同的因素是我們考慮的。在我們基礎設施的某些部分,微服務執行得很好。但我們的伺服器端,這種架構也是真實地傷害了生產力和效能的完美示例。但到頭來,我們最終的解決方案是單體應用。