pnpm の node_modules を探検して理解しよう

はじめに

こんにちは。ドワンゴ教育事業でエンジニアをしているユーンです。

N予備校アプリケーションやその他複数のプロジェクトで pnpm を採用しました。pnpm とは何か、npm とどう違うのかというのを node_modules の構造を追いながら理解しつつ、教育事業での採用した結果についてお話します。

pnpm とは

pnpm とは、npm や yarn とレイヤーを同じくするパッケージマネージャであり、サードパーティのものです。

pnpm.io

pnpm は他のツールと比較して高速でありディスク効率が良いと謳っています。

その pnpm の最大の特徴は、 node_modules の構造にあります。 例えば npm では v3 からフラットな node_modules を使うようになっております。yarn もデフォルトでは同様にフラットな node_modules を提供しています。

フラットな node_modules にしない場合、 foo の依存の bar の依存の baz の依存の qux...... と容易に続いてしまい、 node_modules が深くなりすぎてしまいます。ブラックホールと言われていた所以ですね。これは Windows など File Path に文字数制限のあるファイルシステムではインストールが失敗してしまいます。

一方 pnpm はシンボリックリンクを利用し、モジュール実体とディレクトリ構造を分離しています。これにより重複モジュールの単一化と依存関係の表現を両立しています。 それでは node_modules は無限に深くなってしまうのでは……と思うかもしれませんが、 シンボリックリンクであるため file system 上はそうなりません。

npm での node_modules の課題

"devDependencies": {
  "postcss": "8.4.38",
  "stylelint": "16.4.0"
}

これらのバージョンは執筆時点での最新バージョンであり、 v16.4.0 の stylelint の dependencies 以下に postcss は ^8.4.38指定されています。 この状況の場合、npm では以下のようなツリーになります。

node_modules
├── .bin
├── .package-lock.json
├── @babel
├── @csstools
├── @dual-bundle
├── @nodelib
├── ajv
├── ansi-regex
...
├── postcss
    ├── LICENSE
│   ├── README.md
│   ├── lib
│   └── package.json
...
├── stylelint
│   ├── LICENSE
│   ├── README.md
│   ├── bin
│   ├── lib
│   ├── package.json
│   └── types
...

105 directories, 1 files

途中省略しましたが、 104 ものモジュールが、 node_modules ディレクトリに並んでいます。一般的な構造ですね。 postcss 、 stylelint 両方以下に node_modules は存在しません。

次に、(postcss が peerDep で指定されているライブラリでの)挙動の変更の回避等のために postcss のバージョンを v7 系から上げたくないケースを例とします。その場合の node_modules は以下のようになります。

node_modules
...
├── picocolors (v0.2.1)
...
├── postcss (v7.0.39)
    ├── LICENSE
│   ├── README.md
│   ├── lib
│   └── package.json
...
├── stylelint (v16.4.0)
│   ├── LICENSE
│   ├── README.md
│   ├── bin
│   ├── lib
│   ├── node_modules
│   │   ├── picocolors (v1.0.0)
│   │   ├── postcss (v8.4.38)
│   │   └── postcss-safe-parser (v7.0.0)
│   ├── package.json
│   └── types
...

node_modules/stylelint 以下に node_modules が増えています。このような方法によって、 npm は同一ライブラリが複数バージョン混在するケースに対応しています。

さて、ここで node_modules/stylelint/node_modules 以下を見ていきましょう。 これらは stylelint@16.4.0 の dependencies に記載のある通りのバージョンになっており、それぞれ picocolors@1.0.0 、 postcss@8.4.38 、 postcss-safe-parser@7.0.0 となっています。

一方、node_modules 直下には postcss@7.0.39 、picocolors@0.2.1 があります。picocolors@0.2.1 は postcss@7.0.39 の dependencies に指定されています。

この状態でもしそのまま picocolors を使用したい場合、 node_modules/stylelint/node_modules への探索は行わないため、 v0.2.1 が使用されることになります。ではここで、 picocolors@1.0.0 を使用したい場合どうなるでしょうか。package.json に "picocolors": "1.0.0" を指定して試してみましょう。

