T-CREATOR

NestJS メモリリーク診断:Node.js Profiler と Heap Snapshot で原因を掴む

NestJS メモリリーク診断:Node.js Profiler と Heap Snapshot で原因を掴む

本番環境で突然メモリが増え続けて、サーバーがダウンしてしまった経験はありませんか?NestJS アプリケーションでメモリリークが発生すると、アプリケーションのパフォーマンスが徐々に低下し、最終的にはサービス停止につながります。

この記事では、Node.js Profiler と Heap Snapshot という 2 つの強力なツールを使って、メモリリークの原因を特定し、解決する方法をご紹介いたします。実際のコード例とともに、初心者の方でも実践できるように丁寧に解説していきますね。

背景

メモリリークとは

メモリリークとは、アプリケーションが確保したメモリを適切に解放せず、使用可能なメモリが徐々に減少していく現象です。

Node.js は V8 エンジンのガベージコレクション(GC)によって自動的にメモリ管理を行います。しかし、以下のような場合には GC が正しく動作せず、メモリリークが発生するのです。

メモリリークが発生する主なパターン

以下の表は、NestJS アプリケーションでメモリリークが発生しやすい典型的なケースをまとめたものです。

#パターン説明具体例
1グローバル変数への参照蓄積グローバルスコープで配列やオブジェクトにデータを追加し続けるキャッシュ配列にデータを追加し続ける
2イベントリスナーの解除漏れイベントリスナーを登録したまま削除しないWebSocket の接続イベントリスナー
3タイマーのクリア漏れsetInterval や setTimeout をクリアしない定期実行処理の停止忘れ
4クロージャによる参照保持関数内で外部変数を参照し続けるコールバック内での大きなオブジェクト参照
5循環参照オブジェクト同士が互いに参照し合う親子関係を持つオブジェクトの相互参照

NestJS アプリケーションにおけるメモリ管理の重要性

NestJS は依存性注入(DI)やデコレーター、ミドルウェアなど、多くの抽象化レイヤーを持つフレームワークです。これらの機能は開発を効率化する一方で、メモリ管理の複雑さを増します。

特に以下のような場面では注意が必要でしょう。

  • サービスクラスでの状態管理: シングルトンとして動作するサービスクラスでインスタンス変数にデータを蓄積する
  • WebSocket やストリーム処理: 長時間接続を維持するリソースの管理
  • キャッシュ実装: メモリ内キャッシュの適切な削除タイミング
  • 定期実行タスク: @Cron デコレーターを使った定期処理でのリソース解放

下記の図は、NestJS アプリケーションにおけるメモリリークの一般的な発生場所を示しています。

mermaidflowchart TB
    request["クライアントリクエスト"] --> controller["Controller"]
    controller --> service["Service<br/>(Singleton)"]
    service --> cache["メモリキャッシュ"]
    service --> timer["タイマー処理"]
    service --> websocket["WebSocket接続"]

    cache -->|"削除漏れ"| leak1["メモリリーク発生"]
    timer -->|"クリア漏れ"| leak2["メモリリーク発生"]
    websocket -->|"接続解除漏れ"| leak3["メモリリーク発生"]

    leak1 --> memory["メモリ使用量増加"]
    leak2 --> memory
    leak3 --> memory
    memory --> crash["アプリケーション<br/>クラッシュ"]

図で理解できる要点:

  • NestJS の各レイヤーでメモリリークが発生する可能性がある
  • シングルトンサービスは特に注意が必要
  • 複数のリーク原因が組み合わさることでメモリ使用量が増加する

Node.js のメモリ構造

Node.js のメモリは、V8 エンジンによって以下のように管理されています。

mermaidflowchart LR
    heap["V8 Heap"] --> new_space["New Space<br/>(若い世代)"]
    heap --> old_space["Old Space<br/>(古い世代)"]
    heap --> large_object["Large Object Space<br/>(大きなオブジェクト)"]
    heap --> code_space["Code Space<br/>(コンパイル済みコード)"]

    new_space -->|"生き残ったオブジェクト"| old_space

    gc["ガベージコレクション"] -.->|"頻繁に実行"| new_space
    gc -.->|"低頻度で実行"| old_space

メモリ領域の特徴:

  • New Space: 新しく作成されたオブジェクトが配置される領域で、GC が頻繁に実行されます
  • Old Space: 長期間使用されるオブジェクトが移動する領域です
  • Large Object Space: 大きなオブジェクト専用の領域ですね

メモリリークは、本来 GC で回収されるべきオブジェクトが Old Space に残り続けることで発生します。

課題

メモリリーク検出の難しさ

メモリリークは以下の理由から、検出と原因特定が非常に困難です。

1. 症状の遅延性

メモリリークは即座に問題を引き起こしません。数時間から数日かけて徐々にメモリが増加するため、開発環境やテスト環境では気づきにくいのです。

mermaidflowchart LR
    start["アプリ起動"] -->|"数時間"| normal["正常動作<br/>メモリ: 200MB"]
    normal -->|"数日"| warning["動作低下<br/>メモリ: 800MB"]
    warning -->|"さらに数日"| critical["クリティカル<br/>メモリ: 1.4GB"]
    critical --> crash["OOM エラー<br/>アプリ停止"]

図で理解できる要点:

  • メモリリークは時間をかけて進行する
  • 初期段階では問題が顕在化しない
  • 気づいた時には既に深刻な状態になっている場合が多い

2. 原因の特定が困難

NestJS アプリケーションは複数のレイヤーとモジュールから構成されているため、どこでメモリリークが発生しているのか特定するのが難しいでしょう。

以下の表は、メモリリーク調査における課題をまとめたものです。

#課題詳細影響
1コードベースの複雑さ多数のサービス、モジュール、ミドルウェアが絡み合うどのコンポーネントが原因か特定困難
2非同期処理の追跡Promise、async/await、コールバックが混在実行フローの追跡が難しい
3外部ライブラリの影響サードパーティライブラリ内でのリークブラックボックスとなり調査が困難
4本番環境特有の問題負荷やデータ量が開発環境と異なる再現が困難
5ログだけでは不十分通常のログではメモリの詳細情報が得られない推測による調査になりがち

3. 従来の調査方法の限界

従来のログ出力や console.log によるデバッグでは、メモリリークの調査には限界があります。

