NodeJS 速習チュートリアル

Node.js パフォーマンス診断

1. パフォーマンスが重要な理由

Node.js は、パフォーマンスの問題を診断するためのさまざまなツールやテクニックを提供しています。
本ガイドでは、包括的なパフォーマンス分析のために、組み込みツールから人気のサードパーティ製ソリューションまでをカバーします。

       パフォーマンス向上の鉄則: 最適化の前に必ず「計測」すること。

当てずっぽうで修正するのではなく、このガイドで紹介する手法を用いて実際のボトルネックを特定してください。

2. Node.js のパフォーマンスを理解する

Node.js アプリケーションのパフォーマンスは、主に以下の要因によって影響を受けます。

  • CPU 集約的な操作: イベントループ(Event Loop)をブロックする処理
  • メモリリーク (Memory Leaks): 過度なガベージコレクション(Garbage Collection)の発生
  • I/O ボトルネック: データベースクエリ、ファイル操作、ネットワークリクエストの遅延
  • 非効率なコードとアルゴリズム
  • イベントループの混雑 (Congestion)

これらの問題を診断するには、メソッド論に基づいたアプローチと適切なツールの選択が必要です。

3. 組み込みの診断ツール

3.1 console.time() と console.timeEnd()

操作にかかる時間を計測する最もシンプルな方法です。

// 実行時間を計測
console.time('operation');

// 計測対象の操作
const array = Array(1000000).fill().map((_, i) => i);
array.sort((a, b) => b - a);

console.timeEnd('operation');
// 出力例: operation: 123.45ms

3.2 プロセス統計 (Process Statistics)

Node.js は、グローバルな process オブジェクトを通じてプロセス統計へのアクセスを提供します。

// メモリ使用率
const memoryUsage = process.memoryUsage();
console.log('メモリ使用率:');
console.log(` RSS: ${Math.round(memoryUsage.rss / 1024 / 1024)} MB`);
console.log(` Heap Total: ${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`);
console.log(` Heap Used: ${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`);
console.log(` External: ${Math.round(memoryUsage.external / 1024 / 1024)} MB`);

// CPU 使用率
const startUsage = process.cpuUsage();

// CPU 負荷をシミュレート
const now = Date.now();
while (Date.now() - now < 500); // 500ms のビジーウェイト

const endUsage = process.cpuUsage(startUsage);
console.log('CPU 使用率:');
console.log(` User: ${endUsage.user / 1000}ms`);
console.log(` System: ${endUsage.system / 1000}ms`);

// アップタイム
console.log(`プロセスの稼働時間: ${process.uptime().toFixed(2)} 秒`);

3.3 Node.js Performance Hooks

Node.js 8.5.0 以降、perf_hooks モジュールによって高精度な計測ツールが利用可能になりました。

const { performance, PerformanceObserver } = require('perf_hooks');

// パフォーマンスオブザーバーの作成
const obs = new PerformanceObserver((items) => {
  items.getEntries().forEach((entry) => {
    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
  });
});

// パフォーマンスイベントを購読
obs.observe({ entryTypes: ['measure'] });

// 操作の開始をマーク
performance.mark('start');

// 何らかの処理をシミュレート
const data = [];
for (let i = 0; i < 1000000; i++) {
  data.push(i * i);
}

// 終了をマークして計測
performance.mark('end');
performance.measure('データ処理時間', 'start', 'end');

// マークのクリア
performance.clearMarks();

4. 高度な CPU プロファイリング

CPU プロファイリング(Profiling)を行うべきケース:

  • 過度な CPU 時間を消費している「ホットな関数」の特定
  • 同期処理コード内での最適化ポイントの発見
  • イベントループをブロックしている操作の分析
  • 最適化前後でのパフォーマンス比較

4.1 ソースマップを活用した V8 プロファイラ

TypeScript やトランスパイルされた JavaScript を使用している場合、意味のあるプロファイリング結果を得るためにソースマップ(Source Maps)が不可欠です。

const v8Profiler = require('v8-profiler-node8');
const fs = require('fs');
const path = require('path');

// 正確なプロファイリングのためにソースマップサポートを有効化
require('source-map-support').install();

// ソースマップを考慮した CPU プロファイリングを開始
v8Profiler.setGenerateType(1); // 型情報を含める
const profile = v8Profiler.startProfiling('CPU profile', true);

// プロファイリング対象のコード
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// CPU 負荷と I/O 処理の両方をシミュレート
function processData() {
  const start = Date.now();
  fibonacci(35);
  console.log(`CPU 処理にかかった時間: ${Date.now() - start}ms`);

  // 非同期処理のシミュレート
  setImmediate(() => {
    const asyncStart = Date.now();
    fibonacci(30);
    console.log(`非同期処理にかかった時間: ${Date.now() - asyncStart}ms`);
  });
}

