pnpmの「禁断の果実」を断つ:–no-hoistによる依存関係の完全隔離と、その先のアーキテクチャ設計
多くのフロントエンドエンジニアがnpmやYarnの「hoisting(引き上げ)」に甘えている間に、我々は依存関係の不法侵入による「幽霊依存(Phantom Dependencies)」という地獄を何度も見てきた。
pnpmはコンテンツアドレサブルストレージによるディスク効率の革命児だが、デフォルトでは依然として互換性のために一部の依存をフラットな`node_modules`構造へ引き上げる。しかし、真に堅牢なビルドシステムを構築しようとするアーキテクトにとって、この「便利機能」は時にシステムの決定論的動作を損なう毒となる。
本稿では、`–no-hoist`(厳密には `.npmrc` での `public-hoist-pattern[]` 制御)を極限まで活用し、依存関係を完全隔離する戦略について、その深い設計思想と実装を紐解く。
—
1. なぜ「hoisting」はエンジニアリングの敗北なのか
Node.jsのモジュール解決アルゴリズムは、物理的なディレクトリ構造に依存している。hoistingは、パッケージAが依存するパッケージBを、本来あるべき `node_modules/A/node_modules/B` ではなく、ルートの `node_modules/B` に配置する行為だ。
この挙動が引き起こす致命的な問題は以下の通りだ。
- 幽霊依存の温床: `package.json` に明示していないパッケージが、偶然ルートにあるという理由だけで `import` 可能になる。ビルド環境が変わった瞬間に崩壊する時限爆弾だ。
- 決定論的ビルドの欠如: 依存グラフのわずかな変化でルートの構造が変わり、テストでは通るがデプロイで死ぬ、という再現困難なバグを引き起こす。
- プラグインの誤作動: ESLintやJestなどのツールが、親階層の `node_modules` を探索し、想定外のバージョンを掴むリスク。
2. `.npmrc` による「物理的隔離」の実装
pnpmにおいて依存の不法侵入を完全に防ぐには、`.npmrc` でhoistingを無効化し、依存関係を「あるべき場所」に強制的に留める必要がある。
.npmrc
hoistパターンを空にすることで、すべての依存を本来の依存階層に閉じ込める
public-hoist-pattern[] =
厳密な依存関係チェックを強制(lockfileとの整合性を保証)
frozen-lockfile = true
シンボリックリンクの解決を厳格化し、階層構造をエミュレートする
shamefully-hoist = false
この設定の代償と克服
この設定を有効にすると、従来の「フラットな node_modules」を期待する古いライブラリ(一部の汚い設定を持つビルドツールなど)が動かなくなることがある。しかし、これは「ツール側が依存関係の管理をサボっている」というシグナルだ。我々はこれを解消するために、`pnpm.packageExtensions` を使用して、明示的に欠落している依存を注入する。
pnpm-workspace.yaml またはルートの package.json に記述
pnpm:
packageExtensions:
# 依存が定義されていない古いライブラリに、強制的に依存関係を付与
legacy-package@1.0.0:
dependencies:
missing-peer-dep: “”
3. Dockerパイプライン:マルチステージビルドの最適化ハック
CI/CDにおける `pnpm install` は、単なるパッケージの展開ではない。コンテンツアドレサブルストレージの恩恵を最大限に受けるため、Dockerのレイヤーキャッシュを極限まで活用する。
Dockerfile
FROM node:20-slim AS base
RUN npm install -g pnpm
依存関係定義のみを先にコピーし、インストールすることでキャッシュ効率を最大化
COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm fetch
ソースコードをコピーしてビルド
COPY . .
RUN pnpm install –offline –frozen-lockfile –no-hoist
アーキテクトの知見: `–offline` オプションと `pnpm fetch` の組み合わせにより、ネットワークI/Oを排除し、ビルドの完全な決定論を実現する。これにより、CI環境ごとの依存関係の揺らぎを物理的に排除できる。
4. 実行時オーバーヘッドとメモリ消費の最適化
`–no-hoist` を使用すると、`node_modules` 内のシンボリックリンクの数が増加する。これはOSのファイルシステム走査回数に影響する可能性がある。
- ハック: 大規模なモノレポでは、`node_modules` の探索を高速化するために、ビルド時のみ `PNPM_HOME` をRAMディスク(`/dev/shm`)にマウントする手法が有効だ。
- 監視: `pnpm store status` を定期的に実行し、依存グラフに「不法侵入者」が存在しないか、CIのビルドステップで検証スクリプトを走らせる。
依存関係の健全性をチェックするカスタムスクリプト(CI用)
許可されていないhoistedパッケージがないかを走査する
npx depcheck –ignores=”eslint-plugin-,@types/”
if [ $? -ne 0 ]; then
echo “Error: Unresolved dependencies detected. Architecture violation!”
exit 1
fi
結びに:真のDevOpsエンジニアへ
「便利だから」という理由でデフォルト設定を使い続けるのは、アーキテクトの仕事ではない。我々の使命は、「システムが壊れる余地を極限まで排除する」ことにある。
`–no-hoist` は、一見すると開発体験を厳しくする選択に思えるかもしれない。しかし、その先に待っているのは、「ローカルとCI、そして本番環境で、依存関係が1ビットの狂いもなく完全に一致する」という、究極の安定性である。
依存関係を制御せよ。さもなくば、依存関係に制御されることになる。