typescript// 従来の調査方法の例
export class UserService {
  private users: User[] = [];

  async addUser(user: User) {
    this.users.push(user);

    // ログ出力での調査
    console.log(
      `Current users count: ${this.users.length}`
    );
    console.log(
      `Memory usage: ${
        process.memoryUsage().heapUsed / 1024 / 1024
      } MB`
    );
  }
}

上記のコードでは、配列のサイズとメモリ使用量は確認できますが、以下の情報は得られません。

  • どのオブジェクトがメモリを占有しているか
  • オブジェクト間の参照関係はどうなっているか
  • どのコードパスでオブジェクトが作成されているか
  • ガベージコレクションが適切に動作しているか

必要なツールと知識

メモリリークを効果的に診断するには、以下のツールと知識が必要です。

#項目内容用途
1Node.js ProfilerCPU とメモリのプロファイリングパフォーマンスボトルネックの特定
2Heap Snapshotメモリスナップショットの取得と分析メモリ内のオブジェクト詳細分析
3Chrome DevToolsスナップショットの可視化ツールメモリダンプの視覚的分析
4V8 メモリ構造の理解ヒープ構造と GC の仕組み調査結果の正確な解釈
5メモリリークパターンの知識典型的なリークパターン原因の迅速な特定

これらのツールを適切に組み合わせることで、メモリリークの原因を効率的に特定できます。

解決策

Node.js Profiler と Heap Snapshot の概要

Node.js には、メモリリーク診断のための 2 つの強力なツールが組み込まれています。

Node.js Profiler(プロファイラー)

Node.js Profiler は、アプリケーションの実行時パフォーマンスを記録するツールです。CPU 使用率、関数呼び出しの頻度、実行時間などを計測できます。

主な機能:

  • CPU プロファイリング: どの関数が CPU を多く使用しているか
  • メモリプロファイリング: 時系列でのメモリ使用量の変化
  • イベントループの監視: ブロッキング処理の検出

Heap Snapshot(ヒープスナップショット)

Heap Snapshot は、特定の時点でのメモリの状態を完全にキャプチャするツールです。メモリ内のすべてのオブジェクトとその参照関係を記録します。

主な機能:

  • オブジェクトの種類と数の確認
  • メモリサイズの詳細分析
  • オブジェクト間の参照関係の可視化
  • 保持パス(Retainer Path)の追跡

下記の図は、これら 2 つのツールの使い分けを示しています。

mermaidflowchart TB
    problem["メモリ問題の発生"] --> question{"問題の種類は?"}

    question -->|"メモリが増え続ける"| snapshot["Heap Snapshot<br/>使用"]
    question -->|"処理が遅い"| profiler["Node.js Profiler<br/>使用"]
    question -->|"両方の症状"| both["両方のツールを<br/>組み合わせる"]

    snapshot --> capture["スナップショット<br/>取得"]
    profiler --> record["プロファイル<br/>記録"]
    both --> combined["総合的な<br/>分析"]

    capture --> analyze1["オブジェクト分析"]
    record --> analyze2["CPU分析"]
    combined --> analyze3["パフォーマンス<br/>総合診断"]

図で理解できる要点:

  • 症状によって使い分けるツールが異なる
  • Heap Snapshot はメモリリーク調査に特化
  • 複合的な問題には両方のツールを併用する

診断環境のセットアップ

メモリリーク診断を行うための環境を準備しましょう。

1. 必要なパッケージのインストール

まず、診断に必要なパッケージをインストールします。

bash# v8-profiler-next: Node.js Profiler を使いやすくするライブラリ
# heapdump: Heap Snapshot を簡単に取得するライブラリ
yarn add v8-profiler-next heapdump

# 開発環境用の型定義
yarn add -D @types/node

2. NestJS アプリケーションへの組み込み

診断機能を NestJS アプリケーションに組み込みます。まずは診断用のモジュールを作成しましょう。

bash# 診断用モジュールの作成
nest generate module profiler
nest generate controller profiler
nest generate service profiler

3. Profiler サービスの実装

診断機能を提供するサービスクラスを実装します。

typescript// src/profiler/profiler.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { writeFileSync } from 'fs';
import { join } from 'path';

@Injectable()
export class ProfilerService {
  private readonly logger = new Logger(
    ProfilerService.name
  );

  // スナップショット保存用のディレクトリ
  private readonly snapshotDir = join(
    process.cwd(),
    'memory-snapshots'
  );
}

上記のコードでは、ProfilerService クラスの基本構造を定義しています。ロガーとスナップショット保存先のパスを設定していますね。

4. Heap Snapshot 取得メソッドの実装

Heap Snapshot を取得するメソッドを実装します。

typescript// src/profiler/profiler.service.ts(続き)

import { Injectable, Logger } from '@nestjs/common';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import * as v8 from 'v8';

@Injectable()
export class ProfilerService {
  private readonly logger = new Logger(
    ProfilerService.name
  );
  private readonly snapshotDir = join(
    process.cwd(),
    'memory-snapshots'
  );

  /**
   * Heap Snapshot を取得して保存する
   * @returns 保存されたファイルのパス
   */
  takeHeapSnapshot(): string {
    try {
      // ディレクトリが存在しない場合は作成
      if (!existsSync(this.snapshotDir)) {
        mkdirSync(this.snapshotDir, { recursive: true });
      }

      // タイムスタンプ付きのファイル名を生成
      const timestamp = new Date()
        .toISOString()
        .replace(/[:.]/g, '-');
      const filename = `heap-${timestamp}.heapsnapshot`;
      const filepath = join(this.snapshotDir, filename);

      // スナップショットを取得して保存
      const snapshot = v8.writeHeapSnapshot(filepath);

      this.logger.log(`Heap snapshot saved: ${snapshot}`);
      return snapshot;
    } catch (error) {
      this.logger.error(
        'Failed to take heap snapshot',
        error
      );
      throw error;
    }
  }
}

このメソッドは、v8.writeHeapSnapshot() を使用して現在のメモリ状態をファイルに保存します。タイムスタンプを含むファイル名で保存されるため、複数のスナップショットを時系列で比較できますね。

5. メモリ使用状況取得メソッドの実装

現在のメモリ使用状況を取得するメソッドを追加します。

typescript// src/profiler/profiler.service.ts(続き)

