npm install と npm ci って結局どう使うの?2023年版

うりうりさんの↓のコメントを見て、そういえばnpm ciって見たことあるけどチェックしてないなぁ。というかnpm installも雰囲気で使ってるなぁ。と思ったので、うりうりさんに教えてもらったことを手がかりに、npm installnpm ciについて調べた。

ちょこっと検索して見てみたところ、新旧情報があって自分が混乱したのと、公式ドキュメントには概要は書かれているものの詳しい内容は書かれていないので(僕が見つけられていないだけかもしれない)、今回調べたことを自分用に2023年版として残しておくことにした。

最初にまとめ

思ったより長くなってしまったので、最初にまとめを書いておく。

  • npm ciciは "Continuous Integration"ではなく、"Clean Install"

CI環境の設定には次の3通りの方法があり、どれも一長一短あるので、その長所短所を理解したうえで、好きなものを選べば良い。

  • npm ci + node_modulesキャッシュ
  • npm ci + ~/.npmキャッシュ
  • npm install + node_modulesキャッシュ

やりたいこと

CI環境で、Gitのリポジトリからコードを引っ張ってきたあとに、そのプロジェクトに必要なパッケージをいい感じにインストールしたい。

パッケージ管理の仕組み

を簡単におさらい(とnpm ciの紹介)。

基本的な流れ

  1. Node.jsプロジェクトの依存パッケージをpackage.json に書いておく(といっても、通常はコマンドでパッケージを追加したら自動的にpackage.jsonが更新される)
  2. 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からパッケージのバージョンを読み込んで、そこに記載されているバージョンのパッケージをインストールしてくれる。

つまり、こういうこと↓

  1. ローカル環境で僕がnpm installして4.1.0がインストールされたら、その4.1.0package-lock.jsonに記録される
  2. package-lock.jsonをGitのリポジトリにプッシュする(node_modulesは普通はプッシュしない)
  3. 本番環境のビルドをするときはnpm ciを実行すればpackage-lock.jsonの情報を元にして、パッケージがインストールされる。そのため、4.2.0がリリースされていたとしても4.1.0をインストールしてくれる

ちなみに、npm ciciは、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ファイルが存在しない場合、エラーになる 1
  • package-lock.jsonpackage.jsonに齟齬がある場合、エラーになる
  • package-lock.jsonpackage.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 cinode_modulesを削除しないオプションがあれば解決では?ということ。

node_modulesを削除しないオプション?

(先に言っておくと、こんなオプションは残念ながら、ない)

たとえばnpm ci --no-deleteみたいにして実行できれば、node_modulesをキャッシュする方法で良いとこどりができる。

  • キャッシュヒットした場合、npm ci --no-deleteを実行したら、すでにパッケージが揃ってるから何もしない
  • キャッシュヒットしなくてフォールバックからリストアした場合、npm ci --no-deleteを実行したら、足りていないパッケージだけダウンロードされて更新される

と思って、Issueを探してみたら、あるよね。

Issueから読み取れること

Issueを読むと、次のようなことが分かる。

  • npm cinode_modulesディレクトリを削除するからこそ速い
    • package.jsonnode_modulesの現状を考えずにインストールできるため

だから、node_modulesディレクトリを削除しないというオプションはクリーンインストールという意図にそぐわないので、入れることはできない。

なるほど。逆に言うと、npm installpackage.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 installpackage-lock.jsonを無視していたらしい。だから、npm installを実行すると、実行したタイミングで最新のバージョンのパッケージがインストールされていたみたい。それが問題になってnpm ciが作成されたようである。ところが、この動きがnpm v7で、バグとしてfixされた様子。

つまり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.jsonpackage.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.jsonpackage-lock.jsonに齟齬がある場合、package-lock.jsonが更新されて意図していないバージョンのパッケージがインストールされるというリスクがある。

package.jsonpackage-lock.jsonに齟齬がない状態で運用できる仕組みが整っているなら、個人的にはこれが一番良さそう。齟齬がある場合にエラーになって欲しいなら、npm ciを使う方法が良い(まぁ、npm install前後のpackage-lock.jsonの差分をチェックして、差分があればCIを失敗させるということも可能だろう)。

すっきり

したー!今回は頭の中だけで実行したので、実際の動きを計測したいなと思っている。

注意点:

  • node_modulesをキャッシュする場合、npm cinpm installをした直後にキャッシュするべき。ビルド後だと、ビルド時に生成された情報までキャッシュされる可能性がある。

もうひとつ情報:

これは便利そう。ゆうくさんありがとうございます!

おもしろかった!うりうりさんありがとー!


  1. npm-shrinkwrap.jsonファイルもチェックするが、今回は触れない。