GitHub Action で npm ci 高速化のために cache を使ってみる実験ノート

## 背景

GitHub Actions で、ユニットテストのフレームワークである Vitest を 動かそうとしていました。Vitest を動かすには、Node.js 環境を構築してパッケージインストールをして、という動作が必要になります。

パッケージのインストールは、あまり数が多くないうちは問題にならないにしても、増えてくるとパッケージインストールに時間を食われるようになってきます。

そのとき、インストールしたパッケージを丸々保存しておき、次使うときに復元してやることで、パッケージインストールにかかる時間を減らすことができ、ワークフローの実行時間を減らすことができます。GitHub Actions では、cache という機能があり、それを活用できます。

(今のところ、使っているパッケージは Vitest のみで実行時間は問題になっていないのですが、) 興味を持ったこの機会に cache 機能を使っての効果を確かめてみることにします。

## 下準備

適当に GitHub のリモートリポジトリを用意し、それをクローンしたローカルリポジトリ上で作業をします。

```sh
git clone <URI>
cd <repo_name>
```

違いを分かりやすくするため、依存解決を重くします。ひたすら知っているパッケージをインストールしましょう。

```sh
npm install -D jest vite vitest electron react nextjs nuxt

ls
# node_modules  package.json package-lock.json
```

```package.json
{
  "devDependencies": {
    "electron": "^31.2.0",
    "jest": "^29.7.0",
    "nextjs": "^0.0.3",
    "nuxt": "^3.12.3",
    "react": "^18.3.1",
    "vite": "^5.3.3",
    "vitest": "^2.0.2"
  }
}
```

`node_modules`ディレクトリには、インストールしたパッケージの実態が格納されています。これは、同時に生成されたパッケージ情報ファイルの `package.json` `package-lock.json` から再構築することが可能なため、Git などのソース管理システムに追跡させないべきです。

```.gitignore
node_modules
```

パッケージの実態 `node_modules` を再構築するには、以下のコマンドを実行します。

```sh
npm ci
```

:::note
この `ci` は Clean Install の意。Continuous integration の CI ではないので注意。
:::

:::note warn
`npm install` でも可能ですが、GitHub Actions などの CI (Continuous Integration) 環境上では、`npm ci` の方が推奨されています。詳細は他記事に丸投げで。
:::

普通、CI 環境上は何もない状態から始まるので、パッケージをインストール (再構築) するところから始まります。パッケージが多くなってくると、その再構築 `npm ci` に時間をとられるようになってくるわけです。

## 処理時間測定方法

以下の流れを基本とし、cache を使ってたり使ってなかったりする GitHub Actions ワークフローを実行します。