/**
 * 現在のメモリ使用状況を取得する
 * @returns メモリ使用量の詳細情報
 */
getMemoryUsage() {
  const usage = process.memoryUsage();

  return {
    // RSS (Resident Set Size): プロセス全体のメモリ使用量
    rss: `${Math.round(usage.rss / 1024 / 1024)} MB`,

    // Heap Total: V8 が確保したヒープの総量
    heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`,

    // Heap Used: 実際に使用されているヒープ
    heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`,

    // External: V8 エンジン外で確保されたメモリ(バッファなど)
    external: `${Math.round(usage.external / 1024 / 1024)} MB`,

    // Array Buffers: ArrayBuffer と SharedArrayBuffer のメモリ
    arrayBuffers: `${Math.round(usage.arrayBuffers / 1024 / 1024)} MB`,
  };
}

このメソッドは、Node.js の process.memoryUsage() を使って詳細なメモリ情報を取得します。各メモリ領域の意味を理解することで、問題の切り分けがしやすくなるでしょう。

6. ガベージコレクション実行メソッドの実装

手動でガベージコレクションを実行するメソッドを追加します。

typescript// src/profiler/profiler.service.ts(続き)

/**
 * 手動でガベージコレクションを実行する
 * @returns GC 実行前後のメモリ使用量
 */
forceGarbageCollection() {
  // global.gc() を使用するには --expose-gc フラグが必要
  if (!global.gc) {
    this.logger.warn('Garbage collection is not exposed. Start Node.js with --expose-gc flag.');
    return null;
  }

  // GC 実行前のメモリ使用量
  const beforeGC = process.memoryUsage();

  // ガベージコレクションを実行
  global.gc();

  // GC 実行後のメモリ使用量
  const afterGC = process.memoryUsage();

  return {
    before: {
      heapUsed: `${Math.round(beforeGC.heapUsed / 1024 / 1024)} MB`,
    },
    after: {
      heapUsed: `${Math.round(afterGC.heapUsed / 1024 / 1024)} MB`,
    },
    freed: {
      heapUsed: `${Math.round((beforeGC.heapUsed - afterGC.heapUsed) / 1024 / 1024)} MB`,
    },
  };
}

手動 GC を実行することで、解放可能なメモリがどれくらいあるかを確認できます。GC 後もメモリが減らない場合は、メモリリークの可能性が高いでしょう。

7. Profiler コントローラーの実装

診断機能を HTTP エンドポイントとして公開するコントローラーを実装します。

typescript// src/profiler/profiler.controller.ts

import { Controller, Get, Post } from '@nestjs/common';
import { ProfilerService } from './profiler.service';

@Controller('profiler')
export class ProfilerController {
  constructor(
    private readonly profilerService: ProfilerService
  ) {}

  /**
   * 現在のメモリ使用状況を取得
   * GET /profiler/memory
   */
  @Get('memory')
  getMemoryUsage() {
    return {
      timestamp: new Date().toISOString(),
      memory: this.profilerService.getMemoryUsage(),
    };
  }

  /**
   * Heap Snapshot を取得
   * POST /profiler/snapshot
   */
  @Post('snapshot')
  takeSnapshot() {
    const filepath =
      this.profilerService.takeHeapSnapshot();
    return {
      success: true,
      message: 'Heap snapshot taken successfully',
      filepath,
    };
  }

  /**
   * ガベージコレクションを実行
   * POST /profiler/gc
   */
  @Post('gc')
  forceGarbageCollection() {
    const result =
      this.profilerService.forceGarbageCollection();

    if (!result) {
      return {
        success: false,
        message:
          'GC is not exposed. Start with --expose-gc flag',
      };
    }

    return {
      success: true,
      message: 'Garbage collection executed',
      result,
    };
  }
}

これらのエンドポイントを使って、本番環境やステージング環境でリアルタイムに診断を行えます。

8. グローバル型定義の追加

TypeScript で global.gc を使用するための型定義を追加します。

typescript// src/types/global.d.ts

declare global {
  var gc: (() => void) | undefined;
}

export {};

この型定義により、global.gc() を TypeScript が認識できるようになります。

9. アプリケーション起動設定

--expose-gc フラグを付けてアプリケーションを起動するように設定します。

json// package.json

{
  "scripts": {
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "start:profile": "node --expose-gc dist/main"
  }
}

開発環境では yarn start:profile コマンドで起動することで、GC 機能が有効になります。

Heap Snapshot の取得と分析

Heap Snapshot を使ってメモリリークの原因を特定する方法を見ていきましょう。

Heap Snapshot 取得のタイミング

効果的な分析を行うには、適切なタイミングで複数のスナップショットを取得することが重要です。

#タイミング目的取得方法
1アプリ起動直後ベースライン確立POST ​/​profiler​/​snapshot
2通常運用中正常時の状態確認POST ​/​profiler​/​snapshot
3負荷テスト後ストレス下の動作確認POST ​/​profiler​/​snapshot
4メモリ使用量増加時問題発生時の状態POST ​/​profiler​/​snapshot
5GC 実行後真のメモリリーク確認POST ​/​profiler​/​gcPOST ​/​profiler​/​snapshot

スナップショット比較による分析フロー

複数のスナップショットを比較することで、メモリリークの原因を特定できます。

mermaidflowchart TB
    start["アプリ起動"] --> snapshot1["スナップショット①<br/>取得"]
    snapshot1 --> load["負荷テスト<br/>実行"]
    load --> gc1["GC実行"]
    gc1 --> snapshot2["スナップショット②<br/>取得"]

    snapshot2 --> compare["①と②を比較"]
    compare --> check{"メモリ増加<br/>あり?"}

    check -->|"Yes"| analyze["増加した<br/>オブジェクトを分析"]
    check -->|"No"| normal["正常"]

    analyze --> retainer["保持パスを<br/>追跡"]
    retainer --> identify["リーク原因<br/>特定"]
    identify --> fix["コード修正"]

    fix --> verify["再度テスト"]
    verify --> snapshot3["スナップショット③<br/>取得"]
    snapshot3 --> validate["①③を比較"]
    validate --> resolved["問題解決"]

図で理解できる要点:

  • ベースラインとなるスナップショットが必要
  • GC 後にスナップショットを取得することで、真のリークを特定
  • スナップショット比較により、増加したオブジェクトを特定

