Visual Studio Code のデバッガーで Out of Memory を解消する

こんにちは。PLAY CLOUD本部プラットフォーム技術部 開発第2グループ所属のガリです。

とあるサーバーレス関数で Out of Memory (OOM) が発生しました。処理するデータ量が増えたため、暫定対応としてメモリ割り当てを増やしたところ、問題は一時的に解消されたようです。しかし、メモリを増やすことでコストが上がってしまい、さらにデータ量が増えれば増えるほど、将来的に再発する可能性があります。コストを抑えつつ再発を防止するためには、サーバーレス関数の最適化が必要でした。

問題となったボトルネックを特定するために、プロファイラを使用してみました。 対象のサーバーレス関数はそれほど複雑ではありません。コードを読みながら勘で直すこともできますが、実際にどのくらい改善したのかを確かめるには、プロファイラを使うのが効果的です。

プロファイラにはさまざまな種類がありますが、今回は VS Code のプロファイラ を使用しました。

コードの構成

コードの主な処理は以下のとおりです。

  1. クラウドストレージから JSON ファイルをメモリに読み込む
  2. 送信された更新データで、メモリ上の JSON ファイルの内容の一部を置き換える
  3. クラウドストレージに再保存する

この処理の中で、「JSON ファイルの内容の一部を置き換える」部分 (処理B) がボトルネックではないかと事前にアタリをつけました。

そのため、プロファイリングを行うにあたり、このJSON の書き換え処理に的を絞って解析を進めることにしました。コードをローカルで動かす際、クラウドストレージへのアクセスを担う (処理A) と (処理C) の処理は、コードプロファイリングの対象から外し、モック関数に差し替えています。

コードの処理は以下のようにまとめられます。

const cloneDeep = require("lodash/cloneDeep");


export async function handler() {
  try {
    const data = await importFromCloudStorage();
    const replacedData = replace(data, updatedData);
    exportToCloudStorage(replacedData);
  } catch (err) {
    
  }
}


async function importFromCloudStorage() {
  
}

function replace(data, updatedData) {
  const clonedData = cloneDeep(data);
  const index = clonedData.content.findIndex(
    (item) => item.id === updatedData.id,
  );
  if (index !== -1) {
    clonedData.content[index] = updatedData;
  }
  return clonedData;
}


async function exportToCloudStorage(data) {
  
}

プロファイラーで検証

  1. 検証したいコードの範囲を決めて、ブレークポイントをセットします。

  2. 実行とデバッグ > デバッガー一覧から Node.js を選択

  3. コードが実行され、ブレークポイントに留まる

  4. コールスタックからパフォーマンスプロファイルの取得

  5. プロファイルの種類 > ブレークポイントの選択

  6. vscode-profile-YYYY-MM-DD-HH-mm-ss.heapprofileが作成される (少し時間かかる場合があります)

    heap profile を解析してみるとメモリ消費が主にふたつの処理で発生しました、JSON ファイルをメモリに読み込み (処理A) とメモリ上の JSON ファイルの内容の一部を置き換えます (処理B)。ファイルの読み込みは読み込まれたファイルのサイズが大きいほどメモリ消費が増加するのは通常であります。

    注目すべき点は、置き換え処理において、置き換える前に元のデータを deep コピーしていることです (処理B)。例では、lodash の cloneDeep 関数を用いて deep コピーが行われています。 deep コピーをやめて shallow コピーに切り替え、プロファイラで再検証したところ、メモリ消費が減少することが確認できました。

   function replace(data, updatedData) {
     const clonedData = { ...data };
     // 省略
   }

deep コピー避けるべきか?

確かに deep コピーのメモリ消費が高いが、場合によって deep コピーするのは適切です。 コピーされたデータに変更を加えても元のデータに影響しないためであれば、deep コピーが必要です。

import cloneDeep from "lodash/cloneDeep";

const original = { a: 1, b: { c: 2 } };
const deepCopy = cloneDeep(original);

deepCopy.b.c = 99;
console.log(original.b.c); 

一方で、shallow コピーの場合は以下の通りです。

const original = { a: 1, b: { c: 2 } };
const shallowCopy = { ...original }; 

shallowCopy.b.c = 99;
console.log(original.b.c); 

メモリが限られた環境や、ケースによっては元データ(元の変数)を直接更新しても問題ないケースはあるので、shallow コピー又は元のデータを直接使う方が適切でしょう。

終わりに

サーバーレス関数の Out of Memory (OOM) 問題に対し、VS Code のプロファイラを用いてボトルネックを特定し、最適化を行いました。

今回の件から、プロファイラは改善効果を正確に把握するうえで非常に有効であること、また、データ構造や環境の制約に応じて deep コピーと shallow コピーを適切に使い分けることの重要性 が改めて確認されました。

参考リンク


元の記事を確認する

関連記事