processData();

// 非同期処理の完了後にプロファイリングを停止
setTimeout(() => {
  const profile = v8Profiler.stopProfiling('CPU profile');
  profile.export((error, result) => {
    const filename = path.join(__dirname, 'profile.cpuprofile');
    fs.writeFileSync(filename, result);
    console.log(`CPU プロファイルが ${filename} に保存されました`);
    profile.delete();
  });
}, 1000);

上記の例を使用するには、パッケージをインストールしてください:
npm install v8-profiler-node8

生成された .cpuprofile ファイルは、Chrome DevTools に読み込んで視覚化できます。

4.2 Node.js 組み込みのプロファイリング

Node.js には、コマンドラインフラグからアクセスできる組み込みのプロファイリング機能があります。

# プロファイリングを有効にしてアプリケーションを起動
node --prof app.js

# 生成されたログファイルを処理
node --prof-process isolate-0xNNNNNNNN-NNNN-v8.log > processed.txt

5. 高度なメモリプロファイリング

       メモリリーク検出のコツ: 異なる時間に取得した複数のヒープスナップショット(Heap Snapshots)を比較し、期待通りにガベージコレクションされていないオブジェクトを特定します。

5.1 Chrome DevTools によるヒープスナップショット

ヒープスナップショットは、特定の瞬間のメモリ状態をキャプチャし、メモリリークの特定に役立ちます。

const heapdump = require('heapdump');
const fs = require('fs');
const path = require('path');

// リークする可能性のあるデータを生成
let leakyData = [];
function potentiallyLeaky() {
  const data = {
    id: Date.now(),
    content: Array(1000).fill('potentially leaky data'),
    timestamp: new Date().toISOString()
  };
  leakyData.push(data);
}

// 異なる保持パターンでメモリリークをシミュレート
setInterval(() => {
  potentiallyLeaky();
  // 部分的なリークをシミュレートするため、直近 100 件のみ残す
  if (leakyData.length > 100) {
    leakyData = leakyData.slice(-100);
  }
}, 100);

// 一定間隔でヒープスナップショットを取得
function takeHeapSnapshot(prefix) {
  const filename = path.join(__dirname, `${prefix}-${Date.now()}.heapsnapshot`);
  heapdump.writeSnapshot(filename, (err, filename) => {
    if (err) {
      console.error('ヒープスナップショットの取得に失敗しました:', err);
    } else {
      console.log(`ヒープスナップショットが ${filename} に保存されました`);
    }
  });
}

// 初回のスナップショット
takeHeapSnapshot('heap-initial');

// 定期的にスナップショットを取得
setInterval(() => {
  takeHeapSnapshot('heap-periodic');
}, 10000);

// 最終スナップショットの前に強制的にガベージコレクションを実行
setTimeout(() => {
  if (global.gc) {
    global.gc();
    console.log('ガベージコレクションを強制実行しました');
  }
  takeHeapSnapshot('heap-final');
}, 30000);

上記の例を使用するには、パッケージをインストールしてください:
npm install heapdump

ヒープスナップショットは、Chrome DevTools で分析してメモリリークの場所を突き止めることができます。

6. イベントループとレイテンシの分析

監視すべきイベントループのメトリクス:

  • イベントループの遅延 (Lag): 各ループのティック間にかかる時間
  • アクティブなハンドル (Handles) とリクエスト
  • 保留中の非同期操作 (Pending async operations)
  • ガベージコレクションによる一時停止

イベントループは Node.js パフォーマンスの核心です。ここをブロックすると全体が劣化します。

const toobusy = require('toobusy-js');
const http = require('http');

// しきい値の設定 (ミリ秒)
toobusy.maxLag(100);  // サーバーが「忙しすぎる」と判断する最大の遅延
toobusy.interval(500); // イベントループの遅延をチェックする間隔

const server = http.createServer((req, res) => {
  // イベントループが過負荷かどうかをチェック
  if (toobusy()) {
    res.statusCode = 503; // Service Unavailable
    res.setHeader('Retry-After', '10');
    return res.end(JSON.stringify({
      error: 'サーバーが混雑しています',
      message: '後ほど再試行してください',
      status: 503
    }));
  }

  if (req.url === '/compute') {
    // CPU 集約的な処理
    let sum = 0;
    for (let i = 0; i < 1e7; i++) {
      sum += Math.random();
    }
    res.end(`計算結果: ${sum}`);
  } else {
    res.end('OK');
  }
});

server.listen(3000, () => {
  console.log('サーバーがポート 3000 で起動しました');
});