Chrome DevTools での分析手順

取得した Heap Snapshot は Chrome DevTools で分析します。

手順 1: Chrome DevTools を開く

bash# Chrome ブラウザを開き、以下を入力
chrome://inspect

手順 2: スナップショットファイルを読み込む

  1. 「Memory」タブを選択
  2. 「Load」ボタンをクリック
  3. .heapsnapshot ファイルを選択

手順 3: スナップショットの比較

typescript// 比較手順(Chrome DevTools 内での操作)
// 1. 最初のスナップショットを読み込む
// 2. 「Comparison」ビューを選択
// 3. 比較対象のスナップショットを選択
// 4. 「# Delta」列でソートして、増加したオブジェクトを確認

手順 4: オブジェクトの詳細確認

メモリを多く消費しているオブジェクトをクリックすると、以下の情報が表示されます。

項目説明確認ポイント
Constructorオブジェクトのコンストラクタ名どんなクラスのインスタンスか
Shallow Sizeオブジェクト自体のサイズ直接使用しているメモリ
Retained Sizeそのオブジェクトが保持している総メモリ間接的に保持しているメモリ
Retainersこのオブジェクトを参照しているものなぜ GC されないのか

手順 5: 保持パス(Retainer Path)の追跡

保持パスを辿ることで、なぜオブジェクトがメモリに残り続けているかを特定できます。

mermaidflowchart TB
    obj["問題のオブジェクト<br/>(User)"] --> ref1["参照元①<br/>(users配列)"]
    ref1 --> ref2["参照元②<br/>(UserService)"]
    ref2 --> ref3["参照元③<br/>(NestJSコンテナ)"]
    ref3 --> root["GCルート"]

    note1["配列に追加されたまま"] -.-> ref1
    note2["シングルトンサービス"] -.-> ref2
    note3["DIコンテナが保持"] -.-> ref3

この図のように、オブジェクトがどのような参照チェーンで GC ルートにつながっているかを確認することで、メモリリークの原因が明らかになります。

具体例

実際のメモリリーク問題とその診断・修正プロセスを見ていきましょう。

ケーススタディ 1: キャッシュ配列でのメモリリーク

問題のコード

以下は、ユーザー情報をメモリにキャッシュする典型的な実装です。

typescript// src/users/users.service.ts(問題のあるコード)

import { Injectable, Logger } from '@nestjs/common';

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  // メモリキャッシュとして配列を使用
  private readonly userCache: User[] = [];

  /**
   * ユーザーを作成してキャッシュに追加
   */
  async createUser(
    name: string,
    email: string
  ): Promise<User> {
    const user: User = {
      id: Math.random().toString(36).substr(2, 9),
      name,
      email,
      createdAt: new Date(),
    };

    // キャッシュに追加(削除処理がない!)
    this.userCache.push(user);

    this.logger.log(
      `User created. Cache size: ${this.userCache.length}`
    );

    return user;
  }

  /**
   * キャッシュからユーザーを検索
   */
  async findUser(id: string): Promise<User | undefined> {
    return this.userCache.find((user) => user.id === id);
  }

  /**
   * すべてのユーザーを取得
   */
  async getAllUsers(): Promise<User[]> {
    return this.userCache;
  }
}

このコードの問題点は、userCache 配列にユーザーを追加し続けるだけで、削除する処理がないことです。UsersService はシングルトンなので、この配列はアプリケーションの起動から終了まで存在し続けますね。

症状の確認

メモリ使用状況を定期的に確認してみましょう。

typescript// src/monitoring/memory-monitor.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ProfilerService } from '../profiler/profiler.service';

@Injectable()
export class MemoryMonitorService {
  private readonly logger = new Logger(
    MemoryMonitorService.name
  );

  constructor(
    private readonly profilerService: ProfilerService
  ) {}

  /**
   * 1分ごとにメモリ使用量を記録
   */
  @Cron(CronExpression.EVERY_MINUTE)
  monitorMemory() {
    const usage = this.profilerService.getMemoryUsage();
    this.logger.log(
      `Memory Usage: ${JSON.stringify(usage)}`
    );
  }
}

このモニタリングを実行すると、以下のようなログが出力されます。

bash# アプリ起動直後
[MemoryMonitorService] Memory Usage: {"heapUsed":"50 MB","heapTotal":"100 MB"}

# 30分後(1000リクエスト処理後)
[MemoryMonitorService] Memory Usage: {"heapUsed":"250 MB","heapTotal":"300 MB"}

# 1時間後(2000リクエスト処理後)
[MemoryMonitorService] Memory Usage: {"heapUsed":"450 MB","heapTotal":"500 MB"}

# メモリが線形に増加している!

Heap Snapshot による原因特定

スナップショットを 2 つ取得して比較します。

bash# 1. アプリ起動直後のスナップショット
curl -X POST http://localhost:3000/profiler/snapshot

# 2. 負荷テストを実行
# 1000ユーザー作成リクエストを送信

# 3. GCを実行
curl -X POST http://localhost:3000/profiler/gc

# 4. 2つ目のスナップショット
curl -X POST http://localhost:3000/profiler/snapshot

Chrome DevTools での分析結果:

比較ビューを見ると、以下のような結果が表示されます。

Constructor# New# Deleted# DeltaAlloc. SizeFreed SizeSize Delta
(array)10+1120 KB0+120 KB
User10000+100080 KB0+80 KB
Date10000+100016 KB0+16 KB
(string)300010+2990150 KB1 KB+149 KB

User オブジェクトが 1000 個増加し、一つも削除されていないことがわかります。

保持パスの確認:

User オブジェクトの保持パスを追跡すると、以下のようになっています。

sqlGC Root
  → NestJS DI Container
    → UsersService instance
      → userCache: Array(1000)
        → User instance #1User instance #2
        → ...
        → User instance #1000

UsersService の userCache 配列が原因であることが明確になりました。

解決策の実装

メモリリークを修正するため、以下の改善を行います。

改善 1: LRU キャッシュの導入

bash# LRU キャッシュライブラリのインストール
yarn add lru-cache
yarn add -D @types/lru-cache
typescript// src/users/users.service.ts(改善版)

