👋

ECSのNode.jsサーバーで起きたメモリリークを解消した話

に公開

こんにちは、レバテック開発部の古川と申します。

この記事は レバテック開発部 Advent Calendar 2025 10日目の記事です。

新卒2年目で、入社して初めてアドベントカレンダーに参加させていただきます。

拙い筆ではありますがご容赦ください。

ECSタスクでOutOfMemoryErrorが発生

先日、ECSにホスティングしたNode.jsのWebサーバーがOutOfMemoryError(以下OOM)で異常終了してしまいました。

メモリリークの可能性も含めて原因を調査したため、その手順や顛末についてまとめます。

ヒープメモリとは

調査する前に、まずヒープメモリについて簡単に説明します。

ヒープメモリは「プログラムの実行中に動的に確保されたメモリ領域」のことです。

実行中のプログラム(プロセス)の仮想メモリアドレス上に確保されます。

プロセスの構造
出典:https://pages.cs.wisc.edu/~remzi/OSTEP/vm-intro.pdf#page=4

メモリリークとは「ヒープメモリの中で不要になったデータが解放されずに残り続けている状態」です。

OOMは、スタックメモリ不足が原因である可能性も理論上はありますが、相当特殊な環境でない限りヒープメモリ不足が原因になります。
特に、メモリ使用量が減少しにくい挙動が見られる場合は、メモリリークが原因である可能性が高いです。

Node.jsのヒープメモリをChrome DevToolsで見る

では、Node.js プロセスのヒープメモリをChrome DevToolsから観察できるように準備していきます。

1. Node.jsの準備

nodeコマンドに --inspect オプションを渡して実行すれば、9229ポートからWebSockets経由でヒープメモリを確認することができます。[1]

node --inspect index.js

2. ECSの準備

ローカルからECSにアクセスするために、SSM Session Managerでポートフォワードします。

この場合、コンテナから9229ポートを公開する必要がないため、より安全にデバッグできます。

Terraformで設定していきます。

2.1 TerraformでのSSM有効化

AWS ProviderのバージョンをECS Execに対応している3.34.0以降に指定します。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 3.34.0"
    }
  }
}

ECSサービスのECS Execを有効にします。

resource "aws_ecs_service" "my_service" {
  # ...
  enable_execute_command = true
  # ...
}

SSM Session Managerを経由してコンテナに接続するために、ssmmessages 関連の権限をタスクロールに振ります。

resource "aws_iam_role" "task_role" {
  # ...
}

