うりうりさんの↓のコメントを見て、そういえば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
ファイルもチェックするが、今回は触れない。↩