node_modules
...
├── picocolors (v1.0.0)
...
├── postcss (v7.0.39)
    ├── LICENSE
│   ├── README.md
│   ├── lib
│   ├── node_modules
│   │   ├── picocolors (v0.2.1)
│   └── package.json
...
├── stylelint (v16.4.0)
│   ├── LICENSE
│   ├── README.md
│   ├── bin
│   ├── lib
│   ├── node_modules
│   │   ├── postcss (v8.4.38)
│   │   └── postcss-safe-parser (v7.0.0)
│   ├── package.json
│   └── types
...

望んだ通り node_modules 直下の picocolors は v1.0.0 になっていますが、 node_modules/postcss 以下に新たに node_modules が誕生し、 node_modules/stylelint/node_modules 以下も変わっています。

node_modules/postcss 以下の picocolors は v0.2.1 で、 node_modules/stylelint 以下の postcss は v8.4.38 です。

ここで例に上げた 3 モジュールの例だけですらこれだけの違いがあるのです、 Web アプリケーションを構築するほど多数のモジュールがある場合、 node_modules 以下が複雑になることは想像に難くありません。

ここでは例に上げませんでしたが(偶発的に発生するので意図して例示するのが難しいのです)、 直接インストールしていないモジュールが node_modules 直下に表出することにより発生する問題もあります。

例えば、意図していないバージョンのものを import してしまっていたり、 React が複数バージョン混ざってしまい警告が出るなどのトラブルが発生し得ます。

pnpm の node_modules による解決

他方、 pnpm の node_modules の構造では何が嬉しいかの説明のために、同様の構成の場合の node_modules 以下をみてみます。 まず postcss@8.4.38 、stylelint@16.4.0 の場合は以下のようになります。

node_modules
├── .bin
├── .modules.yaml
├── .pnpm
├── postcss -> .pnpm/postcss@8.4.38/node_modules/postcss
└── stylelint -> .pnpm/stylelint@16.4.0/node_modules/stylelint

5 directories, 1 file

node_modules 直下にあるモジュールは postcss と stylelint の 2 つのみとなっており、それらも node_modules/.pnpm 以下にシンボリックリンクされています。 そこで .pnpm 以下を見てみましょう。

node_modules/.pnpm
├── @babel+code-frame@7.24.2
├── @babel+helper-validator-identifier@7.22.20
├── @babel+highlight@7.24.2
├── @csstools+css-parser-algorithms@2.6.1_@csstools+css-tokenizer@2.2.4
├── @csstools+css-tokenizer@2.2.4
├── @csstools+media-query-list-parser@2.1.9_@csstools+css-parser-algorithms@2.6.1_@csstools+css-tokenizer@2.2.4
├── @csstools+selector-specificity@3.0.3_postcss-selector-parser@6.0.16
├── @dual-bundle+import-meta-resolve@4.0.0
├── @nodelib+fs.scandir@2.1.5
├── @nodelib+fs.stat@2.0.5
├── @nodelib+fs.walk@1.2.8
├── ajv@8.12.0
├── ansi-regex@5.0.1
├── ansi-regex@6.0.1
...
├── postcss@8.4.38
...
├── stylelint@16.4.0
...
120 directories, 1 file

先ほど見かけたような依存モジュールがありますが、各モジュールの末尾にバージョンが付与されていること、同一モジュールのバージョン違いが複数存在していることに注目してください。

ここで、シンボリックリンク先である node_modules/.pnpm/postcss@8.4.38/node_modules/postcss を見てみます。

postcss@8.4.38
└── node_modules
    ├── nanoid -> ../../nanoid@3.3.7/node_modules/nanoid
    ├── picocolors -> ../../picocolors@1.0.0/node_modules/picocolors
    ├── postcss
    │   ├── LICENSE
    │   ├── README.md
    │   ├── lib
    │   ├── node_modules
    │   │   └── .bin
    │   │       └── nanoid
    │   └── package.json
    └── source-map-js -> ../../source-map-js@1.2.0/node_modules/source-map-js

