Nuxt SSRで発生したメモリリークと気をつけるポイント

本記事では、Nuxtを使ったSSR(サーバーサイドレンダリング)環境において、私が遭遇したメモリリークの事例と、その原因・対応策・再発防止のために意識すべきポイントを共有します。

完全に自分の理解不足が原因でしたが、同じような落とし穴にハマる方の参考になれば幸いです。

環境

  • Nuxt
  • Firebase Hosting / Cloud Functions

発生した事象

Firebase Cloud FunctionsにデプロイしたSSRアプリで、リクエストを重ねるごとにコンテナのメモリ使用量が増加。定期的に Memory Limit Exceeded エラーによりコンテナがクラッシュ・再起動する事象が発生しました。

原因

以下のようなコードがルートコンポーネントの app.vue に書かれていたことが原因でした。

  • mitt というライブラリを使ってイベントハンドラー(emitter.on(...))を定義。
  • それが app.vue のトップレベルで行われており、SSRのリクエストのたびにイベントハンドラーが追加されていました。
  • 結果として、リクエストごとにメモリ消費が増加していました。
// app.vue こんなコード
<script setup>
import mitt from 'mitt';
const emitter = mitt();

emitter.on('foo', (foo) => {
  console.log('foo', foo);
});

なぜこの実装ミスが起きたのか

  • app.vueはブラウザでしか実行されないと思っていた、というわけではないのですが理解が曖昧でした。
  • app.vueはルートコンポーネントであり一度しかレンダリングされないのでイベントハンドラーの削除処理を書いていませんでした。

修正方法

  • イベントハンドラーの定義を app.vue のトップレベルから onMounted() の中へ移動
  • また、onBeforeUnmount() でイベントの解除処理も追加

注意すべきポイント

  • モジュールのトップレベルで副作用のあるコードを実行しない
    SSRでは、サーバー上で複数リクエストが同じプロセスを共有するため、モジュールの状態がリクエスト間で共有されてしまう
  • ブラウザ側だけで実行すべき処理は onMounted() に限定する
    onMounted() はクライアント側でのみ呼ばれるため、安全にイベント登録等が可能

SSRを考慮したメモリリーク防止の実践例

ここからは今回遭遇した事例をもとに汎用的な実装例を書きます

イベントハンドラー登録

<script setup>
import mitt from 'mitt';
const emitter = mitt();

// ❌ NG例:SSRごとに登録されメモリを食い尽くす
emitter.on('foo', (foo) => {
  console.log('foo', foo);
});

// ✅ OK例:クライアントのみでイベントを登録
// onMountedはサーバーでは実行されません。
onMounted(() => {
  emitter.on('foo', (foo) => {
    console.log('foo', foo);
  });
});

// イベント解除も忘れずに
onBeforeUnmount(() => {
  emitter.off('foo');
});
</script>

モジュールの定義と使用

// ❌ NG例:トップレベルに定義したオブジェクトをエクスポート
// fooManager.js
const fooManager = {
  data: [],
  add: function (foo) {
    this.data.push(foo);
  },
};
export default fooManager;

// app.vue
<script setup>
import fooManager from './fooManager.js';
const foo = { name: 'foo' };

// ❌ NG: SSRのたびに状態が蓄積されてしまう
fooManager.add(foo);
</script>

// (一応) OK: 危ういがトップレベルで.add()を実行しない限りはサーバー側においてはdataは空のまま増えない
onMounted(() => {
    fooManager.add(foo);
});

より良い実装例

  • importされたfooManagerはサーバーサイドではキャッシュされfooManager.add()を行うとSSRのたびにdataが増えていきます。
  • これを解決するためには、fooManagerをfactory関数経由で取得するようにし、呼び出し側で毎回新しいインスタンスを作るようにします。
// createFooManager.ts
const createFooManager = () => {
    return {
        data: [],
        add: function (foo) {
            this.data.push(foo);
        },
    }
}
export default createFooManager;

// app.vue
<script setup>
// Factory関数自体はキャッシュされる
import createFooManager from './createFooManager.ts';
// インスタンスは都度生成
const fooManager = createFooManager();
const foo = { name: 'foo' };
fooManager.add(foo);
// fooManagerはローカル変数なのでリクエスト処理終了時に破棄される。

まとめ

Nodejsの仕組みとしてimportされたモジュールはキャッシュされます。
SSR時、app.vueはリクエストごとに評価・実行・メモリ破棄されますがimportしているモジュールは過去にキャッシュされたものが使われます。
サーバーサイド(SSR含む)ではその変数がリクエストスコープなのかグローバルスコープなのかを考慮して書いていくのポイントになります。
例えば固有の状態を持たないDBクライアントのインスタンスはグローバルスコープとしてキャッシュされていて問題ありません。
反対にリクエストスコープの変数は 1. ローカルで扱う 2. factoryパターンを使う 3. defineEventHandlerの第一引数に渡ってくるevent.context に入れておくなどで適切に処理されるようになります。

私見

importでキャッシュされる挙動はつまるところ自動でシングルトンパターンとなるので便利ではあるのですが、気をつけないとメモリリークの原因となります。
このような特性があるのでJS/TSではあまり内部状態を持つオブジェクトを作らない方が良いと思っています。

以上