Node.jsアプリケーションを本番運用する際、パフォーマンス問題やメモリリークは避けて通れない課題です。この記事では、Node.jsに組み込まれたプロファイリングツールと、サードパーティのclinic.jsを活用して、パフォーマンスボトルネックやメモリリークを特定・解決する方法を解説します。

実行環境と前提条件

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

この記事では、JavaScriptの基礎知識とNode.jsの基本API(process、Buffer等)を理解していることを前提としています。

CPUプロファイリングの基礎

Node.jsには、V8エンジンの組み込みプロファイラを利用したCPUプロファイリング機能があります。この機能を使うことで、アプリケーションのどの部分がCPU時間を消費しているかを特定できます。

–profフラグによるプロファイリング

--profフラグを付けてNode.jsを起動すると、V8プロファイラがスタックを定期的にサンプリングし、結果をログファイルに記録します。

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

実行後、カレントディレクトリにisolate-0xnnnnnnnnnnnn-v8.logという形式のファイルが生成されます。

–prof-processによるログ解析

生成されたログファイルは人間が読める形式ではありません。--prof-processフラグを使って解析可能な形式に変換します。

1
2
# プロファイルログを解析して読みやすい形式に変換
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

プロファイル結果の読み方

解析結果には複数のセクションが含まれます。まず確認すべきは[Summary]セクションです。

1
2
3
4
5
6
7
[Summary]:
   ticks  total  nonlib   name
     79    0.2%    0.2%  JavaScript
  36703   97.2%   99.2%  C++
      7    0.0%    0.0%  GC
    767    2.0%          Shared libraries
    215    0.6%          Unaccounted

この例では、97.2%のサンプルがC++コードで発生しています。これは、暗号化処理やファイルI/OなどのネイティブAPIが多く使用されていることを示しています。

[C++]セクションでは、どのC++関数がCPU時間を消費しているかを確認できます。

1
2
3
4
5
[C++]:
   ticks  total  nonlib   name
  19557   51.8%   52.9%  node::crypto::PBKDF2(...)
   4510   11.9%   12.2%  _sha1_block_data_order
   3165    8.4%    8.6%  _malloc_zone_malloc

さらに[Bottom up (heavy) profile]セクションでは、関数の呼び出し関係を確認できます。

1
2
3
4
   ticks parent  name
  19557   51.8%  node::crypto::PBKDF2(...)
  19557  100.0%    v8::internal::Builtins::~Builtins()
  19557  100.0%      LazyCompile: ~pbkdf2 crypto.js:557:16

この結果から、PBKDF2関数(パスワードハッシュ生成)がCPU時間の51.8%を消費していることがわかります。

実践例:同期処理から非同期処理への最適化

以下のようなパスワード認証処理がボトルネックになっている場合を考えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 同期版(ボトルネックの原因)
const crypto = require('node:crypto');

app.get('/auth', (req, res) => {
  const { username, password } = req.query;
  const { salt, hash } = users[username];
  
  // 同期的なハッシュ計算がイベントループをブロック
  const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
  
  if (crypto.timingSafeEqual(hash, encryptHash)) {
    res.sendStatus(200);
  } else {
    res.sendStatus(401);
  }
});

これを非同期版に書き換えることで、イベントループのブロッキングを解消できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 非同期版(最適化後)
const crypto = require('node:crypto');

app.get('/auth', (req, res) => {
  const { username, password } = req.query;
  const { salt, hash } = users[username];
  
  // 非同期でハッシュ計算を実行
  crypto.pbkdf2(password, salt, 10000, 512, 'sha512', (err, encryptHash) => {
    if (err) {
      return res.sendStatus(500);
    }
    if (crypto.timingSafeEqual(hash, encryptHash)) {
      res.sendStatus(200);
    } else {
      res.sendStatus(401);
    }
  });
});

この最適化により、同時リクエスト処理能力が約4倍に向上し、平均レイテンシも4秒から1秒に改善されます。

–cpu-profフラグによるモダンなプロファイリング

Node.js 12以降では、--cpu-profフラグを使用することで、Chrome DevToolsで直接読み込めるCPUプロファイルを生成できます。

1
2
3
4
5
6
7
8
# CPUプロファイルを有効にして起動
node --cpu-prof app.js

# プロファイル出力ディレクトリを指定
node --cpu-prof --cpu-prof-dir=./profiles app.js

# サンプリング間隔を指定(マイクロ秒単位、デフォルト1000)
node --cpu-prof --cpu-prof-interval=500 app.js