末端の postcssnode_modules 以下には .binnanoid のみで、他の依存はありません。 ではこの postcss はどのように依存モジュールを解決するかというと、root ディレクトリに向かって上階層の node_modules を順に探索していくため、まず自身が所属している node_modules を解決します。 ここに存在する nanoid@3.3.7 と picocolors@1.0.0 、source-map-js@1.2.0 は、以下に示す postcss@8.4.38 の dependency と一致しています。

"dependencies": {
  "nanoid": "^3.3.7",
  "picocolors": "^1.0.0",
  "source-map-js": "^1.2.0"
}

そして、 postcss からのモジュール探索は、より近い位置で解決され次第打ち切られるため、仮に上位の node_modules に依存と同一のモジュールがあったとしても、依存にあるバージョンのものが常に解決されることになります。 また、 postcss の直上の picocolorsstylelint の直上の picocolors のシンボリックリンクの実体は同一のものであるため、 node_modules のサイズも膨らまないようになっています。

続いて、先ほどと同様に、 postcss のバージョンを下げた状況を試してみます。

node_modules
├── .bin
├── .modules.yaml
├── .pnpm
│   ...
│   ├── picocolors@0.2.1
│   │   └── node_modules
│   │       └── picocolors
│   ├── picocolors@1.0.0
│   │   └── node_modules
│   │       └── picocolors
│   ...
│   ├── postcss@7.0.39
│   │   └── node_modules
│   │       ├── picocolors -> ../../picocolors@0.2.1/node_modules/picocolors
│   │       ├── postcss
│   │       └── source-map -> ../../source-map@0.6.1/node_modules/source-map
│   ├── postcss@8.4.38
│   │   └── node_modules
│   │       ├── nanoid -> ../../nanoid@3.3.7/node_modules/nanoid
│   │       ├── picocolors -> ../../picocolors@1.0.0/node_modules/picocolors
│   │       ├── postcss
│   │       └── source-map-js -> ../../source-map-js@1.2.0/node_modules/source-map-js
│   ...
│   ├── stylelint@16.4.0
│   │   └── node_modules
│   │       ...
│   │       ├── postcss -> ../../postcss@8.4.38/node_modules/postcss
│   │       ...
│   │       ├── stylelint
│   │       ...
│   ...
├── postcss -> .pnpm/postcss@7.0.39/node_modules/postcss
└── stylelint -> .pnpm/stylelint@16.4.0/node_modules/stylelint

先ほどと同様、 node_modules 直下には postcssstylelint のみですが、 postcss のバージョンは 7.0.39 になっています。 また .pnpm 以下に 2 つのバージョンの picocolors と postcss があり、それぞれが依存元の node_modules にシンボリックリンクされています。

node_modules/stylelint の実体である node_modules/.pnpm/stylelint@16.4.0/node_modules/stylelint からは、同じ node_modules 以下に属している postcss (postcss@8.4.38 へのシンボリックリンク) が解決されるようになっています。

複数ある picocolors もそれぞれ依存に指定されたバージョンのものが解決されるようになっています。

この状態で picocolors@1.0.0 を dependencies に追加した場合でも、 node_modules の最上位に picocolors がつくられ、実体は .pnpm 以下へのシンボリックリンクであることが予想できます。同様にモジュール数が増えても構造が大きく変わらないことも予想できるでしょう。 この仕組みが node_modules の節約に繋がっており、また各モジュール独立してキャッシュできることからキャッシュの効率化にも繋がっています。

ここまで pnpm で複数バージョンのモジュールが混在する場合でも問題なく動くことを説明してきました。 他にも、トップレベルの node_modules には「依存の依存」が配置されないため、 dependencies に指定されていないモジュールはユーザ空間から読み込むことができないなどの仕組みもあり、より安全で明確な dependencies の運用を支えてくれるものとなっています。

その他の pnpm の仕組みの詳細については、以下の公式による記事が参考になります。 pnpm.io pnpm.io

workspace

pnpm も他のパッケージマネージャ同様、 workspace 機能を提供しています。

npm や yarn の workspace では同一ライブラリの hoisting や解決順序の問題など運用にテクニックが必要でしたが、 pnpm では上記の仕組みにより、モジュールの解決と効率化をユーザが意図する必要がありません。

