大型前端專案 DevOps 沉思錄 —— CI 篇

CODING發表於2021-12-03
本文作者:成龍
騰訊前端開發工程師,負責騰訊文件前端開發與研發效能提升,AlloyTeam成員。

導語

本篇文章將著重探討 DevOps 在持續整合階段需要提供的能力,將對工作流的設計及流水線的優化思路做一個簡要講解。

DevOps 一詞源於 Development 和 Operations 的組合,即將軟體交付過程中開發與測試運維的環節通過工具鏈打通,並通過自動化的測試與監控,減少團隊的時間損耗,更加高效穩定地交付製品。

隨著騰訊文件的專案規模越來越大,功能特性與維護人員越來越多,特性交付頻率與軟體質量之間的矛盾日漸尖銳,如何平衡兩者成為了目前團隊亟需關注的一個重點,於是,落地一個完善的 DevOps 工具鏈便被提上日程。

當我們在談論 CI 時,我們在談論什麼

CI(Continuous Integration),即持續整合,指頻繁地(一天多次)將程式碼整合到主幹的行為。

注意,這裡既包含持續將程式碼整合到主幹的含義,也包含持續將原始碼生成可供實際使用的製品的過程。因此,我們需要通過 CI,自動化地保證程式碼的質量,並對其構建產物轉換生成可用製品供下一階段呼叫。

因此,在 CI 階段,我們至少有如下階段需要實現:

1. 靜態程式碼檢查

這其中包括,ESLINT/TSLINT 靜態語法檢查,驗證 git commit message 是否符合規範,提交檔案是否有對應 owner 可以 review 等等。這些靜態檢查不需要編譯過程,直接掃描原始碼就可以完成。

2. 單元測試/整合測試/E2E 測試

自動化測試這一環節是保障製品質量的關鍵。測試用例的覆蓋率及用例質量直接決定了構建產物的質量,因此,全面且完善的測試用例也是實現持續交付的必備要素。

3. 編譯並整理產物

在中小型專案中,這一步通常會被直接省略,直接將構建產物交由部署環節實現。但對於大型專案來說,多次頻繁的提交構建會產生數量龐大的構建產物,需要得到妥善的管理。產物到製品的建立我們接下來會有詳細講解。

利於整合的工作流設計

在正式接入 CI 前,我們需要規劃好一種新的工作流,以適應專案切換為高頻整合後可能帶來的問題與難點。這裡涉及到的改造層面非常多,除了敦促開發人員習慣的轉變以及進行新流程的培訓外,我們主要關心的是原始碼倉庫的更新觸發持續整合步驟的方式。

1. 流水線的組織形式

我們需要一個合適的組織形式來管理一條 CI 流水線該在什麼階段執行什麼任務。

市面上有非常多的 CI 工具可以進行選擇,仔細觀察就會發現,無論是 Drone 這樣的新興輕量的工具,亦或是老牌的 Jenkins 等,都原生或通過外掛方式支援了這樣一個特性:Configuration as Code,即使用配置檔案管理流水線。

這樣做的好處是相當大的。首先,它不再需要一個 web 頁面專門用於流水線管理,這對於平臺方來說無疑減少了維護成本。其次對於使用方來說,將流水線配置整合在原始碼倉庫中,享受與原始碼同步升級的方式,使得 CI 流程也能使用 git 的版本管理進行規範與審計溯源。

確立了流水線的組織形式後,我們還需要考慮版本的釋出模式以及原始碼倉庫的分支策略,這直接決定了我們該以什麼樣的方式規劃流水線進行程式碼整合。

2. 版本釋出模式的取捨

在《持續交付 2.0》一書中提到,版本釋出模式有三要素:交付時間、特性數量以及交付質量

img

這三者是相互制衡的。在開發人力與資源相對固定的情況下,我們只能對其中的兩個要素進行保證。

傳統的專案制釋出模式是犧牲了交付時間,等待所有特性全部開發完成並經歷完整人工測試後才釋出一次新版本。但這樣會使得交付週期變長,並且由於特性數量較多,在開發過程中的不可控風險變高,可能會導致版本無法按時交付。不符合一個成熟的大型專案對於持續交付的要求。

對於持續整合的思想來說,當我們的整合頻率足夠高,自動化測試足夠成熟且穩定時,完全可以不用一股腦地將特性全堆在一次釋出中。每開發完成一個特性就自動進行測試,完成後合入等待發布。接下來只需要在特定的時間週期節點自動將已經穩定的等待中的特性發布出去即可。這對於釋出頻率越來越高,釋出週期越來越短的現代大型專案中無疑是一個最優解

3. 分支策略

與大部分團隊一樣,我們原有的開發模式也是分支開發,主幹釋出的思想,分支策略採用業界最成熟也是最完善的 Git-Flow 模式。

