未来を形作るテクノロジーの深掘り記事。

Node.jsに仮想ファイルシステムが必要な理由

Node.jsは実ファイルシステムに強く依存しています。仮想ファイルシステム層があれば、テスト、サンドボックス化、エッジ実行が改善します。

ホログラムのフォルダが宙に浮かぶ中、紙のフォルダをつかむ機械の腕

実ファイルシステムのない環境でNode.jsアプリケーションを動かしてみると、すぐに破綻することがわかります。require()はファイルを読みます。fs.readFile()もファイルを読みます。テンプレートエンジンも、設定ローダーも、ファイルを読みます。ログライブラリはファイルに書き込みます。Node.jsのエコシステム全体が、POSIX風のファイルシステムが存在し、書き込み可能で、コードやデータにアクセスする主要な手段であることを前提にしています。

これは、Node.jsがローカルディスクを持つサーバーで動いていた2010年ごろなら問題ありませんでした。しかし今では、ファイルシステムが存在しない、読み取り専用である、あるいは信頼できない場所でもJavaScriptを動かしたい、という要求が増えており、問題が大きくなっています。エッジランタイム、WebAssemblyサンドボックス、サーバーレス関数、ブラウザベースの開発環境などがその例です。

ファイルシステム前提が崩れる場面

エッジランタイム。 Cloudflare Workers、Deno Deploy、Vercel Edge Functionsは、ユーザーの近くにある最小限のインフラの上でJavaScriptを実行します。こうした環境では書き込み可能なファイルシステムが提供されていないことが多く、提供されていても、リクエストをまたいで永続化されないインメモリの限定的なファイルシステムであることがよくあります。fs.writeFileSyncを呼び出すNode.jsモジュールはクラッシュします。設定ファイルの存在確認にfs.existsSyncを使っているモジュールは、挙動が予測できなくなります。

WebAssembly。 Node.jsをWebAssemblyサンドボックス内で動かすには、ファイルシステム操作をWasmランタイムの仮想ファイルシステム(通常はWASI)へマッピングする必要があります。このマッピングは不完全で、ファイルのパーミッション、シンボリックリンク、プラットフォーム固有のパスはきれいに変換できません。VFSの抽象化があれば、このマッピングを場当たり的にではなく、明示的に行えるようになります。

テスト。 設定ファイルの読み込み、テンプレートの読み込み、ログの書き込みを行うコードのユニットテストには、fsモジュールのモック(ライブラリごとに異なるfs APIを使うため脆い)か、テスト用フィクスチャを置く一時ディレクトリの作成(遅い、不安定、ディスクにゴミが残る)が必要です。仮想ファイルシステムがあれば、テストを完全にメモリ上で、決定的に、実ファイルシステムに触れずに実行できます。

セキュリティのサンドボックス化。 プラグイン、ユーザースクリプト、CI/CDのビルドステップなど、信頼できないコードを実行する場合、ファイルシステムへのアクセスを制御したくなります。現状では、OSレベルのサンドボックス(コンテナ、名前空間)を使うか、fsモジュールをモンキーパッチするしかありません。VFSがあれば、「このコードは/appは読めるが/etcは読めない」「/tmpには書けるが/appには書けない」といったきめ細かなファイルシステムポリシーを設定できます。

VFSはどんな姿になるか

仮想ファイルシステムは新しい概念ではありません。オペレーティングシステムは1980年代からVFS層を使ってきました。LinuxのVFSでは、ext4、NFS、procfsが同じAPIの裏で共存しています。Node.jsにおける考え方も同じです。ファイルシステムのインターフェース(fs.readFile、require()など)と、ファイルシステムの実装を分離するのです。

// Hypothetical VFS API
import { createVFS, MemoryFS, ReadOnlyFS, OverlayFS } from 'node:vfs';
// In-memory filesystem for testing
const testFS = new MemoryFS({
'/app/config.json': '{"port": 3000}',
'/app/templates/index.html': '<h1>Hello</h1>',
});
// Read-only view of the real filesystem
const readOnly = new ReadOnlyFS('/');
// Overlay: reads from real FS, writes go to memory
const sandbox = new OverlayFS(readOnly, new MemoryFS());
// Run code with a specific filesystem
const vfs = createVFS(sandbox);
vfs.run(() => {
// Inside this context:
// fs.readFileSync('/etc/hosts') → reads real file
// fs.writeFileSync('/tmp/log.txt') → writes to memory overlay
// require('./module') → resolves against the VFS
const app = require('./app');
app.start();
});

重要なポイントは、VFSが明示的なfs呼び出しだけでなく、すべてのファイルシステムアクセスを横取りする点です。require()、import()、__dirname、process.cwd()も対象になります。これが有用な理由は、アプリケーションを変更せずに、アプリケーション全体のファイルシステムアクセスを振り向けられることにあります。

モジュール解決という問題

Node.jsとファイルシステムの最も深い絡み合いはモジュール解決です。require('express')と書くと、Node.jsはディレクトリツリーを上へたどりながらnode_modules/expressディレクトリを探し、package.jsonファイルを読み、シンボリックリンクを解決し、mainまたはexportsフィールドをたどります。この処理では、1回のrequireで数十ものファイルシステム呼び出しが発生します。

Yarn PnP(Plug'n'Play)は、node_modulesをモジュール名とzipアーカイブを対応づけるマニフェストファイルに置き換えることで、この問題を部分的に解決しました。ディレクトリ走査が不要なので高速で、ホイスティングによる驚きもないため決定的ですが、Nodeのモジュール解決をモンキーパッチする必要があり、node_modulesの存在を前提にしていたツールが壊れました。