生成される.cpuprofileファイルは、Chrome DevToolsの「Performance」タブで読み込んで視覚的に分析できます。

ヒープスナップショットによるメモリ分析

メモリリークの検出には、ヒープスナップショットが有効です。Node.jsのv8モジュールを使用して、任意のタイミングでヒープスナップショットを取得できます。

v8.writeHeapSnapshotの使用

1
2
3
4
5
6
7
8
const v8 = require('node:v8');

// 現在のヒープスナップショットをファイルに保存
const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);

// カスタムファイル名を指定
const customFilename = v8.writeHeapSnapshot('./snapshots/heap.heapsnapshot');

CLIフラグによるヒープスナップショット

--heapsnapshot-signalフラグを使用すると、シグナルを送信してヒープスナップショットを取得できます。

1
2
3
4
5
# SIGUSR2シグナルでヒープスナップショットを取得
node --heapsnapshot-signal=SIGUSR2 app.js

# 別のターミナルからシグナルを送信
kill -USR2 <pid>

メモリ不足時の自動スナップショット

--heapsnapshot-near-heap-limitフラグを使用すると、ヒープメモリが上限に近づいた際に自動的にスナップショットを取得できます。

1
2
# ヒープ上限100MBに設定し、上限付近で最大3回スナップショットを取得
node --max-old-space-size=100 --heapsnapshot-near-heap-limit=3 app.js

ヒープ統計情報の取得

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const v8 = require('node:v8');

// ヒープ統計情報を取得
const heapStats = v8.getHeapStatistics();
console.log('Heap Statistics:', heapStats);
/*
{
  total_heap_size: 7326976,
  total_heap_size_executable: 4194304,
  total_physical_size: 7326976,
  total_available_size: 1152656,
  used_heap_size: 3476208,
  heap_size_limit: 1535115264,
  malloced_memory: 16384,
  peak_malloced_memory: 1127496,
  does_zap_garbage: 0,
  number_of_native_contexts: 1,
  number_of_detached_contexts: 0,
  ...
}
*/

// ヒープスペース別の統計情報
const spaceStats = v8.getHeapSpaceStatistics();
console.log('Heap Space Statistics:', spaceStats);

重要な指標として以下があります。

  • used_heap_size: 実際に使用中のヒープメモリ
  • heap_size_limit: ヒープの上限サイズ
  • number_of_native_contexts: アクティブなコンテキスト数(増加し続ける場合はリークの可能性)
  • number_of_detached_contexts: デタッチされたコンテキスト数(0以外はリークの可能性)

ヒープスナップショットの分析方法

ヒープスナップショットはChrome DevToolsで分析できます。

  1. Chrome DevToolsを開く(F12)
  2. 「Memory」タブを選択
  3. 「Load」ボタンから.heapsnapshotファイルを読み込む

Summary View

オブジェクトをコンストラクタ名でグループ化し、各グループのメモリ使用量を表示します。

  • Shallow Size: オブジェクト自体が保持するメモリサイズ
  • Retained Size: オブジェクトが解放された場合に回収可能なメモリサイズ

Comparison View

2つのスナップショットを比較して、どのオブジェクトが増加したかを特定します。メモリリークの検出に最も有効な方法です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// メモリリーク検出の手順
// 1. 初期状態でスナップショット1を取得
const snapshot1 = v8.writeHeapSnapshot('./snapshot1.heapsnapshot');

// 2. リークが疑われる操作を繰り返し実行
await performSuspectedOperation();

// 3. スナップショット2を取得
const snapshot2 = v8.writeHeapSnapshot('./snapshot2.heapsnapshot');

// 4. Chrome DevToolsで比較分析

Containment View

オブジェクトの参照階層を表示し、どのオブジェクトが他のオブジェクトを保持しているかを確認できます。

clinic.jsによる高度なパフォーマンス分析

clinic.jsは、Node.jsアプリケーションのパフォーマンス問題を診断するためのツール群です。

clinic.jsのインストール

1
2
3
4
5
# グローバルインストール
npm install -g clinic

# インストール確認
clinic --help

Clinic Doctor

Clinic Doctorは、アプリケーションを実行しながらパフォーマンス問題を自動検出します。

1
2
3
4
5
6
7
8
# autocannon(負荷テストツール)と組み合わせて使用
clinic doctor --autocannon [ / ] -- node app.js

# 手動でベンチマークを実行する場合
clinic doctor -- node app.js
# 別ターミナルで負荷をかける
ab -c 20 -n 1000 http://localhost:3000/
# Ctrl+Cでアプリケーションを停止すると分析結果が表示される