そのため、 node_modules の効率化や hoisting を気にせず純粋に責務に応じて依存モジュールを workspace root に置いたり、バージョン違いの React アプリケーションを同一の workspace に配置したりする程度では問題は発生しないようになっています。

注意点

前述の通り、 pnpm では明示的に依存に示されたモジュールしか import できません。これは「依存の依存」についても同様で、「依存」の dependencies に記述されていないものは使用できません。

使用しているモジュールの peerDependencies に指定されているものが偶然他のモジュールによって入っていた場合に正常に動作しなくなる可能性があるため、 peerDependencies の精査が必要です。モジュールによっては「依存の依存」で暗黙にインストールされることを期待して peerDependencies の記載がないものがあるかもしれません。

また、特定モジュールをパッチしたり、特定モジュールを plugin として受け取るものも注意が必要です。

具体的には webpack は compiler として webpack-dev-server を受け取ります。しかし実行コンテキストは webpack 内のコードとなるため、 webpack-dev-server の dependencies にあるコードには到達できずエラーとなってしまいます。(この辺りは謎が多く、深追いできていないのが現状です。)

私が発見できたのは webpack だけですが、他の plugin 機構を持つモジュールでも同様のことが起こり得る可能性があります。 いずれにせよ、十分な動作確認が必要となります。フロントエンドのコードであればビルドができれば import の問題は解決したと考えることもできますが、そうでない場合はテストケースが十分必要でしょう。

これらの心配をせずに pnpm に移行する方法もあります。それは .npmrcnode-linker:hoisted を指定することです。(https://pnpm.io/npmrc#node-linker)

これは従来の npm のようなフラットな node_modules を生成するオプションです。これにより厳密な依存の管理ができなくなりますが、それらが移行の障害となっている場合には有効かと思われます。

N予備校アプリケーションでの pnpm 採用

ここまでで pnpm がどのような仕組みであり、これまでの課題を解決してくれそうなことがわかりましたね。ではここで、私たちの話をします。

N予備校アプリケーションでは yarn v3 を使用していました。このコードベースは約 8 年もの間代謝を続けながら拡大し続けてきており、依存モジュールの数も相当なものとなっています。

また私たち特有の事情として、 CI 環境である AWS CodeBuild 上で yarn の並列インストールがうまく動かず、並列数 1 で実行していたことも時間がかかる原因となっていました。

そのため、 CI/CD 時のモジュールのインストールの時間が占める割合が高く、特にリリース作業等で連続して CI/CD を待つ必要がある場合に待ち時間が長く、作業に大きな支障をきたしていました。

昨年の 10 月に入社した私がはじめてのリリース作業をした際に、あまりの待ち時間の長さに不便さを感じたことが、検討段階にあった pnpm 移行を実施に踏み切るきっかけでした。私は個人プロジェクトや以前の職場を含めて pnpm の採用で高速化できた経験があったため、急遽 pnpm 対応のプルリクエストを出し、安全性の確認や説明をし、チームにも無事受け入れられたため移行する運びとなりました。

上記のメリットも相まってパッケージインストールの時間は大幅削減され、 CI ではキャッシュは使っていませんが並列インストールが可能になったことで約 20 分かかっていたところが約 14 分に、 CD では cache がうまく効くことによって 7 分から 1 分へ、短縮ができました。

副次的な作用として、ローカルでも webpack や jest の起動が高速化しました。これは調べても明確な理由ははっきりしませんが、 node_modules の解決が高速したことが関係しているのかと推測しています。

終わりに

教育事業では N予備校アプリケーション以外にも様々なアプリケーションがありますが、上記の成功例を元に他のアプリケーションでも pnpm の採用が進みました。 他にもフロントエンドの足回りを軽くし、来るべき改修に備える動きを常にしております。

明日 5 月 11 日に開催される TSKaigi でドワンゴはプラチナスポンサーをさせていただいております。

教育事業のメンバーがスポンサー LT 枠で発表するので、ぜひドワンゴの取り組みを見にお越しください。 私は LT 枠でパワフルな TypeScript の使い方について発表します。お楽しみに!

blog.nnn.dev

We are hiring!

N予備校の Web フロントエンドチームでは、無理なく継続的な開発をしていく手段を模索する仲間を募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com