// 遅延とメモリ使用率を監視
setInterval(() => {
  const lag = toobusy.lag();
  const mem = process.memoryUsage();
  console.log(`イベントループ遅延: ${lag}ms`);
  console.log(`メモリ使用率: ${Math.round(mem.heapUsed / 1024 / 1024)}MB / ${Math.round(mem.heapTotal / 1024 / 1024)}MB`);
}, 1000);

7. フレームグラフ (Flame Graphs)

フレームグラフは CPU サンプリングの結果を視覚的に表現し、アプリケーションのどこで時間が費やされているかを一目で特定するのに役立ちます。

# フレームグラフ生成ツール 0x をグローバルインストール
npm install -g 0x

# 0x でアプリケーションを実行
0x app.js

# プロセス終了時、フレームグラフの視覚化画面がブラウザで開きます

8. ベンチマーク (Benchmarking)

ベンチマークによって、異なる実装を比較し、最も効率的なものを選択できます。

const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;

suite
  .add('RegExp#test', function() {
    /o/.test('Hello World!');
  })
  .add('String#indexOf', function() {
    'Hello World!'.indexOf('o') > -1;
  })
  .add('String#includes', function() {
    'Hello World!'.includes('o');
  })
  .on('cycle', function(event) {
    console.log(String(event.target));
  })
  .on('complete', function() {
    console.log('最速は ' + this.filter('fastest').map('name'));
  })
  .run({ 'async': true });

9. Node.js インスペクタ (Node.js Inspector)

Node.js には統合デバッガとプロファイラが内蔵されており、Chrome DevTools からアクセス可能です。

# インスペクタを有効にして起動
node --inspect app.js

# 起動直後にブレーク(デバッグ用)
node --inspect-brk app.js

Chrome を開き、chrome://inspect にアクセスすると、以下が利用可能です:

  • CPU プロファイラ
  • ヒープスナップショット
  • メモリ割り当てタイムライン
  • デバッガ

10. Clinic.js スイート (Clinic.js Suite)

Clinic.js は、パフォーマンス問題を診断するための強力なツール群です。

# Clinic.js のインストール
npm install -g clinic

# Doctor で全体的な問題を特定
clinic doctor -- node app.js

# Flame で CPU フレームグラフを生成
clinic flame -- node app.js

# Bubbleprof で非同期操作を分析
clinic bubbleprof -- node app.js

11. 実践的なパフォーマンス診断

11.1 ステップ 1: ベースラインメトリクスの確立

最適化の前に、アプリケーションの基準(ベースライン)を計測します。

const autocannon = require('autocannon');
const { writeFileSync } = require('fs');

const result = autocannon({
  url: 'http://localhost:8080',
  connections: 100,
  duration: 10
});

result.on('done', (results) => {
  console.log('ベースラインパフォーマンスメトリクス:');
  console.log(` リクエスト/秒: ${results.requests.average}`);
  console.log(` レイテンシ: ${results.latency.average}ms`);

  writeFileSync('baseline-metrics.json', JSON.stringify(results, null, 2));
});

11.2 ステップ 2: ボトルネックの特定

プロファイリングを駆使して原因を特定します:

  • 計算量の多い処理には CPU プロファイリング
  • メモリ増加には メモリショット
  • コールスタックの分析には フレームグラフ
  • I/O 遅延には イベントループ監視

11.3 ステップ 3: 修正と検証

修正後、再度計測を行い、ベースラインと比較して改善されたか確認します。

12. 一般的なパフォーマンスの問題と解決策

12.1 メモリリーク (Memory Leaks)

  • 兆候: 時間の経過とともにメモリ使用量が増え続け、一定にならない。
  • 解決策:
    • ヒープスナップショットを時間差で取得して比較する
    • グローバル変数、イベントリスナー、参照を保持し続けるクロージャをチェックする
    • オブジェクトが不要になったら適切にクリーンアップする

12.2 長時間実行される操作 (Long-Running Operations)

  • 兆候: 高いイベントループ遅延、不安定なレスポンスタイム。
  • 解決策:
    • CPU 集約的な処理は Worker Threads(ワーカーツール)へ移行する
    • setImmediateprocess.nextTick を使い、大きなタスクを細分化する
    • 専用の外部マイクロサービスへのオフロードを検討する

12.3 非効率なデータベースクエリ

  • 兆候: 全体的なレスポンスが遅く、高レイテンシ。
  • 解決策:
    • データベース操作のプロファイリングを行う
    • 適切なインデックス(Indexing)によるクエリ最適化
    • コネクションプーリングの使用
    • 頻繁にアクセスされるデータへのキャッシュ(Caching)実装