O hirunewani blog

Github ActionsにおけるNode.jsパッケージのキャッシュについて

Created at

Github ActionsにおけるNode.jsパッケージのキャッシュについて、actions/setup-nodeの推奨する方法やnode_modulesをキャッシュする方法、その周辺について紹介する。

actions/setup-nodeが紹介するキャッシュの設定

actions/setup-node はv2以降、cacheに利用しているパッケージマネージャー名を指定すれば、グローバルパッケージデータをキャッシュすることが出来る。

# npmの場合
- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: "npm"
- run: npm ci
# yarnの場合
- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: "yarn"
- run: yarn install --frozen-lockfile
# pnpmの場合
- uses: pnpm/action-setup@v4
   name: Install pnpm
   with:
     version: 9
     run_install: false
- name: Install Node.js
   uses: actions/setup-node@v4
   with:
      node-version: 20
      cache: 'pnpm'
- name: Install dependencies
   run: pnpm install

しかし、意図的にこの設定を無視または理解せずに有効化した上でnode_modulesディレクトリを丸ごとキャッシュしている場合が多くみられる。

actions/setup-nodeのキャッシュ機能の誤解

actions/setup-nodecache機能が入った当初、何をキャッシュするか明示されていなかっため、多くのユーザーがnode_modulesをキャッシュするものと誤解し混乱が生まれていた。

現在はREADMEにCaching global packages dataと記載されているが、global packages dataが何を指しているのかが伝わらず、未だに誤解されている場合がある。

Global package dataとは

各パッケージマネージャーは、基本的にパッケージのインストールがリクエストされた場合、次のようなステップを踏む。

  1. グローバルストアにキャッシュが存在するか確認する。
  2. 存在しない場合、パッケージを特定のディレクトリにダウンロードする。
  3. 存在する又はダウンロードが完了したら、そのディレクトリからnode_modulesにコピーする。
    • pnpmの場合は、コピーではなくハードリンクを張る。

ここで言う特定のディレクトリが、グローバルパッケージデータの指すものである。Yarnにおいてパッケージキャッシュ、pnpmではパッケージストアと呼称されている。

それぞれのパッケージマネージャーにおけるグローバルパッケージデータのディレクトリは次の通りになっている。

ディレクトリ名パスの取得方法
npm.npmnpm config get cache
yarn.cache/yarnyarn cache dir
yarn (v2~).yarn/cacheyarn config get cacheFolder
pnpmpnpm/storepnpm store path —silent

グローバルパッケージデータをキャッシュする戦略

グローバルパッケージデータをキャッシュすることで、パッケージが存在していればダウンロードステップを省略することが出来る。

actions/setup-nodecache機能は、これを行っている。

actions/setup-nodecache機能を利用せずに、グローバルパッケージデータをキャッシュする場合、次のように記述すれば良い。

- uses: actions/setup-node@v4
   with:
    node-version: '20'

- name: Get Yarn cache directory path
  id: yarn-cache-dir
  run: echo "::set-output name=dir::$(yarn cache dir)"

- uses: actions/cache@v3
  id: yarn-cache
  with:
    path: ${{ steps.yarn-cache-dir.outputs.dir }}
    key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
    restore-keys: |
        ${{ runner.os }}-yarn-
- run: yarn install --frozen-lockfile

Global package dataをキャッシュする戦略が遅い場合

グローバルパッケージデータをキャッシュする戦略では、キャッシュされているパッケージが古くなっていないかの確認にかかる時間が支配的になる。 どのパッケージマネージャーでも--prefer-offlineフラグを利用することで、この確認ステップをバイパスすることが出来るので検討してみると良い。 ただしnpmではバグが報告されているため注意。

actions/setup-nodeはなぜnode_modulesをキャッシュしないのか

前述の通り、actions/setup-nodeはグローバルパッケージデータをキャッシュし、node_modulesをキャッシュしない。

これは次のような理由だと考えられる。

  • グローバルパッケージデータをキャッシュする場合、異なるNode.jsバージョン間でキャッシュを再利用できる。
    • 複数のnodeバージョンでテストが必要な場合などに有効。
  • node_modulesのキャッシュではパッケージの確認が行われないため、キャッシュの制御が不十分である場合、不整合を起こす可能性がある。
  • グローバルストアのキャッシュで十分な効果を得られる場合がある。
    • 特にpnpmやyarn v2以降では、ハードリンクを張るだけであるため非常に有効である。
    • npmの場合、あまり改善されない印象がある。

node_modulesをキャッシュする戦略

node_modulesをキャッシュする選択では、キャッシュがある場合、ダウンロードステップと確認ステップに加えてコピーステップもスキップできるため、大幅に実行時間が短縮できる。

次のように記述する。

- uses: actions/setup-node@v4
  id: node
  with:
    node-version: 20
- uses: actions/cache@v4
  id: cache
  with:
    key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.node.outputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }}
    path: |
      node_modules
- run: yarn install --frozen-lockfile
  if: steps.cache.outputs.cache-hit != 'true'

多くの人にとってグローバルパッケージデータを扱う方法よりも分かりやすいと思うが、次の点に注意した上で利用をした方がいい。

  • キャッシュのkeyが曖昧な場合、不整合を起こす可能性がある。
  • Github Actionsのキャッシュストレージを圧迫し、キャッシュミスを発生する可能性が高まる。
    • keyが過度に厳密であったり、複数の環境でCIを走らせる必要がある場合、キャッシュが肥大化する。
    • ブランチ毎にキャッシュは管理されるため、ブランチを大量に生やしてもリスクが高まる。

どちらを使えばいいのか

次のように考えているが、実際に利用する場合は自身で計測し利用してほしい。

グローバルパッケージデータが適しているケース

  • pnpmなどハードリンクを張るパッケージマネージャーを利用している。
  • 複数のNode.jsバージョンでテストを走らせる必要がある。

node_modulesがいいかもしれないケース

  • npmやYarn v1などコピーを行うパッケージマネージャーを利用している。

node_modulesのキャッシュを取る戦略を採用する場合、十分に注意した上での利用が必要なため、複数のリポジトリに導入するのであればComposite Actionsなどで共通化して利用することをおすすめする。