Node.jsのメモリリークを追跡する:Chrome DevToolsとヒープスナップショット活用の実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と理由:Node.jsアプリに潜む「静かなる殺し屋」

本番サーバーが午前3時にクラッシュするのは、エンジニアにとって最悪の事態です。通常、ログには「JavaScript heap out of memory」という不可解なエラーだけが残されています。Node.jsはガベージコレクション(GC)によってメモリを管理していますが、魔法ではありません。不要になったはずのオブジェクトがコードから参照されたまま残っていると、それらはヒープ内に永遠に居座り続け、プロセスを徐々に圧迫して最終的にダウンさせます。

私は、終わりのない階段のようなCloudWatchのチャートを眺めて多くの夜を過ごしてきました。メモリ使用量は上昇し、高止まりしたままベースラインに戻ることはありません。これらのリークは通常、解除し忘れたイベントリスナー、グローバル変数、あるいは巨大なデータ構造を保持し続けるクロージャが原因です。リークが発生していることに気づくのは簡単ですが、本当の課題は「決定的な証拠」を握っている正確なコード行を見つけることです。そこで、Chrome DevToolsとヒープスナップショットが真価を発揮します。

このワークフローを秒間5,000リクエストを処理するサービスに適用した結果、メモリ使用量を120MBで安定させることができました。6時間ごとの緊急再起動はもう必要ありません。クリーンで予測可能なパフォーマンスを実現できたのです。

インストール:デバッグ環境の準備

リークを見つけるために高価なSaaSモニタリングツールは必要ありません。必要なものはすべてNode.jsとブラウザに組み込まれています。まずは、プロセスを実際に確認するために、意図的にメモリリークを起こす小さなアプリケーションを作成してみましょう。

まず、新しいプロジェクトディレクトリを作成し、Expressをインストールします:

mkdir node-leak-hunt
cd node-leak-hunt
npm init -y
npm install express

次に、app.jsを作成します。ここでは、クリーンアップ戦略なしにグローバル配列にユーザーのメタデータを保存するという、よくある間違いをシミュレートします。実際のアプリでは、Redisを使う代わりにメモリ内にカスタムの「セッション」ストアを構築しようとした場合に、このようなことが起こり得ます。

const express = require('express');
const app = express();
const leakyData = [];

app.get('/user', (req, res) => {
    // 各リクエストごとに10,000要素の配列をグローバルメモリに追加
    const userRequest = {
        id: Date.now(),
        metadata: new Array(10000).fill('リークデータセグメント'),
        timestamp: new Date()
    };
    leakyData.push(userRequest);
    
    res.send(`ユーザーを処理しました。キャッシュサイズ: ${leakyData.length}`);
});

app.listen(3000, () => {
    console.log('サーバーが http://localhost:3000 で起動しました');
});

このシナリオでは、ガベージコレクタは leakyData がグローバル変数であることを認識し、後でそのデータが必要になる可能性があると判断します。そのため、これらのオブジェクトは解放されません。/user へのアクセスごとに、ヒープに約80KBが蓄積されていきます。

設定:Chrome DevToolsをNode.jsに接続する

エンジンの内部を確認するには、インスペクターフラグを付けてNodeを実行する必要があります。これにより、DevToolsが接続可能なWebSocketがオープンされます。

以下のコマンドでアプリを起動します:

node --inspect app.js

起動シーケンス中に発生するリークをデバッグする場合は、--inspect-brk を使用してコードの1行目で一時停止させます。今回の実行中のサーバーの場合は、標準のフラグで完璧です。

Google Chromeを開き、以下のアドレスに移動します:

chrome://inspect

「Remote Target」の下にNode.jsプロセスが表示されます。**「inspect」**をクリックしてください。専用のDevToolsウィンドウが開きます。これはフロントエンド用ではなく、バックエンドのV8エンジンに直接つながっています。そのまま**「Memory」**タブに移動し、**「Heap Snapshot」**を選択してください。

検証とモニタリング:「3スナップショット法」

スナップショットを比較することは、ノイズを取り除くための最も信頼できる方法です。特定の時間内に作成され、削除されなかったものを特定します。

ステップ1:ベースライン

アプリの起動直後にスナップショットを取得します。これが「クリーン」な状態です。新規のExpressアプリであれば、15MBから30MB程度になるはずです。

ステップ2:負荷テスト

リークを発生させます。ブラウザを手動でリフレッシュしてもいいですが、ターミナルでループを回す方が、目に見える変化を起こすのに効果的です。以下のコマンドを実行して、エンドポイントを100回叩きます:

for i in {1..100}; do curl http://localhost:3000/user; done

ステップ3:比較

さらに2つのスナップショットを取得します。その際、GCが実行されるまで数秒待ってください。次に、表示を「Summary」から**「Comparison(比較)」**に変更し、ベースラインとして「Snapshot 1」を選択します。「New」のカウントが高く、「Deleted」がゼロになっているオブジェクトを探してください。

(closure)Object タイプの急増が確認できるはずです。これらの中身を展開し、下部にある**「Retainers(保持者)」**パネルを確認してください。そこには app.js 内の leakyData 配列が直接示されているはずです。これが「決定的な証拠」となります。

修正:参照を断つ

リークの修正とは、通常、データをグローバルスコープから外すか、容量制限のあるデータ構造を使用することを意味します。メモリ内でキャッシュする必要がある場合は、上限を設けたLRU(Least Recently Used)キャッシュを使用してください。

// Mapとサイズ制限を使用した、より安全なアプローチ
const cache = new Map();
const MAX_ENTRIES = 500;

app.get('/user', (req, res) => {
    const id = Date.now();
    cache.set(id, { id, data: '...' });
    
    if (cache.size > MAX_ENTRIES) {
        const oldestKey = cache.keys().next().value;
        cache.delete(oldestKey);
    }
    res.send('安全制限付きで処理されました');
});

注目べきメトリクス

  • Shallow Size(シャローサイズ): オブジェクト自体の重さ(通常は小さい)。
  • Retained Size(リテインサイズ): 「本当の」コスト。そのオブジェクトと子要素が削除された場合に解放されるメモリの総量。
  • Distance(距離): ルートからオブジェクトまでのホップ数。距離が1または2という数値は、グローバル変数やモジュールレベルの定数であることを示唆している場合が多いです。

クラッシュを待ってから状態を確認するのではなく、ステージング環境で60秒ごとに process.memoryUsage().heapUsed をログ出力することをお勧めします。負荷テスト後にその数値がベースラインに戻らない場合、リークが発生しています。今それを見つけるのには10分しかかかりませんが、本番環境の障害中に見つけるには10時間かかるかもしれません。

Share: