身為一位維護公司內部建置與發佈工具的工程師,建立方便又有效率的 CI/CD 工具來建置與測試產品,並且為產品的穩定性把關,一直是我們團隊的重要目標。不過我們對待自己的內部工具常常比產品來得隨便…所以在我和主管聊過之後,決定慢慢幫我們的內部工具也來建立一些自動化的測試以及部署。
內部工具說穿了就是個 Node.js 的應用程式。身為 YAML 工程師的我當然是用自己最熟悉的 GitHub Action 來實作。
從最簡單的版本開始
下面是一個最簡單的 Node.js 專案所使用的單元測試的 GitHub Action 的 YAML 設定:
name: Unit test
on: push
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.13.1
- run: npm install
- run: npm run test
這份 YAML 簡單又白話。使用 actions/checkout
拿到最新版的 Code,並使用 actions/setup-node
設置 Node.js 環境。這兩個都是 GitHub 官方的 actions,簡單方便。然後使用 npm install
指令安裝套件,接著跑我們自定義的測試腳本 npm run test
(註:記得換成你自己的測試指令)。
其實如果不要要求太多,這樣就已經完成了,因此這次的分享就到這裡告一段落。
還能做得更好嗎?
…才怪,如果只是這樣,才不值得寫一篇文章 XD
如果你的套件很多,每次都要跑 npm install
會變成一個拖累你的 CI 速度的惡夢,測試如果不是簡單又快速,就會降低開發人員對於測試的關注,這絕對不是一件好事。
優化環境建置速度
Actions/setup-node 的內建優化
其實 actions/setup-node
的文件就有寫到,他們有內建的 cache (快取)機制,可以透過將 package-lock.json
或是 yarn.lock
內的 dependency 做快取:
- uses: actions/setup-node@v2
with:
node-version: 16.13.1
cache: 'npm'
- run: npm install
就是這麼簡單,更詳細用法可以查看 actions/setup-node
的文件(https://github.com/actions/setup-node)。
值得注意的是,這邊的 cache 對象是 .npm
的資料夾,而不是 node_modules
,所以即便有 cache 了,在跑 npm install
時還是有可能會需要下載與安裝一些 package。
使用 NPM CI
我也是在這次建置中才注意到這個酷功能的:
https://docs.npmjs.com/cli/v8/commands/npm-ci
npm ci
顧名思義,是給 CI 用的 npm install
,專注於安裝套件的效率,跑起來比 node_modules
快非常多。加速的方法是透過避免掉一些平常在 npm install
時會處理的套件相依問題,略過需要傳遞給開發者的訊息。npm ci
唯一需要做的事,就是根據 package-lock.json
安裝套件。
我自己實測,可以把我們的專案的安裝時間從 3 分又幾秒降低到 45 秒附近,足足提升了 4 倍,真的誇張。
- uses: actions/setup-node@v2
with:
node-version: 16.13.1
cache: 'npm'
- run: npm ci
其實做到這邊就可以停了,但不禁還是想問,還能再更快嗎?
總覺得每次跑 npm ci
還要重新安裝一遍套件,重新建立 node_modules
有點花時間。
邪門加速法:Cache node_modules
大哉問:為何不直接 Cache node_modules 就好呢?
可以參考前人的討論:
我的理解是,當需要使用不同 Node.js / npm / OS 版本時,Cache node_modules 因為包含了一些針對特定環境編譯過的 packages,所以會在環境稍微不同時就造成問題。對 .npm
做快取並不會讓 CI 速度變慢太多,但卻穩定很多,因此像是 GitHub Action / Travis 或 Azure 會預設對 .npm
做快取,減少不懂的人遇到問題的機會 XD
換個想法,如果我盡量使環境一致,把 Node.js 和 OS 版本都納入 cache key,環境有變動就讓 cache miss,這樣在大部分情況應該就能避免上述的問題,並且更加速 CI 的環境建置。
NPM CI 的陷阱
但還有個問題: npm ci
永遠都會先把 node_modules 刪掉再安裝一遍 packages(我想也是為了避免檢查目前已有的套件的正確性而做的優化)
因此 npm ci
不能和 cache node_modules 一起使用嗎?那就大錯特錯了 XD
如果我們把 node_modules 快取起來,並且確認 package-lock.json
和 node version 等因素都沒有改變,那就不必重新跑一遍 npm ci
了啊,直接跑測試就行了。
可以利用查看 node_modules 是否存在來判斷 cache hit 或 miss:
test -d node_modules # 查看資料夾是否存在,把結果存入 $?
echo $? # 0 代表存在,1 代表不存在
加入 cache node_modules 並使用 npm ci 後:
因為變更有點多,這邊再次附上完整 YAML:
name: Unit test
on: push
jobs:
unit-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.13.1] # 把 node.js 版本記錄在這,之後可以重新取出作為 cache key
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node Modules
uses: actions/cache@v2
with:
path: node_modules
# cache key 包含了所有我們希望固定的資訊:OS,node 版本,以及 package-lock.json 的檔案內容,使用 hashFiles 將檔案內容轉換成字串
key: node-modules-${{ runner.os }}-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
- name: Install Packages
run: test -d node_modules && echo "node_modules exists" || npm ci
- run: npm run test
透過 cache node_modules 後,建置時間大幅縮短,原本需要跑 3 分鐘才能建置完成的 CI,現在只需要 10 秒就可以跑完前置的環境建置部分了。即便是 cache miss,也能在 45 秒附近完成套件的安裝,可說是十分有效的優化,成就感十足。
接下來要做的事情就是慢慢補上 unit test 了…。