Next.jsで発生したAPI ルートのメモリリークを3点ヒープダンプ法で解決した話
はじめに
Next.js を使用しているシステムでメモリリークが発生したので、3点ヒープダンプ法で原因を特定しました。ヒープダンプを取得するまでの手順や Chrome Devtools の Heap Profiler の使い方が分かったので、備忘録としてまとめました。
3行で要約
-
NextApiResponse
の中身はstream
なので、res.end()
などでストリームを終了させないとメモリリークが発生する - メモリリークを特定するのに3点ヒープダンプ法という手法がある
-
Heap Profiler
が便利
Next.jsのシステムでメモリリークが発生
メトリクス監視からメモリリークを発見
本番環境のECSのメトリクスを監視していたところ、メモリ使用率がジリジリと上がり続けていました。新しい機能をデプロイをするとメモリ使用率が一時的に下がるものの、再びメモリ使用率が徐々に上がっていました。
このことから、メモリリークが発生していると判断し、原因を調査することにしました。
発生原因のあたり
ECSのメモリ使用率のグラフを詳しく見てみると、ある時期を境に明らかにメモリ使用率が増加していることがわかりました。Gitのログでその時期にマージされたPRを調べてみると、怪しいPRが見つかりました。以下が問題のPRで更新されたファイルの中身です[1]。
import { AxiosError } from 'axios'
import { NextApiRequest, NextApiResponse } from 'next'
import httpProxyMiddleware from 'next-http-proxy-middleware'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
await httpProxyMiddleware(req, res, {
target: 'https://example.com',
pathRewrite: [
{
patternStr: '/api/proxy',
replaceStr: '/',
},
],
proxyTimeout: 1000,
})
} catch (error) {
if (error instanceof AxiosError) {
return {}
}
throw error
}
}
export default handler
3点ヒープダンプ法を試してみた
Heap Profiler でメモリを観測
メモリリークの原因を特定するための方法を調べたところ、3点ヒープダンプ法という手法があることがわかった[2][3]ので、今回はそれを試してみました。
全体の作業の流れは下記の通りです。
- Chrome Devtools の Memory タブを開き、Heap snapshots を取得できる状態にする
- メモリリークが発生しているAPIを呼び出す前のヒープダンプを取得
- APIを呼び出す
- 3の時にヒープダンプを取得
- APIを呼び出して少し経った後にヒープダンプを取得
- Comparison で 2 と 4 のヒープダンプを比較し、確保されたメモリのうち、5でも残っているものを確認する
この方法で、API呼び出しで確保されたメモリのうち、解放されないメモリが確認できます。
ツールは Heap Profiler
を使います。Heap Profiler
は、V8エンジン上で動作する、ヒープ領域のメモリ割り当てを観測するためのツールです[4]。
作業手順
まず、package.json
のスクリプトに Heap Profiler
を使うためのコマンドを追加します。
"scripts": {
+ "debug": "NODE_OPTIONS='--inspect' next dev",
"build": "next build",
わざわざスクリプトに追加したのは、Nextの仕様上 NODE_OPTIONS='--inspect' yarn dev
というコマンドを直接ターミナルから実行しようとしても、うまく動かない[5]ためです。
追加したコマンドを実行し、アプリケーションを起動します。
yarn debug
アプリケーションが起動したことを確認したら、chrome://inspect
にアクセスして、Remote Target
のセクションの inspect
を選択して、 DevTools
を開きます。
Memory
タブを開き Heap snapshot
を選択した状態で Take snapshot
を押すと、その時点のヒープダンプを取得できます。
上記の方法で、API呼び出し前と呼び出し後のメモリを確認します。
実際にメモリを観測
まずは、上の手順でアプリケーションを起動したあと、API呼び出し前のヒープダンプを取得します。
次にAPIを呼び出します。今回は、k6を使って20秒間で500リクエストを送信してみます。
API呼び出し時のヒープダンプを取得します。
API呼び出し後に少し経ってからヒープダンプを取得します(今回の場合1分くらい)。
最後に、Snapshot3を選択した状態で「Objects allocated between Snapshot 1 and Snapshot 2」を選択します。こうすることで、API呼び出しで確保されたもののうち、残っているメモリを確認することができます。
原因の仮説・検証
原因の仮説立て
取得したスナップショットで Retained Size(保持サイズ)[6]の大きなものを見てみると、受け取ったリクエストや通信のためのSocketオブジェクトがそのまま参照可能になっています。
また、system / Context
の中身を確認すると、こちらにもレスポンスやリクエスト関連のオブジェクトが参照可能であることが表示されています。
通常、リクエストを受け取りレスポンスが適切に返却された場合、これらのオブジェクトはガベージコレクションの対象となり、参照可能なオブジェクトとして残ることはありません。そのため、どうやら当たりをつけたファイルに何かしらの問題があり、レスポンスを返却する部分でメモリの解放ができていないということがわかります。
ここで改めてソースコードを見てみます。
import { AxiosError } from 'axios'
import { NextApiRequest, NextApiResponse } from 'next'
import httpProxyMiddleware from 'next-http-proxy-middleware'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
await httpProxyMiddleware(req, res, {
target: 'https://example.com',
pathRewrite: [
{
patternStr: '/api/proxy/',
replaceStr: '/',
},
],
proxyTimeout: 1000,
})
} catch (error) {
if (error instanceof AxiosError) {
return {}
}
throw error
}
}
export default handler
このファイルは pages/api
ディレクトリに配置されたもので、通常の pages
配下のファイルがページとして扱われるのとは異なり、APIエンドポイントとして扱われます[7]。API のパラメータでは、リクエストに NextApiRequest
、レスポンスに NextApiResponse
が使われます。そして、NextApiResponse
の実態は、Node.jsのクラスである http.ServerResponse
のインスタンスです[8]。
http.ServerResponse とは何か
では、http.ServerResponse
とは何でしょうか? http.ServerResponse
は Node.js のHTTPサーバーがクライアントに対して送信するレスポンスを表すオブジェクトです。例えば、下記の res
は http.ServerResponse
のインスタンスです。
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('Hello, world!');
res.end();
});
server.listen(3000, () => {
console.log('Server is running at http://localhost:3000');
});
そして、 http.ServerResponse
は stream.Writable
インターフェースを実装しているクラスです。
stream.Writable とは何か
stream.Writable
は、データをストリームとして書き込むためのインターフェースです[9]。stream.Writable
には、書き込む際に呼び出す write()
メソッドや
ストリームの書き込みを終了するための end()
メソッドが実装されています。
Node.jsのストリームは、基本的にストリームが終了するか、エラーが発生した場合にメモリが解放されます。stream.Writable
の場合、end()
メソッドが呼び出されたときに、finish
イベントが発火することでストリームが終了し、メモリが解放されます。
そして、NextApiResponse
を使用している今回のファイルでは、よく見るとif文に入ったときに return {}
と空のオブジェクトを返却しているだけで、ストリームを明示的に終了させていません。このことから、ストリームが終了していないことがメモリリークの原因であるという仮説が考えられます。
仮説の検証
仮説ができたので、ストリームを明示的に終了させた場合に、本当にメモリリークが解消されるかを検証します。
ソースコードを以下のように修正し、ストリームを終了するように変更してみます。
} catch (error) {
if (error instanceof AxiosError) {
- return {}
+ res.end()
}
throw error
}
そして、ソースコード修正前と同じようにヒープダンプを取得し、メモリを観測してみます。
すると、修正前には確認できたSocketオブジェクトなどが綺麗になくなり、レスポンスが適切に返却されるようになったことがわかります。このことから、ストリームが終了していないことによってメモリが解放されなかった、という仮説が正しいことを検証できました。
あとは、本番に今回の修正を反映させてECSのメモリを観測します。
修正後のECSメモリ計測結果
修正したコードを本番環境に反映させ、しばらくメモリを観測したところ、メモリリークが解消されていることが確認できました。
終わりに
今回の件で改めて、フレームワークの仕組みを理解することの重要性を認識しました。また、メモリリークの調査方法、Node.jsのストリームという概念、ガベージコレクションのタイミングなど、勉強になることが多かったです。
この記事が、メモリリークに悩む開発者の助けになれば幸いです。
-
今回問題となったロジックには影響を与えない形で、公開用に修正しています。 ↩︎
-
https://www.yoheim.net/blog.php?q=20170205#google_vignette の記事を参考にしました。ありがとうございます。 ↩︎
-
https://postd.cc/simple-guide-to-finding-a-javascript-memory-leak-in-node-js/ の記事を参考にしました。ありがとうございます。 ↩︎
-
https://nodejs.org/en/learn/diagnostics/memory/using-heap-profiler/#using-heap-profiler ↩︎
-
https://nextjs.org/docs/13/pages/building-your-application/configuring/debugging ↩︎
-
保持サイズは、オブジェクト自体が削除されたときに、ガベージコレクションルートから到達できなくなった依存オブジェクトとともに解放されるメモリのサイズです。詳細は、https://developer.chrome.com/docs/devtools/memory-problems/get-started?hl=ja#retained_size ↩︎
-
https://nextjs.org/docs/13/pages/building-your-application/routing/api-routes#dynamic-api-routes ↩︎
-
https://nextjs.org/docs/13/pages/building-your-application/routing/api-routes#parameters ↩︎
-
実際には、関数コンストラクタとプロトタイプベースの継承を使った実装です。 ↩︎
Discussion