img

可以看出,該模式在特性開發,bug 修復,版本釋出,甚至是 hotfix 方面都已經考慮到位了,是一個能應用在生產環境中的工作流。但整體的結構也因此變得極為複雜,不便管理。例如進行一次 hotfix 的操作流程是:從最新發布前使用的主幹分支拉出 hotfix 分支,修復後合入到 develop 分支中,等待下一次版本釋出時拉出到 release 分支中,釋出完成後才能合回主幹。

此外,對於 Git-Flow 的每一個特性分支來說,並沒有一個嚴格的合入時間,因此對於較大需求來說可能合入時間間隔會很長,這樣在合入主幹時可能會有大量的衝突需要解決,導致專案工期無端延長。對此,做大型改造與重構的同學應該深有體會。

針對這一點,我們決定大膽採用主幹開發,主幹釋出的分支策略

我們要求,開發團隊的成員儘量每天都將自己分支的程式碼提交到主幹。在到達釋出條件時,從主幹直接拉出釋出分支用於釋出。若發現缺陷,直接在主幹上修復,並根據需要 cherry pick 到對應版本的釋出分支。

img

這樣一來,對於開發人員來說需要關注的分支就只有主幹和自己 working 的分支兩條,只需要 push 與 merge 兩條 git 命令就能完成所有分支操作。同時,由於合入頻率的提高,平均每人需要解決的衝突量大大減少,這無疑解決了很多開發人員的痛點。

需要說明的是,分支策略與版本釋出模式沒有銀彈。我們採用的策略可能並不適合所有團隊的專案。提高合入頻率儘快能讓產品快速迭代,但無疑會讓新開發的特性很難得到充分的手工測試及驗證。

為了解決這一矛盾點,這背後需要有強大的基礎設施及長期的習慣培養做支援。這裡將難點分為如下幾個型別,大家可以針對這些難點做一些考量,來確定是否有必要採用主幹開發的方式。

1)完善且快速的自動化測試。只有在單元測試、整合測試、E2E 測試覆蓋率極高,且通過變異測試得出的測試用例質量較高的情況下,才能對專案質量有一個整體的保證。但這需要團隊內所有開發人員習慣 TDD(測試驅動開發)的開發方式,這是一個相當漫長的工程文化培養過程。

2)Owner 責任制的 Code Review 機制。讓開發人員具有 Owner 意識,對自己負責的模組進行逐行審查,可以在程式碼修改時規避許多設計架構上的破壞性修改與坑點。本質上難點其實還是開發人員的習慣培養。

3)大量的基礎設施投入。高頻的自動化測試其實是一個相當消耗資源的操作,尤其是 E2E 測試,每一個測試用例都需要啟動一個無頭瀏覽器來支撐。另外,為了提升測試的效率,需要多核的機器來並行執行。這裡的每一項都是較大的資源投入。

4)快速穩定的回滾能力和精準的線上及灰度監控等等。只有在高度自動化的全鏈路監控下,才能保證該機制下發布的新版本能夠穩定執行。這裡的建設我會在之後的文章裡詳細介紹。

大型專案中產物->製品的建立

對於大多數專案來說,在程式碼編譯完成生成產物後,部署專案的方式就是登入釋出伺服器,將每一次生成的產物貼上進發布伺服器中。生成的靜態檔案由於 hash 不同可以同時存放,html 採用直接覆蓋的方式進行更新。

直接使用複製貼上的方式來操作檔案的更新與覆蓋,這樣既不方便對更新歷史的審計與追溯,同時這樣的更改也很難保證正確性。

除此之外,當我們需要回滾版本時,由於伺服器上並沒有存放歷史版本的 html,因此回滾的方式其實是重新編譯打包生成歷史版本的產物進行覆蓋。這樣的回滾速度顯然不是令人滿意的。

一個解決方法是,不要對檔案進行任何的覆蓋更新,所有的產物都應該被上傳持久化儲存。我們可以在請求上游增設一個流量分發服務,來判斷每一條請求應該返回哪一個版本的 html 檔案。

對於大型專案來說,返回的 html 檔案也不一定不是一成不變的。它可能會被注入渠道、使用者自定義等標識,以及 SSR 所需要的首屏資料,從而改變其程式碼形式。因此,我們認為 html 檔案的製品提供方應該是一個單獨的動態服務,通過一些邏輯完成對模板 html 的替換並最終輸出。

總結一下,在每次編譯完成後,產物將會進行如下的整理以生成最終的前端製品:

  1. 針對靜態檔案,如 CSS、JS 等資源將會發布到雲物件儲存中,並以此為源站同步給 CDN 做訪問速度優化。
  1. 針對 HTML 製品,需要一個直出服務做支撐,並打包成 docker 映象,與後端的微服務映象同等級別,供上游的流量分發服務(閘道器)根據使用者請求選擇調起哪些服務負載進行消費。

