動的インポートによるアプリケーション起動プロセスのリファクタリング#
はじめに#
Misskey バックエンドは、Node.js の cluster モジュールを使用してマスタープロセスとワーカープロセスに分割された分散アーキテクチャで動作します。従来の実装では、プロセスの役割に関係なく、すべてのプロセスが同じモジュールグラフを読み込んでいました。これにより、マスタープロセスがワーカーをスポーンする際に、不要なサーバーサイドコードまで含めた完全なモジュールグラフが再インポートされ、コールドスタート時間が大幅に増加していました。
PR #1410 "enhance: 起動から listen までかかる時間を減らす"(2026 年 1 月 24 日マージ)は、この問題を解決するために動的インポート(dynamic imports)を導入し、マスターとワーカーのプロセスコードを分離しました。この最適化により、各プロセスタイプに必要なコードのみが読み込まれるようになり、クラスター環境でのコールドスタート時間が大幅に短縮されました。
本ドキュメントでは、このリファクタリングの実装詳細、パフォーマンスへの影響、およびバックエンドサーバーとジョブキューの起動パフォーマンスの改善について解説します。
実装の主要な変更点#
1. エントリーポイントのリファクタリング (packages/backend/src/boot/entry.ts)#
エントリーポイントは、cluster.isPrimaryとcluster.isWorkerの条件分岐と動的インポートを使用して、各プロセスタイプに必要なコードのみを読み込みます:
if (cluster.isPrimary || envOption.disableClustering) {
// NOTE: Avoid loading worker-side code in the master process (and vice-versa).
// This reduces cold-start time in clustered environments where spawning a worker
// would otherwise re-import the full master/server module graph.
const { masterMain } = await import('./master.js');
await masterMain();
if (cluster.isPrimary) {
ev.mount();
}
}
if (cluster.isWorker) {
const { workerMain } = await import('./worker.js');
await workerMain();
}
主要な利点:
- マスタープロセスは
master.jsのみを読み込む - ワーカープロセスは
worker.jsのみを読み込む - ワーカーをスポーンする際の冗長なモジュールグラフの読み込みを防止
- ESM 互換性のために
.js拡張子を使用
この実装の核心は、コード内のコメントで明確に説明されているように、「マスタープロセスでワーカー側のコードを読み込まない(およびその逆)」ことです。これにより、クラスター環境でワーカーをスポーンする際に、完全なマスター / サーバーモジュールグラフが再インポートされることを回避し、コールドスタート時間を削減します。
2. 共通モジュールの最適化 (packages/backend/src/boot/common.ts)#
common.tsのserver()とjobQueue()の両関数は、動的インポートを使用して重いモジュールの読み込みを遅延させます。
server() 関数#
export async function server() {
const { MainModule } = await import('../MainModule.js');
const { ServerService } = await import('../server/ServerService.js');
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
app.enableShutdownHooks();
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start();
app.get(QueueStatsService).start();
}
return app;
}
この関数は、HTTP サーバーの起動に必要なモジュールのみを、実際に関数が呼び出されたときに動的に読み込みます。
jobQueue() 関数#
export async function jobQueue() {
const { QueueProcessorModule } = await import('../queue/QueueProcessorModule.js');
const { QueueProcessorService } = await import('../queue/QueueProcessorService.js');
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(),
});
jobQueue.enableShutdownHooks();
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
return jobQueue;
}
同様に、ジョブキュー処理に必要なモジュールのみを遅延読み込みします。
遅延読み込みされるモジュール:
MainModule- サーバーの依存関係ServerService- HTTP サーバーQueueProcessorModule- ジョブキューの依存関係QueueProcessorService- ジョブ処理ChartManagementService- メトリクス / 分析QueueStatsService- キュー監視
両関数は同じパターンに従います:
- 関数の先頭で必要なモジュールを
await import()で動的にインポート - NestJS アプリケーションコンテキストを作成
- サービスを初期化して起動
- アプリケーションコンテキストを返す
この設計により、重い NestJS モジュール(MainModule、QueueProcessorModule)は、それぞれの関数が実際に実行されるときにのみ読み込まれ、モジュール評価時には読み込まれません。
プロセス別の起動動作#
マスタープロセス (master.ts)#
マスタープロセスは、環境オプションに基づいてサービスを条件付きで起動します:
if (envOption.disableClustering) {
if (envOption.onlyServer) {
await server();
} else if (envOption.onlyQueue) {
await jobQueue();
} else {
await server();
await jobQueue();
}
} else {
if (envOption.onlyServer) {
// nop
} else if (envOption.onlyQueue) {
// nop
} else {
await server();
}
await spawnWorkers(config.clusterLimit);
}
起動シナリオ:
- クラスターモード(デフォルト): マスターが
server()を起動してから、jobQueue()用のワーカーをスポーン - シングルプロセスモード: マスターが
server()とjobQueue()の両方を実行 - サーバー専用モード: HTTP サーバーのみ、ジョブ処理なし
- キュー専用モード: ジョブ処理のみ、HTTP サーバーなし
ワーカープロセス (worker.ts)#
ワーカープロセスはジョブキュー処理を担当します:
if (envOption.onlyServer) {
await server();
} else if (envOption.onlyQueue) {
await jobQueue();
} else {
await jobQueue();
}
デフォルト動作: ワーカーは通常、デフォルトでjobQueue()を実行し、サーバーサイドモジュールの読み込みを回避します。
パフォーマンスへの影響#
コールドスタート時間の削減#
PR #1410によれば、このリファクタリングは起動から listen までかかる時間を著しく短縮します。
動的インポートがコールドスタート時間を削減する仕組み:
1. 冗長なモジュール読み込みの防止#
従来の実装では、クラスターモードでマスターがワーカーをスポーンする際、各ワーカープロセスがサーバーサイドコードを含む完全なモジュールグラフを再インポートしていました。動的インポートにより、ワーカーはworker.jsとジョブキューの依存関係のみを読み込むようになりました。
これは、Node.js の cluster モジュールの動作原理に起因します。クラスターでワーカーをスポーンすると、新しいプロセスが作成され、同じエントリーポイントから実行が開始されます。従来の静的インポートでは、すべてのモジュールが評価時に読み込まれていたため、ワーカープロセスでも不要なサーバーモジュールが読み込まれていました。
2. 重いモジュール評価の遅延#
NestJS モジュール(MainModuleやQueueProcessorModule)は、それぞれの関数が呼び出されたときにのみ読み込まれ、モジュール評価時には読み込まれません。
NestJS モジュールは、依存性注入コンテナの初期化、プロバイダーの登録、メタデータの処理など、多くの重い処理を伴います。これらを遅延させることで、初期起動時のオーバーヘッドが大幅に削減されます。
3. プロセス固有のコードパス#
マスターとワーカープロセスは完全に分離されたコードブランチを読み込むため、依存関係の相互汚染を回避します。各プロセスは、その役割に必要な最小限のコードのみをメモリに保持します。
バックエンドサーバーの起動パフォーマンス#
server() 関数は、遅延読み込みにより以下の恩恵を受けます:
- 条件付きモジュール読み込み:
ServerServiceおよび HTTP 関連の依存関係は、必要なときにのみ読み込まれます - クラスターモードでの最適化: ワーカープロセスはサーバーモジュールを一切読み込まないため、メモリ使用量が大幅に削減されます
- 段階的な初期化:
ChartManagementServiceとQueueStatsServiceの初期化は、サーバー起動後まで遅延されます
この設計により、クラスター環境では以下のような起動フローが実現されます:
- マスタープロセスが起動し、
entry.tsでmaster.jsのみをインポート - マスターが
server()を呼び出し、その時点で初めてMainModuleとServerServiceがインポートされる - HTTP サーバーが起動し、リクエストの受け付けを開始
- ワーカーがスポーンされるが、サーバー関連のコードは一切読み込まない
ジョブキューの起動パフォーマンス#
jobQueue() 関数も同様の最適化を実現します:
- キュー固有のモジュール読み込み:
QueueProcessorModuleの依存関係は、キュー起動時にのみ読み込まれます - プロセス分離: クラスターモードでは、マスターはキューモジュールを読み込まず(ワーカーが処理)、HTTP サーバーのメモリフットプリントを削減
- 独立した NestJS コンテキスト: ジョブキュー処理を HTTP サーバーから分離した独立したアプリケーションコンテキストで実行
ジョブキューの起動フローは以下のようになります:
- ワーカープロセスが起動し、
entry.tsでworker.jsのみをインポート - ワーカーが
jobQueue()を呼び出し、その時点で初めてQueueProcessorModuleとQueueProcessorServiceがインポートされる - ジョブキュー処理が開始され、バックグラウンドタスクの実行を開始
- HTTP サーバー関連のコードは一切読み込まれず、メモリを節約
アーキテクチャ図#
以下の図は、動的インポートによるプロセス分離を示しています:
関連する最適化#
このリファクタリングは、Misskey コードベース全体で採用されている動的インポートパターンの一部です:
PR #234(2023 年 11 月)- クラスター環境での起動ロジック修正#
PR #234は、workerMain()がcluster.isWorkerが真の場合にのみ呼び出されるように起動ロジックを修正しました。これにより、プロセス分離のためのより明確な基盤が整備されました。
PR #1163(2025 年 11 月)- AI サービスでの遅延読み込みパターン#
PR #1163は、AI サービスでnsfwjsライブラリの遅延読み込みを実装しました。静的インポートを動的インポート(const nsfw = await import('nsfwjs'))に置き換え、ライブラリがアプリケーション起動時ではなく、実際に検出リクエストが必要なときにのみ読み込まれるようにしました。
これは、PR #1410 と同じ設計原則を示しています:起動時のオーバーヘッドとメモリフットプリントを削減するために、重い依存関係を遅延読み込みするというパターンです。
まとめ#
動的インポートを使用した起動プロセスのリファクタリングは、Misskey バックエンドのパフォーマンスを大幅に向上させました。主な成果は以下の通りです:
-
プロセス分離の実現:
cluster.isPrimaryとcluster.isWorkerの条件分岐により、各プロセスが必要なコードのみを読み込むようになりました -
コールドスタート時間の短縮: ワーカーのスポーン時に完全なモジュールグラフが再インポートされなくなり、クラスター環境での起動時間が著しく短縮されました
-
モジュール読み込みの最適化:
server()とjobQueue()関数内での動的インポートにより、重い NestJS モジュールの評価が実際に必要になるまで遅延されます -
メモリ効率の向上: 各プロセスが役割に必要な最小限のコードのみをメモリに保持することで、リソース消費が削減されました
-
柔軟な起動構成: 環境オプションにより、サーバー専用、キュー専用、またはその両方のモードで実行できる柔軟性が提供されます
このアーキテクチャパターンは、大規模な Node.js アプリケーションにおけるクラスター環境でのパフォーマンス最適化のベストプラクティスを示しています。動的インポートとプロセス分離を組み合わせることで、スケーラビリティと効率性の両方を達成できます。