適切なVFSがあれば、Yarn PnPのアプローチを第一級の機能にできます。モジュール解決はVFSを経由し、zipアーカイブ、インメモリモジュール、リモートURL、あるいは従来のnode_modulesディレクトリ走査など、任意のマッピングを実装できます。環境ごとに異なる戦略を使いつつ、アプリケーションコードからは同じAPIを使えるようになります。

先行事例

他のランタイムは、この問題のバリエーションをすでに解決しています。

  • Denoは、デフォルトでURLからモジュールを読み込み、管理されたディレクトリにキャッシュします。node_modulesもファイルシステムベースのモジュール解決もありません。モジュールをどこに、どのように保存するかはランタイムが制御します。
  • Bunは、ハードリンクを使ったグローバルなモジュールキャッシュを採用し、ファイルシステムのオーバーヘッドを減らしています。そのモジュール解決は、Node.jsと比べて大幅に最適化されています。
  • Goのio/fsインターフェース(Go 1.16で追加)は、OSのファイルシステム、埋め込みファイル(embed.FS)、zipアーカイブ、インメモリのファイルシステムに実装されるファイルシステムインターフェースを定義しています。標準ライブラリの関数はfs.FSインターフェースを受け取るため、実ファイルなしでテストできます。
  • JavaのNIO FileSystem抽象化は、プラガブルなファイルシステムプロバイダーに対応しています。テスト用のインメモリ実装や、zipベースのファイルシステムも含まれます。
  • .NETのIFileSystemパターンは、テスタビリティのために.NETアプリケーションで広く使われていますが、ランタイムの機能ではなくコミュニティの慣習です。

Goのアプローチは特に示唆に富んでいます。小さなインターフェース(Open、Read、Stat)を定義し、それを標準ライブラリ全体で使うことで、既存のコードを壊さずにファイルシステムの抽象化を非常に簡単なものにしました。Node.jsも同じことができます。VFSインターフェースを定義し、標準ライブラリとモジュールローダーを段階的にそれを使うよう移行すればよいのです。

互換性という課題

Node.jsのVFSにとって最大の障壁は実装ではなくエコシステムです。npmには200万を超えるパッケージがあり、その相当数が、POSIXのセマンティクス、実パス、書き込み可能なディレクトリを前提にした方法でファイルシステムにアクセスしています。

既存のパッケージを壊すVFSは、登場した時点で失敗します。オプトイン式で後方互換性がなければなりません。VFSを明示的に作成しなければ、今日と全く同じように動作すべきです。VFSを作成した場合は、ファイルシステム呼び出しを透過的に横取りし、行儀のよいパッケージが変更なしで動くようにすべきです。

重要なのは「行儀のよい」という条件です。cpやrmを外部コマンドとして呼び出すパッケージ、libcのopen()を直接呼ぶネイティブアドオン、/procやその他のOS固有のファイルシステムに依存するパッケージは、VFSでは動きません。これは越えられない境界です。コードが基盤システムにまで手を伸ばせば、どんな抽象化も必ず漏れ出します。

実際に提案されていること

Node.jsのVFSに関する議論は理論的なものではなく、Node.jsのissueトラッカーには具体的な提案があります。現在の方向性には、いくつかの重要なアイデアが含まれています。

第一に、fsモジュールが処理を委譲するFileSystemProviderインターフェースです。デフォルトのプロバイダーは実ファイルシステムです。カスタムプロバイダーは、同じインターフェースを実装して、インメモリ、読み取り専用、オーバーレイ、リモートのファイルシステムを提供します。

第二に、モジュールローダーとの統合です。require()とimport()はVFSを経由してモジュールを解決するようになり、node_modulesディレクトリに依存しないモジュール解決戦略が可能になります。

第三に、ポリシーレイヤーです。VFSはアクセス制御を強制し、定義された範囲外のパスの読み書きを防ぐことができます。これにより、Denoの--allow-readと--allow-writeフラグに似た、Node.js組み込みのサンドボックス機能が得られます。

これがNode.js 24や26に入るのか、あるいはいつ入るのかは、Node.jsチームの人員と、ファイルシステムアクセスの仕組みに関する破壊的変更へのコミュニティの受容度次第です。ただ、プレッシャーは現実のものです。エッジランタイムは成長しており、WebAssemblyのデプロイ先は増えています。「どこでもJavaScript」と「実ファイルシステムを持つサーバー上のNode.js」の間のギャップは、競争上の弱点になりつつあります。

今すぐできること

ランタイムのVFSを待たなくても、ファイルシステムの抽象化の恩恵は受けられます。新しいコードであれば、ファイルシステムへのアクセスをインターフェースの裏に隠しましょう。fs.readFileを直接呼ぶ代わりに、コードが依存するFileStoreインターフェースを作ります。本番では実ファイルシステムで、テストではインメモリのマップで実装します。これは標準的な依存性逆転であり、地味ですが効果的で、どのランタイムとも互換性があります。

既存のコードには、memfsやunionfsといったライブラリが使えます。これらはNodeのfsモジュールにパッチを当てて、インメモリおよびオーバーレイのファイルシステム実装を提供します。完璧ではありません。ネイティブアドオンや子プロセスはこれを迂回してしまいます。しかし純粋なJavaScriptのコードでは動作し、テストを劇的に楽にしてくれます。

ファイルシステムは、Node.jsインフラの中で、まだきれいな抽象化の境界を持たない最後の大きな要素です。ストリームにはインターフェースがあり、HTTPにもインターフェースがあります。モジュールローダーでさえフックを持っています。ファイルシステムにも同じ扱いがなされたとき、Node.jsは特定のサーバー向けではなく、本当にポータブルなランタイムになるでしょう。それは押し進める価値のある変化です。