1. ソースチェックアウト [`actions/checkout@v4`](https://github.com/marketplace/actions/checkout)
2. Node.js セットアップ [`actions/setup-node@v4`](https://github.com/marketplace/actions/checkout)
3. パッケージインストール `npm ci`

このうち、2 と 3、またキャッシュを使用する場合はキャッシュリストアにかかる時間も含めた部分を計測対象とします。

![actions-steps.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/35e03567-9d16-d5ea-44ad-6791736a5b8a.png)

ワークフローを 5回 re-run (合計 6回実行) し、初回以外の 5回で実行時間を確認します。

![actions-re-run-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/f05b997d-5359-14c2-c4dc-7e518368e6a1.png)

![actions-re-run-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/93d4422b-e234-62b9-914e-7632489dad2c.png)

:::note
キャッシュを用いる場合、初回はキャッシュヒットしないために時間がかかるため、初回の実行時間は無視します。
:::

## 実験

### cache 無し

まずは cache 機能を用いない、基本的な形で実装します。

```yml
name: JS dependency test 1

on: [ push, workflow_dispatch ]

jobs:
  js-depend-test:
    runs-on: ubuntu-latest

    steps:
    - name: Source checkout
      uses: actions/checkout@v4
    - name: Set up Node 20
      uses: actions/setup-node@v4
      with:
        node-version: 20
    - name: Dependency installing
      run: npm ci
```

所要時間は以下の通り。

| 回数 | `setup-node` \[秒\] | `npm ci` \[秒\] | 計 \[秒\] |
|:-:|:-:|:-:|:-:|
| 1 | 0 | 11 | 11 |
| 2 | 0 | 10 | 10 |
| 3 | 0 | 10 | 10 |
| 4 | 1 | 12 | 13 |
| 5 | 0 | 10 | 10 |
| 6 | 2 | 11 | 13 |
| 2~6 平均 | 0.6 | 10.6 | 11.2 |

### actions/setup-node で cache

`actions/node-setup` に、キャッシュを設定できる項目があるようです。これも試してみます。

```diff_yaml
name: JS dependency test 2

on: [ push, workflow_dispatch ]

jobs:
  js-depend-test:
    runs-on: ubuntu-latest

    steps:
    - name: Source checkout
      uses: actions/checkout@v4
    - name: Set up Node 20
      uses: actions/setup-node@v4
      with:
        node-version: 20
+       cache: npm
+       cache-dependency-path: package-lock.json
    - name: Dependency installing
      run: npm ci
```

所要時間は以下の通り。

| 回数 | `setup-node` \[秒\] | `npm ci` \[秒\] | 計 \[秒\] |
|:-:|:-:|:-:|:-:|
| 1 | 1 | 10 | 11 |
| 2 | 1 | 7 | 8 |
| 3 | 9 (?) | 8 | 17 |
| 4 | 3 | 8 | 11 |
| 5 | 2 | 7 | 9 |
| 6 | 2 | 7 | 9 |
| 2~6 平均 | 3.4 | 7.4 | 10.8 |

(?) 一度だけ、なぜか `setup-node` に異様に時間がかかりました。回線かなんかでも不安定になったのでしょうか･･･？

cache の一覧を見てみると、新しく cache が作られているのが分かります。(下のもう一つの cache は、次の「actions/cache で node_modules を丸々 cache」で生成されたもの。)

![actions-cache.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/0b071d2a-1c0f-254b-001d-6ac10150747a.png)

### actions/cache で node_modules を丸々 cache

`node_modules` さえ復元できれば、必要なパッケージは揃うことになるわけです。

というわけで、cache を使って`node_modules` を丸々保存および復元するようにしてみます。

```diff_yaml
name: JS dependency test 3

on: [ push, workflow_dispatch ]

jobs:
  js-depend-test:
    runs-on: ubuntu-latest

    steps:
    - name: Source checkout
      uses: actions/checkout@v4
    - name: Set up Node 20
      uses: actions/setup-node@v4
      with:
        node-version: 20
+   - name: Dependency restore
+     id: cache-restore
+     uses: actions/cache@v4
+     with:
+       path: node_modules
+       key: js-depend-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    - name: Dependency installing
+     if: steps.cache-restore.outputs.cache-hit != 'true'
      run: npm ci
```

cache がヒットした際は、`npm ci` は不要になるので実行されないように組みました。

所要時間は以下の通り。

| 回数 | `setup-node` \[秒\] | `cache` \[秒\] | `npm ci` \[秒\] | 計 \[秒\] |
|:-:|:-:|:-:|:-:|:-:|
| 1 | 1 | 1 | 10 | 12 |
| 2 | 0 | 4 | 0 (*) | 4 |
| 3 | 0 | 2 | 0 (*) | 2 |
| 4 | 0 | 2 | 0 (*) | 2 |
| 5 | 0 | 2 | 0 (*) | 2 |
| 6 | 0 | 2 | 0 (*) | 2 |
| 2~6 平均 | 0.0 | 2.4 | 0.0 | 2.4 |

:::note
(*) if の条件が満たされないためにスキップされた箇所

![step-skipped.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/d01155e7-f106-d9ed-9f6b-351c285d6093.png)
:::

<details><summary>'actions/cache@v4' について軽く説明</summary>

`actions/cache`の設定について、key はファイル名、path はそのファイルの中身 (保存する内容)、というイメージで OK。

ワークフローの `actions/cache` に差し掛かったところで、ファイルの復元を試みます。ヒットする key がなければ、復元されず (当該ファイルやディレクトリは存在しないまま) 次に進みます。

当該ファイルが無いままだと正しく進められないので、キャッシュヒットしなかった場合はファイルを準備するスクリプトを動かす、という動きのために、上のワークフローファイルにあるように `if: steps.cache-restore.outputs.cache-hit != 'true'` が活用できます。

他のステップが終わった後に、Post ステップが入ってきます。key がヒットしなかった場合は、ここでキャッシュ保存をします。

![cache-post.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/8f2ec2ae-5b97-2a59-2d08-4670fb5ec97c.png)

---

</details>

## `actions/setup-node` の cache は微妙？

| 形態 | 所要時間平均 \[秒\] |
|:-:|:-:|
| cache 無し | 11.2 |
| `actions/setup-node` でキャッシュ | 10.8 |

(謎の外れ値が入ったのもありますが、) `actions/setup-node` の効果が微妙に見えます。

ところで、`actions/setup-node` の README を見てみると、Caching global packages data とあります。

https://github.com/actions/setup-node/blob/main/README.md#caching-global-packages-data

`npm ci` でパッケージインストールをするとき、global キャッシュにヒットすればキャッシュから復元、ヒットしなければパッケージサーバからダウンロードをする。ダウンロードが無い分、cache 無しよりは早くなったのではないか、と予想します。(ここら辺詳しくないが･･･)

## まとめ

| 形態 | 所要時間平均 \[秒\] |
|:-:|:-:|
| cache 無し | 11.2 |
| `actions/setup-node` でキャッシュ | 10.8 |
| `actions/cache` でキャッシュ | 2.4 |

`actions/cache` で `node_modules` を丸々復元する形だと、cache 無しで十数秒のところが数秒になり、いい感じにスピードアップができました。(なお適切なやり方かは不明)

この機会で、GitHub Actions の cache についてもちょっとは詳しくなれました。