import { Injectable, Logger } from '@nestjs/common';
import { LRUCache } from 'lru-cache';

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  // LRU キャッシュを使用(最大1000件、TTL 1時間)
  private readonly userCache: LRUCache<string, User>;

  constructor() {
    this.userCache = new LRUCache<string, User>({
      max: 1000, // 最大エントリ数
      ttl: 1000 * 60 * 60, // 1時間(ミリ秒)
      updateAgeOnGet: true, // アクセス時にTTLをリセット
      updateAgeOnHas: false, // 存在チェックではTTLをリセットしない
    });
  }

  /**
   * ユーザーを作成してキャッシュに追加
   */
  async createUser(
    name: string,
    email: string
  ): Promise<User> {
    const user: User = {
      id: Math.random().toString(36).substr(2, 9),
      name,
      email,
      createdAt: new Date(),
    };

    // LRUキャッシュに追加(自動的に古いエントリは削除される)
    this.userCache.set(user.id, user);

    this.logger.log(
      `User created. Cache size: ${this.userCache.size}`
    );

    return user;
  }

  /**
   * キャッシュからユーザーを検索
   */
  async findUser(id: string): Promise<User | undefined> {
    return this.userCache.get(id);
  }

  /**
   * すべてのユーザーを取得
   */
  async getAllUsers(): Promise<User[]> {
    return Array.from(this.userCache.values());
  }

  /**
   * キャッシュをクリア
   */
  clearCache(): void {
    this.userCache.clear();
    this.logger.log('User cache cleared');
  }
}

LRU キャッシュを使用することで、以下の利点が得られます。

  • 自動削除: 最大エントリ数を超えると、最も古いエントリが自動的に削除される
  • TTL サポート: 一定時間経過後にエントリが自動削除される
  • メモリ上限: キャッシュサイズが一定以下に保たれる

改善 2: 定期的なクリーンアップ処理

typescript// src/users/users.service.ts(クリーンアップ処理の追加)

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LRUCache } from 'lru-cache';

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);
  private readonly userCache: LRUCache<string, User>;

  constructor() {
    this.userCache = new LRUCache<string, User>({
      max: 1000,
      ttl: 1000 * 60 * 60,
      updateAgeOnGet: true,
    });
  }

  // ... 他のメソッド ...

  /**
   * 1時間ごとに期限切れエントリをクリーンアップ
   */
  @Cron(CronExpression.EVERY_HOUR)
  cleanupExpiredEntries() {
    const beforeSize = this.userCache.size;

    // 期限切れエントリを削除
    this.userCache.purgeStale();

    const afterSize = this.userCache.size;
    const removed = beforeSize - afterSize;

    if (removed > 0) {
      this.logger.log(
        `Cleaned up ${removed} expired cache entries`
      );
    }
  }

  /**
   * キャッシュの統計情報を取得
   */
  getCacheStats() {
    return {
      size: this.userCache.size,
      max: this.userCache.max,
      utilizationPercent: Math.round(
        (this.userCache.size / this.userCache.max) * 100
      ),
    };
  }
}

定期的なクリーンアップにより、メモリが確実に解放されます。

修正後の検証

修正後、再度メモリ使用量を確認しましょう。

bash# 1. 修正後のスナップショット取得
curl -X POST http://localhost:3000/profiler/snapshot

# 2. 同じ負荷テストを実行
# 1000ユーザー作成リクエストを送信

# 3. GCを実行
curl -X POST http://localhost:3000/profiler/gc

# 4. 修正後のスナップショット取得
curl -X POST http://localhost:3000/profiler/snapshot

修正後の結果:

bash# アプリ起動直後
[MemoryMonitorService] Memory Usage: {"heapUsed":"50 MB","heapTotal":"100 MB"}

# 30分後(1000リクエスト処理後)
[MemoryMonitorService] Memory Usage: {"heapUsed":"65 MB","heapTotal":"120 MB"}

# 1時間後(2000リクエスト処理後)
[MemoryMonitorService] Memory Usage: {"heapUsed":"70 MB","heapTotal":"120 MB"}

# メモリが安定している!

Chrome DevTools での比較でも、User オブジェクトの数が 1000 件以下に制限されていることが確認できます。

ケーススタディ 2: イベントリスナーのメモリリーク

問題のコード

WebSocket 接続を管理するサービスで発生するメモリリークの例です。

typescript// src/websocket/websocket.service.ts(問題のあるコード)

import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter } from 'events';

interface WebSocketConnection {
  id: string;
  userId: string;
  emitter: EventEmitter;
  connectedAt: Date;
}

@Injectable()
export class WebSocketService {
  private readonly logger = new Logger(
    WebSocketService.name
  );
  private readonly connections = new Map<
    string,
    WebSocketConnection
  >();
  private readonly globalEmitter = new EventEmitter();

  /**
   * 新しい接続を登録
   */
  registerConnection(
    connectionId: string,
    userId: string
  ): void {
    const emitter = new EventEmitter();

    // イベントリスナーを登録(問題:removeListener が呼ばれない!)
    emitter.on('message', (data) => {
      this.handleMessage(connectionId, data);
    });

    emitter.on('error', (error) => {
      this.handleError(connectionId, error);
    });

    // グローバルイベントにも登録
    this.globalEmitter.on(
      `user:${userId}:notification`,
      (notification) => {
        this.sendNotification(connectionId, notification);
      }
    );

    const connection: WebSocketConnection = {
      id: connectionId,
      userId,
      emitter,
      connectedAt: new Date(),
    };

    this.connections.set(connectionId, connection);
    this.logger.log(
      `Connection registered: ${connectionId}`
    );
  }

  /**
   * 接続を削除(問題:イベントリスナーが削除されない!)
   */
  removeConnection(connectionId: string): void {
    this.connections.delete(connectionId);
    this.logger.log(`Connection removed: ${connectionId}`);
    // emitter.removeAllListeners() が呼ばれていない!
  }

  private handleMessage(
    connectionId: string,
    data: any
  ): void {
    this.logger.log(
      `Message from ${connectionId}: ${JSON.stringify(
        data
      )}`
    );
  }

  private handleError(
    connectionId: string,
    error: Error
  ): void {
    this.logger.error(`Error from ${connectionId}:`, error);
  }

  private sendNotification(
    connectionId: string,
    notification: any
  ): void {
    const connection = this.connections.get(connectionId);
    if (connection) {
      connection.emitter.emit('notification', notification);
    }
  }
}

