npm ciのキャッシュ方式の検討

結論から言うと、node_modulesをキャッシュしてnpm ciの実行を省略するのが、多くの場合には有効そうです。

はじめに

CIで npm ci を使うとき、実行時間短縮のためにキャッシュの利用を検討することになると思います。このとき、どのようにキャッシュするのが良いのでしょうか?

よく知られているキャッシュ方式として、以下の二通りの方式があります。

  • ~/.npmをキャッシュする方式
  • node_modulesをキャッシュする方式

それぞれの違いについて、詳しく見てみましょう。

~/.npmをキャッシュする方式

npm ci を実行すると、POSIX系のOSではデフォルトで ~/.npm にキャッシュデータが書き込まれます。package-lock.json をキーにこのディレクトリをキャッシュしておくことで、次回以降の npm ci 実行時にこのキャッシュデータを利用しよう、というのがこの方式です。

例えばGitHub Actionsの公式アクションであるactions/setup-nodeでは、この記事の執筆時点ではこの方式が推奨されており、これを補助する機能が実装されています。また、NPM公式ドキュメントのnpm-ciの項目でも、この方式の例が掲載されています。

一方でこの方式の欠点として、キャッシュデータを利用していても、npm ci では色々な計算処理が行われるため、幾らか時間が掛かるという点が挙げられます。そのため、この方式は「意外と時間が掛かる」という感想になることが多いように思います。

node_modulesをキャッシュする方式

npm ci の主な用途は、./node_modules 内に依存パッケージをインストールすることです。そこで、package-lock.json をキーにこのディレクトリをキャッシュしておくことで、次回以降の npm ci を省略しよう、というのがこの方式です。

キャッシュが利用できる場合には npm ci が省略されるので、前述の方式と比べるとより短く済みます。一見すると全部この方式で良さそうですが、一体どんな欠点があるのでしょうか?話が長くなりそうなので先に結論を書いておくと、以下の二つの欠点があります。

  • Node.jsのバージョンをキャッシュキーに含めないと、バージョン変更時に困る
  • postinstall等の兼ね合いで上手くいかない場合がある

例えばactions/cacheのNPM利用者向けの説明箇所では、昔はnode_modulesをキャッシュする例が紹介されていました。しかし以下のIssueとPull Requestで、~/.npmをキャッシュする例に変更されました。

このときの変更理由を見てみると、この方式の欠点が見えてきそうです。

This is generally not recommended: see here, here, here, etc. It also doesn't integrate well with npm's suggested CI workflow -- which is to cache ~/.npm and use npm ci -- because npm ci always removes node_modules if it exists so caching it strictly slows down the build.

説明の補足として、時代背景を考慮する必要があります。当時 npm ci はまだ比較的新しい機能であり、今ほど普及していませんでした。実際、この例でもそれまで npm install が利用されていました。そこで、npm install から npm ci に変更しながらキャッシュ方式も変更しようという、二点の変更が同時に提案された訳ですね。

それで、"generally not recommended" の拠り所として挙げられているリンク先を見てみると、「異なるバージョンのNode.js間で同じnode_modulesを再利用すると問題が起こる場合があるから、良くない」という話が書かれています。この問題は、Node.jsのバージョンをキャッシュキーに含めれば回避できそうです。

また、これについては特に言及を見かけませんでしたが、node_modulesをキャッシュすると上手くいかなくなる場合も稀に起こり得るはずです。

npm ci の主な用途は、./node_modules 内に依存パッケージをインストールすることです。

と前述しましたが、NPMのパッケージには、installpostinstall など、利用者がパッケージをインストールする際に実行されるスクリプトを登録できる機能があります。稀な例だとは思いますが、もしこれが高度に利用されていると、上手くいかなくなる可能性はありそうです。

GitHub Actionsでの設定例

以上の点を踏まえ、ここではnode_modulesをキャッシュする方式の例として、GitHub Actionsでの単純な設定例を記述してみます。

- uses: actions/setup-node@v3
  with:
    node-version-file: .node-version
- id: cache-node-modules
  uses: actions/cache@v3
  with:
    path: node_modules
    key: node-modules-${{ runner.os }}-${{ hashFiles('.node-version') }}-${{ hashFiles('package-lock.json') }}
    restore-keys: node-modules-${{ runner.os }}-${{ hashFiles('.node-version') }}-${{ hashFiles('package-lock.json') }}
- if: steps.cache-node-modules.outputs.cache-hit != 'true'
  run: npm ci

この例では、キャッシュキーにNode.jsのバージョンを含め、キャッシュを見つけられなかった場合にのみ npm ci を実行しています。

おわり

以上、npm ci の二つのキャッシュ方式について、それぞれの違いを見てみました。

結果、可能な場合にはnode_modulesをキャッシュし、できればキャッシュキーにNode.jsのバージョンを含めるというのが、より実行時間の短縮をねらいたい場合には有効そうに思いました。npm ci のキャッシュ方式、皆さんも是非検討してみてください。