npm

npm ciを使おう あるいはより速く

人類はより高速にCIを回していくべきだと思っている りんご(@mstssk)です。

先日、 npm の v5.7がリリースされ npm ci というサブコマンドが新たに追加されました。

The npm Blog — Introducing npm ci for faster, more reliable... http://blog.npmjs.org/post/171556855892/introducing-npm-ci-for-faster-more-reliable

CI/CDを開発プロセスに組み込んでいる場合により整合性があり高速なエクスペリエンスを提供する、と公式ブログでは紹介しています。

npm ci は何をするのか

npm ci を実行すると常に package-lock.json から依存関係をインストールします。
既に node_modules フォルダの中身があっても一旦削除します。

従来の npm install コマンドを実行すると、 package.json と package-lock.json の両方を見て依存関係の解決と依存パッケージの node_modules へのインストールを行います。 package.json を解決して必要に応じてロックファイルである package-lock.json の更新もします。

一方で npm ci は package.json の依存関係の解決を行わず、常に package-lock.json を見て依存パッケージをダウンロードし node_modules の洗い替えを行います。
しかし、 package.json を完全に無視するというわけではなく、 package-lock.json と依存バージョン指定が食い違っているとエラーにしてくれます。例えば、あるパッケージを v0.8.9でインストールし package-lock.json まで作成済みの時に、そのパッケージを更新しようとして package.json だけ v0.9.0 に書き換えてしまった状態で npm ci を実行するとエラーになります。

このように、依存関係の更新をせずに整合性チェックと依存パッケージのダウンロードのみを行うため npm install より高速に動作し、CIで必要なことだけを行うのが npm ci コマンドです。

npm ci は銀の弾丸ではない

npm ci が package.json と package-lock.json の整合性をチェックしてくれるのは嬉しいです。
一方で、依存パッケージのダウンロード・インストールの高速化については、爆速になるというほどではありません。

私が普段触っているWebサイト構築プロジェクトいくつかで時間を計測してみました。

npm install npm ci
プロジェクトA 25.515s 16.656s
プロジェクトB 29.761s 20.676s
プロジェクトC 54.380s 40.710s

※いずれも node_modules が存在しない状態で計測。

どの場合でも早くなるのは10秒前後でした。この10秒が package.json を見て依存関係の解決を行う時間なのでしょう。

削減できなかった時間が何なのかというと、ログを見ている限りでは各依存パッケージの preinstall / postinstall のスクリプトの実行時間のようです。
例えば node-sass パッケージは実行環境ごとのバイナリのインストールや動作チェックを行っているようですし、その他にもインストール時に node-gyp を使ってネイティブモジュールをビルドしている、なんてものもありました。

思い切って node_modules をキャッシュすると早い

CIの速度を重視するなら npm ci を使うのではなく、もっと愚直に node_modules をキャッシュしてしまうという手段もあります。

例えば、私は普段CircleCIの config.yml で次のように設定して、node_modules をキャッシュさせています。

.circleci/config.yml
version: 2
jobs:
  build:
    steps:
      - checkout
      - restore_cache:
          keys:
            - npm-cache-{{ checksum "package-lock.json" }}
      - run: npm install
      - save_cache:
          key: npm-cache-{{ checksum "package-lock.json" }}
          paths:
            - ./node_modules
      /* 後略 */

この方法によって、通常 npm install に54秒かかっていたプロジェクトも、キャッシュのレストアと npm install の時間あわせて15秒程度で済んでいます。
もちろん、最初の1回や package-lock.json を更新した際は時間がかかってしまいますが、多くの場合で大幅に速度改善になります。

ただし、この方法では npm ci が提供する package.json と package-lock.json の整合性チェックが使えません。
npm ci は node_modules があると一旦消して洗い替えしてしまうのでキャッシュと一緒に使用できません。
CIで何を担保するのかを考慮した上でキャッシュの使用を検討するとよいでしょう。

まとめ

npm ci は既にCIでnpmを使っているプロジェクトへの強力なサポートとなります。
どうしても速度を改善したい場合は node_modules をキャッシュしてしまう方法もあります。

みなさんもプロジェクトにあわせてCIをメンテしていきましょう。

追記 2018-07-18

この記事を書いた後に気づいたことなど。

~/.npm をキャッシュしよう

npm ci の公式ドキュメントでは、Travis CIを使う場合に ~/.npm フォルダをキャッシュする例を記載しています。

前述のとおり node_modules をキャッシュしても npm ci が消してしまい意味がありません。
しかし、 npm が依存関係解決をする際にダウンロードしてきたものをキャッシュしている ~/.npm フォルダをCIのキャッシュに含むことでより速度改善を図ることができます。

今は私が携わっているプロジェクトでも、 ~/.npm をキャッシュしつつ npm ci コマンドを使うよう CircleCI の設定を変えています。

npm ci は人間の attention というリソースの消費も改善する

どんなツールでもサービスでも同じなのですが、何かを行う・行わせるというのは人間の attention1(注意とか意識とか集中力とか)を消費します。

npm install コマンドを使うと、 package.json を変えていなくても npm のバージョンが違っていれば package-lock.json は変更されてしまいます。
更には、 package.json を変えたのに package-lock.json をコミットし忘れてしまったといった場合にCIで気づくことができません。
特に開発メンバーが多いプロジェクトではメンバー間の技術力・意識の差により問題が顕著になります。
それを防ぐために開発者がいちいち頑張るのはリソースを余計に消費していることになります。

npm ci を使えば、速度の改善と依存関係の整合性のチェックを一息に行ってくれますので、上記の問題について開発者が気を揉む必要がなくなります。

そもそも、 npm 公式の Blog 記事のタイトルにも "for faster, more reliable builds" とあり、依存関係解決について速度と信頼性向上のための機能であるとわかります。
本qiita記事を書いたときは速度改善にばかり目が行ってしまっていましたが、実際運用してみて npm ci が何を提供してくれるのかを再認識しました。


  1. Lolipopの頃のAndroidの通知機能の公式ドキュメントに「通知を出してユーザーの注意を向けさせたりするのは、ユーザーの注意や関心を奪い消費しているということを認識しよう」といったことが書いてあって、通知に限った話ではないなと思い、私はその言い回しをたまに使っています。