この問題の核心は、removeConnection() でイベントリスナーを削除していないことです。EventEmitter オブジェクトとそのリスナーがメモリに残り続けます。

診断プロセス

イベントリスナーのリークは、以下の方法で検出できます。

typescript// src/websocket/websocket-diagnostics.controller.ts

import { Controller, Get } from '@nestjs/common';
import { WebSocketService } from './websocket.service';

@Controller('websocket-diagnostics')
export class WebSocketDiagnosticsController {
  constructor(
    private readonly websocketService: WebSocketService
  ) {}

  /**
   * EventEmitter のリスナー数を確認
   */
  @Get('listeners')
  getListenerCounts() {
    // この診断用メソッドを WebSocketService に追加する必要がある
    return this.websocketService.getListenerDiagnostics();
  }
}
typescript// src/websocket/websocket.service.ts(診断メソッドの追加)

/**
 * イベントリスナーの診断情報を取得
 */
getListenerDiagnostics() {
  const diagnostics = {
    connectionCount: this.connections.size,
    globalEmitterListeners: this.globalEmitter.eventNames().map(event => ({
      event: event.toString(),
      count: this.globalEmitter.listenerCount(event),
    })),
    totalGlobalListeners: this.globalEmitter.eventNames().reduce(
      (sum, event) => sum + this.globalEmitter.listenerCount(event),
      0
    ),
  };

  return diagnostics;
}

診断 API を呼び出すと、以下のような結果が返されます。

json{
  "connectionCount": 10,
  "globalEmitterListeners": [
    {
      "event": "user:user1:notification",
      "count": 150
    },
    {
      "event": "user:user2:notification",
      "count": 200
    }
  ],
  "totalGlobalListeners": 350
}

接続数は 10 なのに、リスナー数が 350 もある状態です。これは明らかにメモリリークですね。

解決策の実装

イベントリスナーを適切に管理する実装に修正します。

typescript// src/websocket/websocket.service.ts(改善版)

import {
  Injectable,
  Logger,
  OnModuleDestroy,
} from '@nestjs/common';
import { EventEmitter } from 'events';

interface WebSocketConnection {
  id: string;
  userId: string;
  emitter: EventEmitter;
  connectedAt: Date;
  // リスナー参照を保持(削除時に使用)
  listeners: {
    messageListener: (data: any) => void;
    errorListener: (error: Error) => void;
    notificationListener: (notification: any) => void;
  };
}

@Injectable()
export class WebSocketService implements OnModuleDestroy {
  private readonly logger = new Logger(
    WebSocketService.name
  );
  private readonly connections = new Map<
    string,
    WebSocketConnection
  >();
  private readonly globalEmitter = new EventEmitter();

  constructor() {
    // EventEmitter のメモリリーク警告を有効化
    this.globalEmitter.setMaxListeners(100);
  }

  /**
   * 新しい接続を登録
   */
  registerConnection(
    connectionId: string,
    userId: string
  ): void {
    const emitter = new EventEmitter();

    // リスナー関数を変数に保存(後で削除するため)
    const messageListener = (data: any) => {
      this.handleMessage(connectionId, data);
    };

    const errorListener = (error: Error) => {
      this.handleError(connectionId, error);
    };

    const notificationListener = (notification: any) => {
      this.sendNotification(connectionId, notification);
    };

    // イベントリスナーを登録
    emitter.on('message', messageListener);
    emitter.on('error', errorListener);
    this.globalEmitter.on(
      `user:${userId}:notification`,
      notificationListener
    );

    const connection: WebSocketConnection = {
      id: connectionId,
      userId,
      emitter,
      connectedAt: new Date(),
      listeners: {
        messageListener,
        errorListener,
        notificationListener,
      },
    };

    this.connections.set(connectionId, connection);
    this.logger.log(
      `Connection registered: ${connectionId}`
    );
  }

  /**
   * 接続を削除(改善:すべてのリスナーを削除)
   */
  removeConnection(connectionId: string): void {
    const connection = this.connections.get(connectionId);

    if (!connection) {
      this.logger.warn(
        `Connection not found: ${connectionId}`
      );
      return;
    }

    // 個別のイベントリスナーを削除
    const { emitter, userId, listeners } = connection;

    emitter.removeListener(
      'message',
      listeners.messageListener
    );
    emitter.removeListener(
      'error',
      listeners.errorListener
    );
    this.globalEmitter.removeListener(
      `user:${userId}:notification`,
      listeners.notificationListener
    );

    // すべてのリスナーを削除
    emitter.removeAllListeners();

    // 接続を削除
    this.connections.delete(connectionId);

    this.logger.log(
      `Connection and all listeners removed: ${connectionId}`
    );
  }

  /**
   * モジュール破棄時にすべての接続をクリーンアップ
   */
  onModuleDestroy(): void {
    this.logger.log(
      'Cleaning up all WebSocket connections...'
    );

    // すべての接続を削除
    const connectionIds = Array.from(
      this.connections.keys()
    );
    connectionIds.forEach((id) =>
      this.removeConnection(id)
    );

    // グローバルイベントもクリア
    this.globalEmitter.removeAllListeners();

    this.logger.log('All WebSocket connections cleaned up');
  }

  private handleMessage(
    connectionId: string,
    data: any
  ): void {
    this.logger.log(
      `Message from ${connectionId}: ${JSON.stringify(
        data
      )}`
    );
  }

  private handleError(
    connectionId: string,
    error: Error
  ): void {
    this.logger.error(`Error from ${connectionId}:`, error);
  }

  private sendNotification(
    connectionId: string,
    notification: any
  ): void {
    const connection = this.connections.get(connectionId);
    if (connection) {
      connection.emitter.emit('notification', notification);
    }
  }

  /**
   * イベントリスナーの診断情報を取得
   */
  getListenerDiagnostics() {
    const diagnostics = {
      connectionCount: this.connections.size,
      globalEmitterListeners: this.globalEmitter
        .eventNames()
        .map((event) => ({
          event: event.toString(),
          count: this.globalEmitter.listenerCount(event),
        })),
      totalGlobalListeners: this.globalEmitter
        .eventNames()
        .reduce(
          (sum, event) =>
            sum + this.globalEmitter.listenerCount(event),
          0
        ),
      // 期待値との比較
      expectedListeners: this.connections.size,
      leakDetected:
        this.globalEmitter
          .eventNames()
          .reduce(
            (sum, event) =>
              sum + this.globalEmitter.listenerCount(event),
            0
          ) > this.connections.size,
    };

    return diagnostics;
  }
}

