在 GitHub Action 優化 Node.js App 的環境建置

npm ci 與 cache 的應用

身為一位維護公司內部建置與發佈工具的工程師,建立方便又有效率的 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 了…。