data "aws_iam_policy_document" "ecs_task_role_ssmmessages" {
  version = "2012-10-17"
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "ecs_task_role_ssmmessages" {
  # ...
  policy = data.aws_iam_policy_document.ecs_task_role_ssmmessages.json
}

resource "aws_iam_role_policy_attachment" "ecs_task_role_ssmmessages" {
  role       = aws_iam_role.task_role.name
  policy_arn = aws_iam_policy.ecs_task_role_ssmmessages.arn
}

2.2 CLIの準備

AWS CLIでローカルからセッションを開始します。

CLUSTER_NAME="my-cluster" # クラスタの識別子
TASK_ID="xxxxxx"          # タスクの識別子
RUNTIME_ID="xxxxxx"       # コンテナの識別子

REMOTE_PORT="9229"
LOCAL_PORT="9229"

aws ssm start-session \
    --target "ecs:${CLUSTER_NAME}_${TASK_ID}_${RUNTIME_ID}" \
    --document-name AWS-StartPortForwardingSession \
    --parameters "{\"portNumber\":[\"$REMOTE_PORT\"], \"localPortNumber\":[\"$LOCAL_PORT\"]}"

Starting session with... と表示されれば正常な動作(接続待機中)です。

この時点で、localhost:9229 から指定したコンテナのポート9229にアクセスできます。

3. Chromeの準備

localhost:9229 をChrome DevToolsのMemoryタブで分析します。

chrome://inspect から localhost:9229 を選択してDevToolsを開いてください。

DevToolsのMemoryタブ

Memoryタブには主に4つのデバッグ機能があります。

  • Heap snapshot:ボタンを押した瞬間に、メモリ上に存在するすべてのオブジェクト(JSオブジェクトやDOMノードなど)を記録します。

    • 用途:メモリリークの発見, 2回スナップショットを撮ってヒープ使用状況の比較
  • Allocations on timeline:記録を開始してから終了するまでの間、いつ、どれくらいのメモリが確保され、いつ解放されたかをグラフで表示します。

    • 用途:特定の操作(ボタンを押す, スクロールする)によるリークの発見
  • Allocation sampling:すべてのメモリ割り当てを記録するのではなく、間引いて記録することで、ブラウザへの負荷を減らします。

    • 用途:重い処理の特定。JSの実行スタックごとのメモリ使用内訳の把握
  • Detached elements:DOMツリーからは削除されたものの、JavaScriptの変数などから参照が残っているため、ガベージコレクションによって消去できない要素を一覧表示します。

    • 用途:DOMリーク(ブラウザ側のメモリリーク向け)

まずは Heap snapshot でデバッグしていきます。

JavaScriptのガベージコレクションの概要

実際にヒープメモリを見る前に、ガベージコレクション(以下GC)の仕組みについて簡単に触れます。

JavaScriptを含めた一般的な高級言語におけるGCの基本概念は "到達可能性" です。

到達可能性とは「あるオブジェクトから参照を伝って特定のオブジェクトにアクセスできること」です。[2]

以下は簡単な例です。

// 1. userという変数が、ヒープメモリ上の {name: "Taro"} を参照しています
let user = {
  name: "Taro"
};
// 判定:ルート(user変数)から {name: "Taro"} に到達できる → メモリに残る

// 2. 参照を上書きします
user = null;

// 判定:{name: "Taro"} に辿り着く道がなくなった → 「到達不可能」
// 結果:次のGCのタイミングで {name: "Taro"} はメモリから消されます

このようにしてどのメモリを解放すべきかを決定しています。

ここで、参照を保持しているオブジェクトを Retainers(参照保持者) と言います。

方針を決めて調べる

以上を踏まえて、今回の調査ステップは次の二つです。

  1. ヒープメモリからメモリ使用量が多いオブジェクトを探す。
  2. そのRetainersを探す。

では、スナップショットしたヒープメモリを見ていきます。

オブジェクトをサイズで降順ソートし、一番サイズが大きなオブジェクトとそのRetainersを見てみましょう。
(以下抜粋)

(concatenated string) × 2,493,815
798 kB 1%
"<a class="(sliced string)" href="...

Retainers
  [0] in Array @2105903
    pathStates in system / Context @2105595
      context in createPathState() @2105573 vee-validate.mjs:2282
        context in defineComponentBinds() @2105563 vee-validate.mjs:2985
          context in defineField() @2105565 vee-validate.mjs:2905
            context in useFieldModel() @2105559 vee-validate.mjs:2952
              context in validate() @2102063 vee-validate.mjs:2760
                buffer in system / Context @2106105
                  context in push() @2106103 server-renderer.cjs.prod.js:378
                    execution_async_resources in system / Context @98949
                      context in emitInitScript() @153189 node:internal/async_hooks:503
                        partialName in TraceSegment @1843519 segment.js:42
                          [19] in (GC roots) @3

中間の関数やスコープを見ると、 vee-validate.mjs が明記されています。
VeeValidateは、Vue.jsアプリケーションでフォームバリデーションを実装するためのライブラリです。

context in createPathState() @2105573
vee-validate.mjs:2282

context in defineComponentBinds() @2105563
vee-validate.mjs:2985

context in validate() @2102063
vee-validate.mjs:2760

確かに、createPathState ↗︎, defineComponentBinds ↗︎, validate ↗︎ という名前は、Vue の標準機能ではなく、VeeValidate ライブラリ固有のようです。

さらに、これらの親(GCルートに近い場所)を見ると、SSR のレンダリングコンテキストということがわかります。

buffer in system / Context @2106105
context in push() @2106103 server-renderer.cjs.prod.js:378

何が起こっていたか?

まとめると、以下のように変化しています。

(SSR Context) server-renderer
      ↓
(Scope) createPathState (VeeValidate の関数スコープ)
      ↓
(Reactive Data) pathStates (VeeValidate が管理するフォーム項目の状態配列)
      ↓
(Target) 個々の入力フィールドのバリデーション状態、エラーメッセージ、タッチ状態など

つまり、SSR の Context からVeeValidate の内部ステート(pathStates)への参照チェーンが切れていないということです。
原因は、VeeValidate の DevTools プラグイン機能が有効になっていたことでした。

通常、Vue アプリケーションはリクエストが終わればメモリを解放します。
しかし、VeeValidate の DevTools プラグイン機能が SSR 側で動いてしまうと、メモリリークが発生するリスクがあります。[3]

開発者がブラウザの DevTools でフォームの状態を見られるように、生成された全ての FormField の情報をグローバルな管理オブジェクトに登録しようとします。

本来、サーバーサイドでは誰も DevTools を見ないので、この機能は無効化されるべきです。
しかし、NODE_ENV といった環境変数の影響により、サーバー上でもこの「デバッグ用データの登録」が走り続けてしまうことがあります。

その場合、リクエスト処理が終わって、本来なら破棄されるはずのコンポーネントやバリデーション状態(pathStates)が、「DevTools 用のグローバルなリスト」に参照されているため、GCの対象外となります。

結果、リクエストが来るたびに、過去の全ユーザー分のフォームデータがサーバーのメモリに積み重なっていました。

おわり

対応策として NODE_ENVstaging から production に修正することで、VeeValidate の DevTools プラグイン機能を無効にしました。
結果、メモリリークは解消し、DevTools プラグインが生成した大量のオブジェクトがなくなったことをヒープメモリスナップショットから確認しました。

メモリリークもう起こらないでください😭

脚注
  1. オプションの詳細についてはこちらのドキュメントを参照してください。https://nodejs.org/ja/learn/getting-started/debugging#command-line-options ↩︎

  2. JavaScriptの到達可能性についてはこちらの記事を参照してください。https://javascript.info/garbage-collection ↩︎

  3. https://github.com/logaretm/vee-validate/issues/4978 ↩︎

レバテック開発部

Discussion