うりうりさんの↓のコメントを見て、そういえばnpm ciって見たことあるけどチェックしてないなぁ。というかnpm installも雰囲気で使ってるなぁ。と思ったので、うりうりさんに教えてもらったことを手がかりに、npm installとnpm ciについて調べた。
これ、node_modulesキャッシュしてたり
— wreulicke (@wreulicke) March 14, 2023
npm install使ってるけど
npmのグローバルキャッシュ(~/.npm)をキャッシュした上で
npm ciで早くなったりしないんだろうか
GitHub Actions上でテストを約3倍早くした話https://t.co/MpmFktGBxU
ちょこっと検索して見てみたところ、新旧情報があって自分が混乱したのと、公式ドキュメントには概要は書かれているものの詳しい内容は書かれていないので(僕が見つけられていないだけかもしれない)、今回調べたことを自分用に2023年版として残しておくことにした。
最初にまとめ
思ったより長くなってしまったので、最初にまとめを書いておく。
npm ciのciは "Continuous Integration"ではなく、"Clean Install"
CI環境の設定には次の3通りの方法があり、どれも一長一短あるので、その長所短所を理解したうえで、好きなものを選べば良い。
npm ci+node_modulesキャッシュnpm ci+~/.npmキャッシュnpm install+node_modulesキャッシュ
やりたいこと
CI環境で、Gitのリポジトリからコードを引っ張ってきたあとに、そのプロジェクトに必要なパッケージをいい感じにインストールしたい。
パッケージ管理の仕組み
を簡単におさらい(とnpm ciの紹介)。
基本的な流れ
- Node.jsプロジェクトの依存パッケージを
package.jsonに書いておく(といっても、通常はコマンドでパッケージを追加したら自動的にpackage.jsonが更新される) npm installを実行するとpackage.jsonの情報を元にパッケージがダウンロードされて、node_modulesディレクトリの中にインストールされる
バージョンには幅を持たせられる
package.jsonでは、依存パッケージのバージョンに幅を持たせられる。たとえば^4.1.0と書くと、「 このパッケージのバージョン4.1.0以上でマイナーバージョンアップは受け入れる(つまり < 5.0.0 まで)」という意味になる。
npm installを実行すると、その条件を満たす最新のバージョンがインストールされる。
参照:https://docs.npmjs.com/cli/v9/configuring-npm/package-json#dependencies
違うバージョンになってしまう
バージョンに幅を持たせられるのは便利なのだけど、そうすると、同じpackage.jsonからパッケージをインストールしても、時期によって異なるバージョンがインストールされてしまう。これは困る。
たとえば、僕がローカル環境で実行したときには4.1.0がインストールされたのに、本番環境用にビルドしたときにはマイナーバージョンアップされてて4.2.0がインストールされる、ということが起こり得る。
本番環境には、動作確認をしたバージョンと全く同じバージョンのものをデプロイしたいので、困る。
package-lock.json
このような状況を防ぐために、package-lock.jsonというファイルがある。
npm installを実行するとpackage.jsonを元にインストールするバージョンが決まって、そのバージョンのパッケージがインストールされる。このときに、インストールしたパッケージの情報をpackage-lock.jsonというファイルに書き出しておいてくれる。
ここでnpm ciコマンドが登場する。
npm ciコマンド
npm ciコマンドを実行するとpackage-lock.jsonからパッケージのバージョンを読み込んで、そこに記載されているバージョンのパッケージをインストールしてくれる。
つまり、こういうこと↓
- ローカル環境で僕が
npm installして4.1.0がインストールされたら、その4.1.0がpackage-lock.jsonに記録される package-lock.jsonをGitのリポジトリにプッシュする(node_modulesは普通はプッシュしない)- 本番環境のビルドをするときは
npm ciを実行すればpackage-lock.jsonの情報を元にして、パッケージがインストールされる。そのため、4.2.0がリリースされていたとしても4.1.0をインストールしてくれる
ちなみに、npm ciのciは、CI環境で使われるから "Continuous Integration" のCIかな?と思っていたら違った。"Clean Install" のことだった。
参照:https://docs.npmjs.com/cli/v9/commands/npm-ci
ここまでが、パッケージ管理の仕組み。次にnpm ciの動きをもう少し詳しく見てみる。
npm ciコマンドの動き
package-lock.jsonをチェックしてくれる
package-lock.jsonファイルが存在しない場合、エラーになる 1package-lock.jsonとpackage.jsonに齟齬がある場合、エラーになるpackage-lock.jsonやpackage.jsonを更新することはない
node_modulesディレクトリを削除する
node_modulesディレクトリがある場合、node_modulesディレクトリを削除してから、パッケージをインストールする
CI環境ではnpm ciを使う
ということで本題のCI環境。
CI環境では、毎回クリーンな状態から始めて、そこにパッケージをインストールしたい。だから、単純にnpm ciを実行すればOK。package-lock.jsonを元にパッケージがインストールされる。
なのだけど、CIを実行するたびにパッケージをダウンロードするとビルドに時間がかかってしまう。そこで、CI環境のキャッシュを利用する。
キャッシュ
スピードアップのため、ダウンロードしてきたパッケージをキャッシュしたい。
node_modulesをキャッシュする場合
package-lock.jsonの内容のハッシュ値をキーにしてnode_modulesをキャッシュする。
- キャッシュがなければ
npm ciを実行 - ハッシュにマッチするキャッシュがあれば何も実行せずにそのまま利用
ここまででも十分良い。
フォールバックキー
ただ、この場合、package-lock.jsonの内容が変更されたり、キャッシュが期限切れになると、もういちど全てをダウンロードする必要がある。そこで、フォールバックキーの利用を考える。CircleCIもGitHub Actionsもキャッシュに対してフォールバックキーが指定できる。
フォールバックキーを使うと、キーにマッチするキャッシュがない場合に、違うバージョンのpackage-lock.jsonのキャッシュをフォールバックとして利用するよう設定できる。「package-lock.jsonの内容がちょっと違うからキャッシュの内容もそのままでは使えないんだけど、何もないところから始めるよりは速い」みたいな状況。
なのだけど、node_modulesをキャッシュする場合は、このフォールバックの方法は意味がない。キャッシュからnode_modulesをリストアしたとして、その内容はpackage-lock.jsonの内容と少し違うのでnpm ciを実行する必要がある。そうすると、node_modulesは削除されて再度ダウンロードが始まってしまう。
~/.npmをキャッシュする場合
それは困った。ということでnode_modulesではなく、~/.npmをキャッシュすることにする。~/.npmは、npm自体のキャッシュディレクトリ(場所は設定やオプションで変更できる)。
- CIのキャッシュがなければ
npm ciを実行npm ciはパッケージを~/.npmにダウンロードして、node_modulesにコピーする
- CIのキャッシュがあっても
npm ciを実行~/.npmにnpmのキャッシュがあるのでダウンロードはスキップして、node_modulesにコピーする
CIのキャッシュがヒットすれば、ダウンロードの時間分だけ速くなる。そして、この場合はフォールバックキーも機能する。npm ciは、パッケージがnpmのキャッシュに見つからなければダウンロードしてインストールするため。
これもこれで十分良い。
コピーの時間が・・・
これはまだ計測していないので推測なのだが、node_modulesをキャッシュする場合に比べて、~/.npmをキャッシュする場合は、ファイルを~/.npmからnode_modulesにコピーする分だけ時間がかかると想像できる。
くぅ・・・。今の状況を整理するとこういうこと。
node_modulesをキャッシュすると、ヒットしたときのスピードは速いが、ヒットしなかったときにフォールバックは利用できない~/.npmをキャッシュすると、フォールバックが効くが、キャッシュにヒットしてもパッケージをキャッシュからnode_modulesにコピーする必要があるので毎回その分だけ遅くなる
両方の良いところを取りたいよー。と考えて思いつくのが、npm ciにnode_modulesを削除しないオプションがあれば解決では?ということ。
node_modulesを削除しないオプション?
(先に言っておくと、こんなオプションは残念ながら、ない)
たとえばnpm ci --no-deleteみたいにして実行できれば、node_modulesをキャッシュする方法で良いとこどりができる。
- キャッシュヒットした場合、
npm ci --no-deleteを実行したら、すでにパッケージが揃ってるから何もしない - キャッシュヒットしなくてフォールバックからリストアした場合、
npm ci --no-deleteを実行したら、足りていないパッケージだけダウンロードされて更新される
と思って、Issueを探してみたら、あるよね。
Issueから読み取れること
Issueを読むと、次のようなことが分かる。
npm ciはnode_modulesディレクトリを削除するからこそ速いpackage.jsonやnode_modulesの現状を考えずにインストールできるため
だから、node_modulesディレクトリを削除しないというオプションはクリーンインストールという意図にそぐわないので、入れることはできない。
なるほど。逆に言うと、npm installはpackage.jsonや既存のnode_modulesを考慮してパッケージをインストールするのか。だからnpm ciより時間がかかる。
そのためCI環境ではnpm ciを使うことがお勧めされている。たしかに、何もキャッシュがない状況からのインストールだとnpm ciは速いかもしれない。しかし、node_modulesをフォールバックのキャッシュからリストアした場合にはnpm ciよりもnpm installの差分更新の方が速いことも考えられる。
ここで、ずっと気になっていた問題に触れることにする。「npm installじゃだめなの?」という問題。
npm installじゃだめなの?
Issue自体は特に対応なしでクローズされているのだが「npm ciにオプションを追加するのではなくnpm installに『package-lock.jsonからインストールする』というオプションを追加してはどうか?」と提案があり、RFCリクエストが作成されている。
[RRFC] npm install --from-lockfile · Issue #415 · npm/rfcs · GitHub
だけど2023-03-15現在もステータスはOPEN。RFCリクエスト起票者のコメントで「このRFCリクエストには対応して欲しいと思うが、優先度は下がっている。なぜならnpm installの挙動が改善されたからだ」とある。・・・え?
どうも、以前はnpm installはpackage-lock.jsonを無視していたらしい。だから、npm installを実行すると、実行したタイミングで最新のバージョンのパッケージがインストールされていたみたい。それが問題になってnpm ciが作成されたようである。ところが、この動きがnpm v7で、バグとしてfixされた様子。
- https://github.com/npm/npm/issues/17979#issuecomment-332701215
- https://github.com/npm/cli/issues/564#issuecomment-921314014
つまりnpm installは、package-lock.jsonが存在してpackage.jsonと齟齬がない場合は、新しいバージョンがリリースされていたとしても、package-lock.jsonの内容を元にパッケージをインストールする。内容に齟齬がある場合は、package.jsonの内容を元にpackage-lock.jsonを更新する。
という動きに変わった様子なのだ。そして、そのことは、どこにも書かれていない(か、僕が見つけることができていない)。
なるほど?
混乱した。頭の整理をしよう。
npm installを実行すると、実行したタイミングのバージョンのパッケージがインストールされていたためnpm ciが生まれたnpm ciは、クリーンインストールによってインストールのスピードを上げているnpm installは、その後バグフィックスされ、package-lock.jsonがあればそのバージョンのパッケージをインストールするようになった
つまり、package-lock.jsonとpackage.jsonの内容に齟齬がないように運用できれば、次のようなCI環境が可能となる。
npm installを利用したCI環境
node_modulesをキャッシュすれば、次のような運用になる。
- キャッシュが何もなければ、
npm installを実行して全てインストールする - キャッシュがヒットした場合、
npm installを実行して、差分がなければ何もせずに終わる - キャッシュはヒットしなかったがフォールバックキーにヒットした場合、
npm installを実行して、差がある分だけインストールされる
おー。
まとめ
ということで、まとめると次のようになる
その1: npm ci + node_modulesキャッシュ
- キャッシュがなければ
npm ciを実行 - ハッシュにマッチするキャッシュがあれば何も実行せずにそのまま利用
シンプル。だが、フォールバックは効かない。
その2:npm ci + ~/.npmキャッシュ
- キャッシュがあってもなくても
npm ciを実行
これもシンプル。フォールバックも効く。でも、コピーの分だけ時間がかかる(と思う)。
その3:npm install + node_modulesキャッシュ
- キャッシュがあってもなくても
npm installを実行
キャッシュの中で有効なパッケージをそのまま利用され、足りないパッケージはダウンロードされる。ただし、package.jsonとpackage-lock.jsonに齟齬がある場合、package-lock.jsonが更新されて意図していないバージョンのパッケージがインストールされるというリスクがある。
package.jsonとpackage-lock.jsonに齟齬がない状態で運用できる仕組みが整っているなら、個人的にはこれが一番良さそう。齟齬がある場合にエラーになって欲しいなら、npm ciを使う方法が良い(まぁ、npm install前後のpackage-lock.jsonの差分をチェックして、差分があればCIを失敗させるということも可能だろう)。
すっきり
したー!今回は頭の中だけで実行したので、実際の動きを計測したいなと思っている。
注意点:
node_modulesをキャッシュする場合、npm ciやnpm installをした直後にキャッシュするべき。ビルド後だと、ビルド時に生成された情報までキャッシュされる可能性がある。
もうひとつ情報:
少しズレるかもですが、pnpm であれば .npm 相当のグローバルストアから node_modules にハードリンクを貼るので、コピーの時間は相当短くなります(yarn v3 の hardlinks-global オプションも同様)
— こたに ゆうく (@yukukotani) March 14, 2023
これは便利そう。ゆうくさんありがとうございます!
おもしろかった!うりうりさんありがとー!
-
npm-shrinkwrap.jsonファイルもチェックするが、今回は触れない。↩