改善ポイントは以下の通りです。

  • リスナー参照の保持: リスナー関数を変数に保存し、削除時に使用
  • 明示的なリスナー削除: removeListener() で個別に削除
  • OnModuleDestroy 実装: モジュール破棄時にすべてのリソースをクリーンアップ
  • 診断機能の強化: リーク検出ロジックを追加

修正後の検証

修正後、診断 API で確認すると以下のようになります。

json{
  "connectionCount": 10,
  "globalEmitterListeners": [
    {
      "event": "user:user1:notification",
      "count": 1
    },
    {
      "event": "user:user2:notification",
      "count": 1
    }
  ],
  "totalGlobalListeners": 10,
  "expectedListeners": 10,
  "leakDetected": false
}

接続数とリスナー数が一致し、メモリリークが解消されました。

ケーススタディ 3: タイマーのメモリリーク

問題のコード

定期的にデータを同期するサービスでのメモリリークです。

typescript// src/sync/data-sync.service.ts(問題のあるコード)

import { Injectable, Logger } from '@nestjs/common';

interface SyncTask {
  userId: string;
  intervalId: NodeJS.Timeout;
  startedAt: Date;
}

@Injectable()
export class DataSyncService {
  private readonly logger = new Logger(
    DataSyncService.name
  );
  private readonly syncTasks = new Map<string, SyncTask>();

  /**
   * ユーザーのデータ同期を開始
   */
  startSync(userId: string): void {
    // 既に同期タスクが存在する場合はスキップ
    if (this.syncTasks.has(userId)) {
      this.logger.warn(
        `Sync already running for user: ${userId}`
      );
      return;
    }

    // 10秒ごとにデータ同期を実行
    const intervalId = setInterval(() => {
      this.syncUserData(userId);
    }, 10000);

    const task: SyncTask = {
      userId,
      intervalId,
      startedAt: new Date(),
    };

    this.syncTasks.set(userId, task);
    this.logger.log(`Sync started for user: ${userId}`);
  }

  /**
   * ユーザーのデータ同期を停止(問題:clearInterval が呼ばれない!)
   */
  stopSync(userId: string): void {
    this.syncTasks.delete(userId);
    this.logger.log(`Sync stopped for user: ${userId}`);
    // clearInterval() が呼ばれていない!
  }

  private async syncUserData(
    userId: string
  ): Promise<void> {
    this.logger.log(`Syncing data for user: ${userId}`);
    // データ同期処理...
  }
}

stopSync()clearInterval() を呼んでいないため、タイマーが動き続けます。タイマーのコールバック関数がメモリに残り、参照しているオブジェクトも解放されません。

解決策の実装

タイマーを適切に管理する実装に修正します。

typescript// src/sync/data-sync.service.ts(改善版)

import {
  Injectable,
  Logger,
  OnModuleDestroy,
} from '@nestjs/common';

interface SyncTask {
  userId: string;
  intervalId: NodeJS.Timeout;
  startedAt: Date;
  syncCount: number; // 同期回数を記録
}

@Injectable()
export class DataSyncService implements OnModuleDestroy {
  private readonly logger = new Logger(
    DataSyncService.name
  );
  private readonly syncTasks = new Map<string, SyncTask>();

  /**
   * ユーザーのデータ同期を開始
   */
  startSync(userId: string): void {
    // 既に同期タスクが存在する場合は停止してから再開
    if (this.syncTasks.has(userId)) {
      this.logger.warn(
        `Sync already running for user: ${userId}. Restarting...`
      );
      this.stopSync(userId);
    }

    // 10秒ごとにデータ同期を実行
    const intervalId = setInterval(() => {
      this.syncUserData(userId);

      // 同期回数をインクリメント
      const task = this.syncTasks.get(userId);
      if (task) {
        task.syncCount++;
      }
    }, 10000);

    const task: SyncTask = {
      userId,
      intervalId,
      startedAt: new Date(),
      syncCount: 0,
    };

    this.syncTasks.set(userId, task);
    this.logger.log(`Sync started for user: ${userId}`);
  }

  /**
   * ユーザーのデータ同期を停止(改善:clearInterval を実行)
   */
  stopSync(userId: string): void {
    const task = this.syncTasks.get(userId);

    if (!task) {
      this.logger.warn(
        `No sync task found for user: ${userId}`
      );
      return;
    }

    // タイマーをクリア
    clearInterval(task.intervalId);

    // タスクを削除
    this.syncTasks.delete(userId);

    const duration = Date.now() - task.startedAt.getTime();
    this.logger.log(
      `Sync stopped for user: ${userId}. ` +
        `Duration: ${Math.round(duration / 1000)}s, ` +
        `Sync count: ${task.syncCount}`
    );
  }

  /**
   * すべての同期タスクを停止
   */
  stopAllSync(): void {
    this.logger.log(
      `Stopping all sync tasks (${this.syncTasks.size} tasks)...`
    );

    const userIds = Array.from(this.syncTasks.keys());
    userIds.forEach((userId) => this.stopSync(userId));

    this.logger.log('All sync tasks stopped');
  }

  /**
   * モジュール破棄時にすべてのタイマーをクリア
   */
  onModuleDestroy(): void {
    this.logger.log(
      'DataSyncService is being destroyed. Cleaning up...'
    );
    this.stopAllSync();
  }

  /**
   * 同期タスクの診断情報を取得
   */
  getSyncDiagnostics() {
    const tasks = Array.from(this.syncTasks.values()).map(
      (task) => ({
        userId: task.userId,
        runningFor: `${Math.round(
          (Date.now() - task.startedAt.getTime()) / 1000
        )}s`,
        syncCount: task.syncCount,
      })
    );

    return {
      activeTaskCount: this.syncTasks.size,
      tasks,
    };
  }

  private async syncUserData(
    userId: string
  ): Promise<void> {
    try {
      this.logger.log(`Syncing data for user: ${userId}`);
      // データ同期処理...
    } catch (error) {
      this.logger.error(
        `Failed to sync data for user: ${userId}`,
        error
      );
      // エラーが発生した場合は同期を停止
      this.stopSync(userId);
    }
  }
}