速度即效率,流水線優化思路

對於一個好的工具來說,內部設計可以很複雜,但對於使用者來說必須足夠簡單且好用。

在主幹開發這樣高頻的持續整合下,整合速度即效率,流水線的執行時間毫無疑問是開發人員最關心的,也是流水線是否好用的決定性指標。我們可以從幾個方面著手,提高流水線執行效率,減少開發人員的等待時間。

1. 流水線任務編排

對流水線各個階段需要執行的任務我們需要遵循一定的編排原則:無前置的任務優先,執行時間短的任務優先,無關聯的任務並行。

根據這一原則,我們可以通過分析流水線中執行的各個任務,對每一個任務做一次最短路徑依賴分析,最終得出該任務的最早執行時機。

2. 巧用 Docker Cache

Docker 提供了這樣一個特性:在 Docker 映象的構建過程中,Dockerfile 的每一條可執行語句都會構建出一個新的映象層,並快取起來。在第二次構建時,Docker 會以映象層為單位逐條檢查自身的快取,若命中相同映象層,則直接複用該條快取,使得多次重複構建的時間大大縮短。

我們可以利用 Docker 的這一特性,在流水線中減少通常會重複執行的步驟,從而提高 CI 的執行效率。

例如前端專案中通常最耗時的依賴安裝 npm install,變更依賴項對於高頻整合來說其實是一個較小概率的事件,因此我們可以在第一次構建時,將 node_modules 這個資料夾打包成為映象供下次編譯時呼叫。Dockerfile 示例編寫如下:

FROM node:12 AS dependenciesWORKDIR /ciCOPY . .RUN npm installENV NODE_PATH=/ci/node_modules

我們給流水線增加一條檢查快取命中的策略:在下次編譯之前,先查詢是否有該映象快取存在。並且,為了保證本次構建的依賴沒有更新,我們還必須比對本次構建與映象快取中的 package-lock.json 檔案的 md5 碼是否一致。若不一致,則重新安裝依賴並打包新映象進行快取。若比對結果一致,則從該映象中直接取到 node_modules 資料夾,從而省去大量依賴安裝的時間。

流水線拉取映象資料夾的方法示例如下,其中 --from 後跟的是之前快取構建映象的別名:

COPY --from=dependencies node_modules/ .# 其他步驟執行

同理,我們也可以將這一特性擴充套件到 CI 過程中所有更新頻率不高,生成時間較長的任務中。例如 Linux 中環境依賴的安裝、單元測試每條用例執行前的快取、甚至是靜態檔案數量極多的資料夾的複製等等,都能利用 Docker cache 的特性達到幾乎跳過步驟,減少整合時間的效果。由於原理大致相同,在此就不贅述了。

3. 分級構建

眾所周知,流水線的執行時間一定會隨著任務數量的增多而變慢。大型專案中,隨著各項指標計算的接入,各項測試用例的數量逐漸增多,執行時間遲早會達到我們難以忍受的地步。

但是,測試用例的數量一定程度上決定著我們專案的質量,質量檢查決不能少。那麼有沒有一種方法既可以讓專案質量得到持續保障的同時,減少開發者等待整合的時間呢?答案就是分級構建。

所謂分級構建,就是將 CI 流水線拆分為主構建和次級構建兩類,其中主構建需要在每次提交程式碼時都要執行,並且若檢查不通過無法進行下一步操作。而次級構建不會阻塞工作流,通過旁路的方式在程式碼合入後繼續執行。但是,一旦次級構建驗證失敗,流水線將會立即發出通知告警,並阻塞其他所有程式碼的合入,直到該問題被修復為止。

對於某任務是否應放入次級構建過程,有如下幾點原則:

1)次級構建將包含執行時間長(如超過 15 分鐘)、耗費資源多的任務,如自動化測試中的 E2E 測試。

2)次級構建應當包含用例優先順序低或者出錯可能性低的任務,儘量不要包含重要鏈路。如果自動化測試中的一些測試用例經過實踐發現失敗次數較高,應當考慮增加相關功能單元測試,並移入主構建過程。

3)若次級構建仍然過長,可以考慮用合適的方法分割測試用例,並行測試。

總結

我們認為,從程式碼整合、功能測試,到部署釋出、基礎設施架構管理,每一個環節都應該有全面且完善的自動化監控手段,並儘量避免人工介入。只有這樣,軟體才能同時兼顧質量與效率,在提高發布頻率的情況下保證可靠性。這是每一個成功的大型專案最終一定要實現的目標。

參考資料

  1. 《持續交付 2.0》—— 喬樑 著
  2. https://www.redhat.com/zh/top...
  3. https://www.36kr.com/p/121837...

立即開啟高效雲端研發工作流

相關文章