從25分鐘到7分鐘,我們用了這些方法提升Rails CI的效率
最近,我們?cè)?Gusto 創(chuàng)下了一個(gè)新紀(jì)錄:6 分 29 秒。
這是我們?yōu)?Gusto 最大的一個(gè)應(yīng)用程序,一個(gè) Rails 單體程序運(yùn)行測(cè)試套件所花費(fèi)的時(shí)間。6 分 29 秒是公司的持續(xù)集成(CI)流水線啟用以來的最快紀(jì)錄。上一次 CI 套件跑出這樣的成績,公司的規(guī)模還很小,而現(xiàn)在我們共有全球數(shù)百名工程師在使用這個(gè) Rails 單體應(yīng)用,為全美 1% 的小型企業(yè)提供支持。
對(duì) Gusto 而言,高速 CI 流水線并不只是做做樣子,我們把它視為一種競爭優(yōu)勢(shì),代碼部署越快,那么客戶的業(yè)務(wù)開展也會(huì)越快。隨著 CI 速度的提升,工程師的生產(chǎn)率也在提高,CI 時(shí)間每縮短一分鐘,Gusto 每位工程師每周可增加 2%的拉取請(qǐng)求。
我們的目標(biāo)很簡單,希望讓測(cè)試套件的速度成為一個(gè)參數(shù)的函數(shù),這個(gè)參數(shù)就是:我們?cè)敢饣ǘ嗌馘X?將基礎(chǔ)架構(gòu)簡化到這個(gè)層面后,就更容易做成本效益分析,例如如果想要將構(gòu)建速度從 7 分鐘提升到 5 分鐘,那么需要花費(fèi) 1 美元。
這篇文章介紹了我們是怎樣加快測(cè)試套件速度的,其中涉及一個(gè) Rails 單體程序和一個(gè)主要用 React 編寫的 JavaScript 單頁應(yīng)用程序(SPA),這些經(jīng)驗(yàn)適用于所有速度較慢的測(cè)試套件。
我的同事 Kent 說,構(gòu)建軟件有 3 個(gè)步驟:
- 讓它跑起來(Make it work)
- 讓它走上正軌(Make it right)
- 讓它跑得更快(Make it fast)
“讓它跑起來”指的是做出不會(huì)隨便崩潰的軟件。在這一步代碼可能晦澀難懂,但足以為客戶提供價(jià)值,并且通過了測(cè)試,讓我們能信任它。沒有測(cè)試,就很難判斷“它能行嗎?”
“讓它走上正軌”指的是要讓代碼可維護(hù),且易于更改。代碼不僅能在計(jì)算機(jī)上運(yùn)行,更要讓人容易理解。新來的工程師可以輕松向代碼添加功能,代碼中的缺陷也應(yīng)該很容易隔離和糾正。
“讓它跑得更快”指的是要提升軟件性能。為什么它會(huì)是最后一步呢?對(duì)于像 Gusto 這樣的金融科技公司來說,如果只關(guān)注速度卻無視質(zhì)量,那么我們的客戶和我們自己就離破產(chǎn)不遠(yuǎn)了。并非每段代碼都需要優(yōu)異的性能,如果一段代碼每天可能只執(zhí)行一次,那么就算它有”高性能”水平,卻難以閱讀和理解,那也是一段失敗的代碼。
我們把這套原則應(yīng)用在 CI 套件的提速優(yōu)化過程中。
1. 讓它跑起來
消除不可靠測(cè)試
首先需要做的事情是消除測(cè)試套件中的不可靠測(cè)試(test flakes)。不可靠測(cè)試(flaky test)指的是結(jié)果不確定的測(cè)試,它有時(shí)會(huì)通過,有時(shí)會(huì)失敗。速度飛快但不可靠的測(cè)試套件并不能讓你確信代碼可以正常運(yùn)行,這只是在拋硬幣賭運(yùn)氣而已。
為了讓一個(gè)規(guī)模龐大的工程團(tuán)隊(duì)消除不可靠測(cè)試,我們采用并執(zhí)行了以下政策:
在 master 分支上所有失敗的測(cè)試都將視為不可靠的。這些測(cè)試將標(biāo)記為已跳過(skipped)。負(fù)責(zé)不可靠測(cè)試的團(tuán)隊(duì)可以在空閑時(shí)修復(fù)它們并取消跳過標(biāo)記。
這個(gè)做法不僅能讓測(cè)試套件一直亮綠燈,同時(shí)也讓各個(gè)團(tuán)隊(duì)決定何時(shí)編寫更多確定性測(cè)試。他們可以立刻開始編寫,也可以選擇等到再次處理這個(gè)功能時(shí)再行動(dòng)。這種方法減少了一個(gè)團(tuán)隊(duì)的不確定測(cè)試給其他團(tuán)隊(duì)帶來的損害。
當(dāng)然,這種方法也存在質(zhì)疑,“如果我們跳過了一項(xiàng)重要的測(cè)試該怎么辦?”是最常見的問題。沒錯(cuò),這個(gè)問題很重要,但我們需要搞清楚問題的背景。一個(gè)測(cè)試之所以會(huì)被標(biāo)記為已跳過,是因?yàn)樗鼤?huì)隨機(jī)失敗,首先要考慮的是我們對(duì)這個(gè)測(cè)試和功能到底有多大的信心。很多時(shí)候,測(cè)試會(huì)出現(xiàn)不可靠情況是因?yàn)樯a(chǎn)環(huán)境中的確存在錯(cuò)誤!
通過這種方式,我們?cè)谥鞣种系臉?gòu)建綠燈率從約 75%增至 98%!
2. 讓它走上正軌
回到默認(rèn)狀態(tài)
隨著時(shí)間的流逝,我們逐漸偏離了運(yùn)行 RSpec 測(cè)試的默認(rèn)路徑。遵守默認(rèn)值是很難的。下面是 RSpec 測(cè)試的一些默認(rèn)值:
在各個(gè)測(cè)試用例之間重置狀態(tài)。這樣可以確保測(cè)試是可重復(fù)的、確定性的,并且不會(huì)相互依賴。
測(cè)試執(zhí)行是隨機(jī)的。這樣可以確保測(cè)試之間不存在相互依賴,幫助避免測(cè)試污染。
測(cè)試文件使用 Rails 自動(dòng)加載器。這意味著我們僅加載應(yīng)用程序所需的部分,而不是程序整體,可以幫助避免不完整的測(cè)試設(shè)置。
重新采用這些默認(rèn)值的過程并不輕松。確保每個(gè)測(cè)試用例都重置其狀態(tài)(數(shù)據(jù)庫、Redis 值、緩存等),都會(huì)帶來新的不可靠測(cè)試。根據(jù)其性質(zhì),我們可以修復(fù)更改或?qū)⒅罢5臏y(cè)試標(biāo)記為不可靠。
我們慢慢重新引入了 RSpec 默認(rèn)值,這為測(cè)試提速奠定了基礎(chǔ)。
3. 讓它跑得更快
引入測(cè)試時(shí)間上限
我們的測(cè)試是不平衡的。有些測(cè)試文件只需幾毫秒就能執(zhí)行完畢,還有些則需要花費(fèi)數(shù)十分鐘時(shí)間。耗時(shí)幾分鐘的測(cè)試是集成測(cè)試,涉及我們應(yīng)用程序中最重要的一些流程。我們希望這些測(cè)試的速度能更快,但并不想移除它們。
因?yàn)闇y(cè)試套件是分布在多個(gè)節(jié)點(diǎn)上并行執(zhí)行的,所以很快就遇到了測(cè)試提速的瓶頸。
我們的測(cè)試套件速度取決于最慢的測(cè)試文件,因此實(shí)施了一項(xiàng)新政策:
任何測(cè)試文件的執(zhí)行時(shí)間都不能超過 2 分鐘。
這個(gè)門檻是憑空拉出來的,但似乎很實(shí)用。我們只有 40 多個(gè)耗時(shí)超過 2 分鐘的文件。
確定界限之后,我們開始處理速度緩慢的測(cè)試,試圖讓它們通過新的門檻,之前 40 個(gè)文件的時(shí)間都降到了閾值以下。之后,每個(gè)團(tuán)隊(duì)都有責(zé)任確保其測(cè)試文件的執(zhí)行時(shí)間不超過 2 分鐘,而執(zhí)行時(shí)間超過 2 分鐘的測(cè)試文件會(huì)被標(biāo)記為已跳過。
根據(jù)最壞情況來平衡測(cè)試
現(xiàn)在我們有了一個(gè)可靠的測(cè)試套件,只是速度很慢,它可以按任何順序執(zhí)行測(cè)試,但是將測(cè)試分配給節(jié)點(diǎn)的方法是隨機(jī)的。有些節(jié)點(diǎn)只需幾秒鐘就完成了,而另一些節(jié)點(diǎn)則需要數(shù)十分鐘。我們?cè)鯓硬拍茏屗鼈兤胶饽兀?/p>
我們面臨的最后一個(gè)問題是測(cè)試平衡。我們?cè)谶@一步評(píng)估了兩種解決方案:
- 開發(fā)一個(gè)隊(duì)列,以在節(jié)點(diǎn)準(zhǔn)備就緒后為其輸入測(cè)試用例。雖然這種方案原理上沒問題,但 RSpec 需要對(duì)框架做大幅更新才能兼容這種方案。此外,它在所有各不相同的并行作業(yè)之間引入了共享狀態(tài)。
- 在一次 CI 流程開始時(shí)在一個(gè)數(shù)據(jù)庫中記錄測(cè)試時(shí)間,將測(cè)試分為不同的桶,讓所有分組都有相同的長度。
我們采用了記錄與分桶的方法將測(cè)試分配到各個(gè)節(jié)點(diǎn)上,因?yàn)樗浅_m合 knapsack(https://docs.knapsackpro.com/ruby/knapsack)。測(cè)試運(yùn)行期間,這種方法也不會(huì)在許多不同的并行作業(yè)之間共享狀態(tài)。這是很重要的,因?yàn)橐粋€(gè)共享隊(duì)列可能有數(shù)百個(gè)節(jié)點(diǎn),每個(gè)節(jié)點(diǎn)每秒為一個(gè)構(gòu)建可以請(qǐng)求數(shù)千次工作。
我們建立了一個(gè) MySQL 實(shí)例來記錄所有文件的測(cè)試時(shí)間。在每次 CI 流程開始時(shí),它會(huì)根據(jù)每個(gè)測(cè)試文件的第 99 個(gè)百分位時(shí)間生成一個(gè) knapsack 文件。在每次 CI 流程結(jié)束時(shí),它將上傳新的結(jié)果。
為什么是第 99 個(gè)百分位?由于我們?cè)诠蚕碛布ˋWS)上運(yùn)行 CI,因此無法控制基礎(chǔ)架構(gòu),各個(gè)測(cè)試文件的測(cè)試時(shí)間會(huì)大相徑庭。我們無法將這些波動(dòng)與使用的 EC2 實(shí)例類型,或者其他任何可以衡量的參數(shù)關(guān)聯(lián)起來。
我們沒有進(jìn)一步完善構(gòu)建基礎(chǔ)架構(gòu),而是讓系統(tǒng)具備了彈性。我們使用第 99 個(gè)百分位來組織測(cè)試,從而保證了測(cè)試的性能表現(xiàn)有一個(gè)下限,而不是在獲得較好的平均性能時(shí)卻存在明顯偏低的個(gè)例。即便底層硬件發(fā)生變化或基礎(chǔ)架構(gòu)層出現(xiàn)故障,CI 管道依舊能保障可預(yù)期的性能水平。
這套策略實(shí)施之后,我們就有了一個(gè)自平衡的系統(tǒng)。測(cè)試越多,系統(tǒng)也就越平衡。如果某些測(cè)試隨著時(shí)間的推移變慢,則測(cè)試桶也會(huì)隨之調(diào)整平衡狀態(tài)。
提升并行度
現(xiàn)在到了有意思的地方:讓測(cè)試速度真的變快。
這里的主要做法是增加并行度。項(xiàng)目開始以來,我們已經(jīng)從 40 個(gè)并行作業(yè)增加到了 130 個(gè)。這稍稍增加了成本,但大幅提升了 CI 的運(yùn)行速度。在 Gusto,我們使用 Buildkite 作為 CI 基礎(chǔ)架構(gòu),但這種并行化的理念適用于所有主流 CI 產(chǎn)品。
雖然我們將并行度提高到了 3 倍以上,但 CI 費(fèi)用卻沒有隨之線性增長。為什么?因?yàn)槲覀兏玫乩昧艘延械?CPU 時(shí)間,通過在各個(gè)節(jié)點(diǎn)之間平衡作業(yè),總 CPU 時(shí)間并沒有變化,但是實(shí)際運(yùn)行時(shí)間大幅縮短了。
4. 總結(jié)
在過去幾個(gè)月中,我們?cè)谝稽c(diǎn)點(diǎn)讓 Gusto 主要應(yīng)用程序的 CI 管道變得更堅(jiān)實(shí)可靠,而且速度更快。
這種改進(jìn)依舊是一項(xiàng)日常工作。在出現(xiàn)不可靠測(cè)試時(shí)我們還是會(huì)跳過它們,或者尋找新的優(yōu)化策略來加快構(gòu)建速度。無論你們現(xiàn)在使用的是什么技術(shù),我們都希望這篇文章可以為你們的團(tuán)隊(duì)提供一個(gè)路線圖參考,幫助改進(jìn)你們的 CI 管道和軟件發(fā)布架構(gòu)。