改善ポイントは以下の通りです。

  • clearInterval の実行: タイマーを確実に停止
  • OnModuleDestroy 実装: アプリケーション終了時のクリーンアップ
  • エラーハンドリング: エラー発生時に同期を停止
  • 診断機能: アクティブなタイマーの確認

タイマーリークの検出方法

タイマーのリークは、以下のコードで検出できます。

typescript// src/sync/sync-diagnostics.controller.ts

import {
  Controller,
  Get,
  Post,
  Param,
  Delete,
} from '@nestjs/common';
import { DataSyncService } from './data-sync.service';

@Controller('sync-diagnostics')
export class SyncDiagnosticsController {
  constructor(
    private readonly dataSyncService: DataSyncService
  ) {}

  /**
   * アクティブな同期タスクの一覧を取得
   */
  @Get('tasks')
  getActiveTasks() {
    return this.dataSyncService.getSyncDiagnostics();
  }

  /**
   * 特定ユーザーの同期を開始
   */
  @Post('start/:userId')
  startSync(@Param('userId') userId: string) {
    this.dataSyncService.startSync(userId);
    return {
      success: true,
      message: `Sync started for ${userId}`,
    };
  }

  /**
   * 特定ユーザーの同期を停止
   */
  @Delete('stop/:userId')
  stopSync(@Param('userId') userId: string) {
    this.dataSyncService.stopSync(userId);
    return {
      success: true,
      message: `Sync stopped for ${userId}`,
    };
  }

  /**
   * すべての同期を停止
   */
  @Delete('stop-all')
  stopAllSync() {
    this.dataSyncService.stopAllSync();
    return {
      success: true,
      message: 'All sync tasks stopped',
    };
  }
}

診断 API を使って、タイマーが適切に管理されているか確認できます。

bash# アクティブなタスクを確認
curl http://localhost:3000/sync-diagnostics/tasks

# 結果例(修正前)
{
  "activeTaskCount": 150,
  "tasks": [...]
}

# 結果例(修正後)
{
  "activeTaskCount": 10,
  "tasks": [
    {
      "userId": "user1",
      "runningFor": "30s",
      "syncCount": 3
    }
  ]
}

メモリリーク予防のベストプラクティス

これまでの事例から学んだ、メモリリーク予防のベストプラクティスをまとめます。

#ベストプラクティス実装方法効果
1リソースのライフサイクル管理OnModuleDestroy を実装モジュール破棄時のクリーンアップ
2制限付きコレクションの使用LRU キャッシュなどを活用メモリ使用量の上限設定
3イベントリスナーの明示的削除removeListener の実行イベントリスナーリークの防止
4タイマーの適切な管理clearInterval/clearTimeoutタイマーリークの防止
5定期的なクリーンアップCron ジョブでの自動削除長期運用での安定性向上
6診断機能の組み込みメトリクス取得 API問題の早期発見
7弱い参照の活用WeakMap/WeakSet の使用GC による自動削除

下記の図は、これらのベストプラクティスを適用した理想的なアーキテクチャを示しています。

mermaidflowchart TB
    app["NestJS Application"] --> lifecycle["ライフサイクル管理"]
    app --> collection["コレクション管理"]
    app --> event["イベント管理"]
    app --> timer["タイマー管理"]

    lifecycle --> destroy["OnModuleDestroy<br/>実装"]
    collection --> lru["LRU Cache<br/>使用"]
    collection --> cleanup["定期クリーンアップ"]
    event --> remove["removeListener<br/>実行"]
    timer --> clear["clearInterval<br/>実行"]

    destroy --> safe["安全なシャットダウン"]
    lru --> limit["メモリ上限"]
    cleanup --> auto["自動削除"]
    remove --> noleak1["リーク防止"]
    clear --> noleak2["リーク防止"]

    safe --> healthy["健全なアプリ"]
    limit --> healthy
    auto --> healthy
    noleak1 --> healthy
    noleak2 --> healthy

図で理解できる要点:

  • 複数の予防策を組み合わせることが重要
  • ライフサイクル管理が基盤となる
  • 各種リソースに対して適切な管理方法を適用する

まとめ

この記事では、NestJS アプリケーションでのメモリリーク診断について、Node.js Profiler と Heap Snapshot を使った実践的な方法をご紹介いたしました。

重要なポイント

メモリリーク診断で押さえておくべき重要なポイントは以下の通りです。

診断ツールの使い分け:

  • Heap Snapshot はメモリリークの原因特定に最適
  • Node.js Profiler はパフォーマンスボトルネックの検出に有効
  • 両方を組み合わせることで総合的な診断が可能

効果的な診断プロセス:

  • ベースラインとなるスナップショットを取得する
  • 負荷テスト後に GC を実行してからスナップショットを取得する
  • スナップショットを比較して増加したオブジェクトを特定する
  • 保持パスを追跡してメモリリークの原因を突き止める

典型的なメモリリークパターン:

  • キャッシュ配列への無制限な追加
  • イベントリスナーの削除漏れ
  • タイマーのクリア漏れ
  • クロージャによる参照保持
  • 循環参照

予防のベストプラクティス:

  • OnModuleDestroy でリソースをクリーンアップする
  • LRU キャッシュなど制限付きコレクションを使用する
  • イベントリスナーを明示的に削除する
  • タイマーを適切に管理する
  • 定期的なクリーンアップ処理を実装する
  • 診断機能を組み込んで問題を早期発見する

実践での活用

実際の開発現場では、以下のような流れでメモリリーク診断を行うことをおすすめします。

  1. 開発段階: 診断用エンドポイントを実装する
  2. テスト段階: 負荷テストとスナップショット取得を自動化する
  3. ステージング段階: メモリ使用量を継続的にモニタリングする
  4. 本番段階: 異常検知時に自動的にスナップショットを取得する仕組みを構築する

Node.js Profiler と Heap Snapshot は、メモリリーク問題の解決に非常に強力なツールです。この記事で紹介した診断方法とベストプラクティスを実践することで、安定したアプリケーション運用が実現できるでしょう。

メモリリークは早期発見が重要です。定期的な診断と適切なリソース管理で、健全なアプリケーションを維持していきましょう。

関連リンク