Clinic Doctorは以下の問題を検出します。

  • I/O問題: 同期I/Oや遅いI/O操作
  • イベントループ遅延: イベントループがブロックされている状態
  • メモリ問題: 異常なメモリ増加パターン
  • CPU問題: CPU使用率の異常

Clinic Flame

Clinic Flameは、フレームグラフを生成してCPU使用状況を視覚化します。

1
clinic flame -- node app.js

フレームグラフの読み方は以下の通りです。

  • 横軸:サンプル数(CPU時間の相対的な長さ)
  • 縦軸:コールスタックの深さ
  • 色:関数のカテゴリ(JavaScript、C++、GC等)

幅の広いブロックがCPU時間を多く消費している関数を示します。

Clinic Bubbleprof

Clinic Bubbleprofは、非同期処理のフローを視覚化します。I/O待ちやプロミスチェーンのボトルネックを特定するのに有効です。

1
clinic bubbleprof -- node app.js

Clinic HeapProfiler

Clinic HeapProfilerは、メモリアロケーションを追跡し、どのコードがメモリを割り当てているかを特定します。

1
clinic heapprofiler -- node app.js

メモリリークの一般的な原因と対策

1. グローバル変数への蓄積

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 問題のあるコード:グローバル配列にデータを蓄積
const cache = [];

app.get('/data', (req, res) => {
  const data = fetchData();
  cache.push(data); // 際限なく蓄積される
  res.json(data);
});

// 対策:サイズ制限を設ける
const cache = [];
const MAX_CACHE_SIZE = 1000;

app.get('/data', (req, res) => {
  const data = fetchData();
  cache.push(data);
  if (cache.length > MAX_CACHE_SIZE) {
    cache.shift(); // 古いエントリを削除
  }
  res.json(data);
});

2. イベントリスナーの解除忘れ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 問題のあるコード:リスナーが解除されない
class MyService {
  constructor(emitter) {
    this.handler = (data) => this.process(data);
    emitter.on('data', this.handler);
  }
  
  process(data) {
    // 処理
  }
}

// 対策:destroyメソッドでリスナーを解除
class MyService {
  constructor(emitter) {
    this.emitter = emitter;
    this.handler = (data) => this.process(data);
    this.emitter.on('data', this.handler);
  }
  
  process(data) {
    // 処理
  }
  
  destroy() {
    this.emitter.off('data', this.handler);
  }
}

3. クロージャによる参照保持

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 問題のあるコード:クロージャが大きなオブジェクトを参照
function createHandler() {
  const largeData = new Array(1000000).fill('data');
  
  return function handler(req, res) {
    // largeDataを参照し続けるため解放されない
    res.json({ count: largeData.length });
  };
}

// 対策:必要な値のみをキャプチャ
function createHandler() {
  const largeData = new Array(1000000).fill('data');
  const count = largeData.length; // 必要な値のみを取得
  
  return function handler(req, res) {
    res.json({ count }); // 大きなオブジェクトへの参照なし
  };
}

4. タイマーのクリア忘れ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 問題のあるコード:タイマーがクリアされない
class PollingService {
  start() {
    this.timer = setInterval(() => {
      this.poll();
    }, 1000);
  }
  
  poll() {
    // ポーリング処理
  }
}

// 対策:stopメソッドでタイマーをクリア
class PollingService {
  start() {
    this.timer = setInterval(() => {
      this.poll();
    }, 1000);
  }
  
  poll() {
    // ポーリング処理
  }
  
  stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }
}

5. Promiseの未処理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 問題のあるコード:Promiseが解決されずに蓄積
const pendingRequests = new Map();

function makeRequest(id) {
  return new Promise((resolve) => {
    pendingRequests.set(id, resolve);
    // resolveが呼ばれない場合、Mapに残り続ける
  });
}

// 対策:タイムアウトを設定して確実に解決
function makeRequest(id, timeout = 30000) {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      pendingRequests.delete(id);
      reject(new Error('Request timeout'));
    }, timeout);
    
    pendingRequests.set(id, (result) => {
      clearTimeout(timeoutId);
      pendingRequests.delete(id);
      resolve(result);
    });
  });
}

メモリリーク検出のベストプラクティス

定期的なヒープ統計監視

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const v8 = require('node:v8');

// 定期的にヒープ統計を記録
setInterval(() => {
  const stats = v8.getHeapStatistics();
  console.log({
    timestamp: new Date().toISOString(),
    usedHeapSize: Math.round(stats.used_heap_size / 1024 / 1024) + 'MB',
    heapSizeLimit: Math.round(stats.heap_size_limit / 1024 / 1024) + 'MB',
    externalMemory: Math.round(stats.external_memory / 1024 / 1024) + 'MB',
  });
}, 60000); // 1分ごとに記録

手動GCの活用(開発時のみ)

1
2
# --expose-gcフラグでGCを手動実行可能にする
node --expose-gc app.js
1
2
3
4
5
6
7
// 開発環境でのメモリリーク検証
if (global.gc) {
  // GCを実行してから統計を取得
  global.gc();
  const stats = v8.getHeapStatistics();
  console.log('After GC:', stats.used_heap_size);
}

プロセスレベルのメモリ監視

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// process.memoryUsage()でメモリ使用状況を確認
setInterval(() => {
  const memUsage = process.memoryUsage();
  console.log({
    rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB',        // 物理メモリ
    heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB', // ヒープ全体
    heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB',  // 使用中ヒープ
    external: Math.round(memUsage.external / 1024 / 1024) + 'MB',   // 外部メモリ
    arrayBuffers: Math.round(memUsage.arrayBuffers / 1024 / 1024) + 'MB',
  });
}, 60000);

GCプロファイリング

Node.jsのGCProfilerクラスを使用して、ガベージコレクションの詳細を分析できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const { GCProfiler } = require('node:v8');

const profiler = new GCProfiler();
profiler.start();

// アプリケーション処理
setTimeout(() => {
  const result = profiler.stop();
  console.log(JSON.stringify(result, null, 2));
}, 10000);

/*
出力例:
{
  "version": 1,
  "startTime": 1674059033862,
  "statistics": [
    {
      "gcType": "Scavenge",
      "beforeGC": {
        "heapStatistics": {
          "totalHeapSize": 5005312,
          "usedHeapSize": 4883840,
          ...
        }
      },
      "cost": 1574.14,
      "afterGC": {
        "heapStatistics": {
          "totalHeapSize": 6053888,
          "usedHeapSize": 4059096,
          ...
        }
      }
    }
  ],
  "endTime": 1674059036865
}
*/

GCタイプの意味は以下の通りです。

  • Scavenge: 新世代(Young Generation)のGC、高速
  • Mark-sweep: 旧世代(Old Generation)のGC、比較的遅い
  • Mark-compact: メモリをコンパクト化するGC、最も遅い

頻繁なMark-sweepやMark-compactは、メモリ使用量が高いことを示唆しています。

パフォーマンス分析ワークフローのまとめ

flowchart TD
    A[パフォーマンス問題の認識] --> B{問題の種類は?}
    B -->|CPUボトルネック| C[--prof / --cpu-prof]
    B -->|メモリリーク| D[heapsnapshot]
    B -->|不明| E[Clinic Doctor]
    
    C --> F[プロファイル解析]
    F --> G[ホットスポット特定]
    G --> H[最適化実施]
    
    D --> I[スナップショット比較]
    I --> J[リーク箇所特定]
    J --> K[参照解除・修正]
    
    E --> L{検出された問題}
    L -->|I/O問題| M[Clinic Bubbleprof]
    L -->|CPU問題| N[Clinic Flame]
    L -->|メモリ問題| O[Clinic HeapProfiler]
    
    H --> P[再検証]
    K --> P
    M --> P
    N --> P
    O --> P
    P --> Q{問題解決?}
    Q -->|No| B
    Q -->|Yes| R[完了]

まとめ

Node.jsのパフォーマンス分析とメモリリーク検出には、複数のツールと手法を組み合わせて使用することが効果的です。

CPUプロファイリングでは、--prof--cpu-profフラグを使用して、どの関数がCPU時間を消費しているかを特定できます。特に同期的なCPU集約型処理は、非同期版に置き換えることでパフォーマンスを大幅に改善できます。

メモリ分析では、v8.writeHeapSnapshot()やヒープ統計APIを活用して、メモリの使用状況を監視し、リークを検出します。Chrome DevToolsのComparison Viewは、どのオブジェクトが増加しているかを特定するのに特に有効です。

clinic.jsは、これらの分析を自動化し、視覚的なレポートを生成してくれる強力なツールです。問題の種類が不明な場合は、まずClinic Doctorで診断することをお勧めします。

メモリリークの主な原因(グローバル変数への蓄積、イベントリスナーの解除忘れ、クロージャによる参照保持、タイマーのクリア忘れ)を理解し、適切なクリーンアップ処理を実装することで、安定したNode.jsアプリケーションを構築できます。

参考リンク