【Next.js】Next/Imageの画像プレビューにて発生したメモリリークを追う

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う

この記事は、 PLEX Advent Calendar 2024の4日目の記事です。

こんにちは

株式会社プレックスでWebアプリケーションの開発をしているtetty0217 です。

はじめに

画像データを取り扱うフォームにおいて、入力した画像をプレビューで表示する機能が付属していることは珍しくないでしょう。

今日はそんなプレビュー機能の画像描画をnext/imageで無秩序に実装すると発生するクラッシュについて、Safariブラウザを題材として「なぜメモリリークが原因だったのか」「ブラウザでは何が起きていたのか?」という点をブラウザのレイヤー(Safari Web Inspector)とネイティブのレイヤー(Xcode)から追っていきます。

対象読者

  • Next.jsにおいて特に決まりなくnext/imageを使用している方
  • next/imageによるメモリリーク発生時のブラウザや端末では何が起こっているのか気になる方

インデックス

  • 【破壊編】クラッシュを起こしてみよう
  • 【調査編1】クラッシュの原因を追う
  • 【調査編2】メモリリークの発生タイミングを追う
  • 【#メモリ君を救いたい編】様々なアプローチ検証
  • まとめ

前提

大事ですね前提。本記事では主に下記を前提として検証をしています。

計測について

各測定については3回行ったうちの中間の値を利用しています(パフォーマンス測定ツールについては観測ごとに差分が出るため)

検証環境

検証アプリケーション

bulk-image-preview.vercel.app

ボタンをクリックしてから画像ファイルを選択すると、Blob化された画像データをGrid Layoutでnext/imageを通じて描画するだけのシンプルなアプリケーションです。

他の検証パターン

検証アプリケーションのコード

github.com

【破壊編】クラッシュを起こしてみよう

まずはこのGIFをご覧ください。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_demo1

これは検証アプリケーションのホームアプリで1.4MBの画像ファイルを60枚入力し意図的にクラッシュを発生させたものです。

お手元にiPhoneをお持ちでしたら、実際に検証アプリケーションにアクセスして数十枚〜程度の画像ファイルを入力してみるのもよいでしょう。

昨今のスマートフォン端末のカメラアプリで撮影した写真データはサイズが大きく(※数MB程度)、ある程度のファイル数をBlob化したものをnext/imageで一挙に描画しようとするとこのようになります。

【調査編1】クラッシュの原因を追う

この章では破壊編において発生したクラッシュにはどのような原因があるのかをXcodeを活用して観測していきます。

メモリリーク...っぽいけど断定できる理由は何か

でかいサイズのファイルを大量に描画しようとしてクラッシュしているんだから見ればわかるじゃないですかー。

まあ、それはそうなんですが、クラッシュした原因がメモリリークであるという論拠があってもいいですよね。

ということでXcodeのツールを使って探してみます。

なぜXcodeを使うのか

クラッシュしたかどうかは実際の画面でもSafari Web Inspectorでもわかるのですが、システムがなぜクラッシュしたかというのはネイティブのレイヤーを見ないとわからないのです。

よって、Safari App自体のプロセスを観測するためにXcodeを使用します。

クラッシュ時のログを見にいく

1.準備

スマートフォン端末をMacBookと接続しておきます。

2.コンソールアプリを開く

Xcodeのメニューバーから Window > Devices and Simulators を選択します。

Devices and Simulatorsのウィンドウが開いたら、MacBookと接続している端末を選択し表示された Open Console を選択するとコンソールアプリが開きます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_コンソールアプリを開く

3.コンソールアプリでログを出力する

コンソールアプリが開けたら 開始 を選択してから破壊編の動作を再度行います。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_コンソールアプリでログを出力する

破壊編のクラッシュを引き起こしたところで停止をすると下記画像のように出力されたログを確認することができました。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_出力されたログを確認

4.クラッシュ原因には何がある?

クラッシュした原因を探そうにも何をどう見れば良いのか…ということでクラッシュレポートのタイプを特定するためにDeveloper AppleUnderstanding the exception types in a crash reportを見ていきましょう。

ざっと見た限りではクラッシュ原因を分類するタイプが下記のように定義されています*1

  • EXC_BAD_ACCESS
    • 無効なメモリアクセス。解放済みポインタや無効なアドレスへのアクセスが原因で発生します。
  • EXC_BAD_INSTRUCTION
    • 無効またはサポートされていないCPU命令の実行。通常はバグやアサーション失敗によるものです。
  • EXC_BREAKPOINT
  • EXC_CRASH
    • abort()などによる明示的なクラッシュの発生。
  • EXC_RESOURCE
    • リソース制限(例: メモリ、CPU、ファイル記述子など)に達した場合に発生します。
  • EXC_GUARD
  • EXC_CORPSE_NOTIFY
    • クラッシュ後のリソースの通知に関連する特殊な例外(通常、開発者が直接関与することは少ない)。

5.クラッシュタイプの特定

先ほど出力したログにタイプをそれぞれ検索にかけてみると EXC_RESOURCE が引っかかりました。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_クラッシュタイプの特定

どうやら「EXC_RESOURCE: com.apple.WebKit.WebContent exceeded mem limit: InactiveSoft 1536 MB 」というエラーが発生しています。

内容としては「WebContentプロセス(WebKitレンダリングエンジン)が、メモリのソフト制限(InactiveSoft)1.5GBを超えている」というエラーです。

調査報告

破壊編で発生したクラッシュの原因はメモリリークであるということがわかりました。

【調査編2】メモリリークの発生タイミングを追う

調査編1でクラッシュの原因はメモリリークだったことがわかりましたが、この章では時系列や統計情報で状況確認をしていくためにSafari Web Inspectorを活用して調査をしていきます。

Inspectorを実機に接続する

1. 準備

Safariの開発者モード有効化に関しては下記をチェック!

product.plex.co.jp

2. 実機端末のWebインスペクタを有効化

  • 設定 > アプリ > Safari を開く
  • 画面最下部の 詳細 を開く
  • 表示された Webインスペクタ にチェックを入れると準備完了です

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_実機端末のWebインスペクタを有効化

3. 実機のSafariブラウザとMacBookのWebインスペクタを接続

  1. MacBookSafariを開く
  2. スマートフォン端末で検証アプリケーションを開く
  3. Safariのメニューバーから 開発 > ${スマートフォン端末名称} を選択する

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_端末設定

実機のSafariブラウザとMacBookのWeb Inspectorを接続できました!

試しにalert関数を実行してみると実機側にも反映されます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_MacBookのWeb Inspector

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_Safariブラウザ

破壊編の様子をツールで見てみる

実際にメモリリークが発生するまでをWeb InspectorのPerformance Timelineでプロファイルして見ていきましょう。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_メモリリークが発生

画像を入力してからFileをBlobに変換して描画をするまでを観測した図です。

CPUとメモリの使用率が急に0%になっている地点が破壊編で観測したメモリリークの発生箇所になります(崖になっているところ)

Performance Timelineを見ていく

WebkitのPerformance TimelineはChromeのPerformance Timelineより詳細度が低いですが、各DevToolsの中では情報がまとまって見やすいのではなかろうかと思います。

Timelineは各イベントを時系列でプロットしたイベントビューとイベントごとの詳細を表示する詳細ビューの2つのセクションに分かれています。

※プロファイリング中はJITの最適化が解除されていることに注意が必要です。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_Performance Timeline

スクリーンショット

観測範囲に時系列でViewPortのスクリーンショットを表示しています。

詳細ビューでは下記画像のようにViewPortの変遷を見ることができるのですが、どうやら入力した画像が描画され始めた辺りでメモリリークが発生したように伺えます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_スクリーンショット

ネットワーク要求

観測範囲において時系列で発生したネットワークのアクティビティを表示しています。つまるところネットワークタブをより簡潔にしたビューです。

今回は画像の入力のみを行っていますから、アプリケーションのAsset関連の取得を除くと画像のBlobデータが60個表示されます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_ネットワーク要求

レイアウトとレンダリング

観測範囲において時系列で発生した描画と描画に関連する処理が列挙されます。

ここではブラウザ上での再レンダリングによるスタイルの再計算や60個の画像データの処理がコンポジットに表示されました。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_レイアウトとレンダリング

メディアとアニメーション

観測範囲において時系列で発生したメディア要素とCSSのAnimationおよびTransitionの情報が列挙されます。

今回はスタイリングに使用したMUIの内部で処理されたTransitionが表示されています。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_メディアとアニメーション

JavaScriptイベント

観測範囲において時系列で発生したJavaScriptのアクティビティやスタック、非同期処理、イベントの情報をサマリーとして列挙されます。

CPU

観測範囲において処理された関連するすべてのスレッドの情報のサマリーが列挙されます。

ここではどのスレッドでどの程度のCPUが使用されたのか、処理のピーク、メインスレッドの各アクティビティの割合など統計を確認することができるので、パフォーマンスを確認する上でまず見るべきタイムラインになってきます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_CPU

メモリ

観測範囲におけるメモリ使用量の大枠が内訳が表示されます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_メモリ

上記のような確認を経て、ブラウザのクラッシュ発生時は「ViewPortに画像が描画され始めた箇所」でCPU/メモリの使用を維持できずに(クラッシュしたことで)Web Inspector上での観測が中断されたことがわかりました。

タイムラインの内容を分析する

もうnext/imageの箇所直して終わりじゃん!いいえ、まだまだ追っていきます。

今回はメモリリークが原因ということで特に数値が大きいCPU Timelineのプロファイルにフォーカスして見ていきます。

使用率の内訳

このようにCPU Timelineの詳細では観測範囲における総計を見ることができます。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_クラッシュ時総計

メインスレッドは40%弱使用している中、その他(WebKit thread + Other thread)ではなんと200%以上もCPUを使用しています。

比較のためnext/image → imgタグに置き換えた画面を使用してみると下記のような結果になりました。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_非クラッシュ時総計

メインスレッドは大きく変わっていませんが、その他のスレッドの特にWebkit ThreadがCPU使用率が激減しています。

Webkit Threadとは

WebKitが持つバックグラウンドの最適化処理などを実行するスレッドのまとまりを表しているものです。

調査報告

今回の主題である画像の最適化処理に関して、Webkit2にはバックグラウンドで処理を実行してブラウザのレスポンスに影響をなるべく及ぼさないようにするsplit process modelという概念がありました。

trac.webkit.org

next/imageのようにimgタグに対して画像描画の最適化を図るオーバーヘッドを持っているようなコンポーネントを多く描画することがこの別スレッドを大きく使用することになるみたいですね(もちろん画像サイズも。)

Webkitあたりはちょっと記憶が薄いところを掘り起こしてきましたので、情報が古かったり誤っていたら是非ご指摘をいただけると幸いです。

【#メモリ君を救いたい編】様々なアプローチ検証

この章では破壊編と同じデータ量を使い、画像の加工方式や描画量、next/image以外を使うなどアプローチを変えて事象を見てみます。

【パターン1】next/imageからimgタグに置き換える

next/imageは上述の通り、imgタグを描画するにあたってさまざまな最適化を行うことから画像の描画にあたってのオーバーヘッドが乗っているコンポーネントになります。

今回のプレビュー機能にはユーザーに対し、これから投稿される画像を「事細かに確認してほしい」というメッセージはありません。

よって選択した画像が大まかにあってそうか確認してもらえればよいという解釈の元、next/imageではなくネイティブのimgタグを使用してみました。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_demo2

https://bulk-image-preview.vercel.app/img-tag

結果としては同じデータ量にも関わらずクラッシュすることはありませんでした。

これは調査編2で観測した通り、CPUとメモリの使用率が大きく下がったことが要因としてあります。

そのため、試しに同じサイズの画像ファイルを200件に増やして追加してみても無事描画されました。

【パターン2】入力サイズは同じだが描画範囲を減らしてみる

サイズの大きいリストを表示するためには、画面のパフォーマンスと体験を両立させるために仮想スクロールといったDOMの描画を制限するという手法があります。

next/imageとデータ量は同じとして描画量を減らしてみるとどうなるでしょうか?簡単に描画するnext/imageを3つに減らしてみましょう。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_demo3

https://bulk-image-preview.vercel.app/narrow-image

CPUタイムラインの総計

【Next.js】Next/Imageの画像プレビューにて発生したメモリリークを追う_Pattern2

描画するDOMを激減させたので、それは大丈夫でしょうと思われるかもしれません。私もそう思います!

実際に処理される画像データは変わりありませんが、next/imageが描画される量によって違いがあるということで、やはりnext/imageをたくさん使うと大変だということがわかります。

imgタグに対して行った200件を追加してみると、少し時間がかかりつつも描画されたのでnext/imageのオーバーヘッドがいかに大きいかがわかります。

【パターン3】画像ファイルを圧縮してからBlob化する

最後に、単純にnext/imageが描画される量が単に多いだけがメモリリークの理由なのか?ということを見ていきたいと思いますので、Blob自体のサイズを削減してみましょう。

今回は例としてblueimp-load-imageを利用して画像ファイルの圧縮を行います。

【Next.js】next/imageの画像プレビューにて発生したメモリリークを追う_demo4

https://bulk-image-preview.vercel.app/compressed-image

CPU Timeline

【Next.js】Next/Imageの画像プレビューにて発生したメモリリークを追う_Pattern3

どうやら、サイズの大きいBlobほどnext/imageにおけるオーバーヘッドが大きくなるようですね。 圧縮処理を追加しているため破壊編の例よりはメインスレッドの使用率が高く、Blobサイズが小さくなったためnext/imageのオーバーヘッドが小さくなっていることがわかります。

ちなみに限界まで試したところ150件でOOM(Out of Memory)がかかりました。

調査報告

メモリリークを引き起こさないためには色々なアプローチが取れそうだということがわかったので、サービスの体験に応じて最適化を考えていければと思います。

本記事では詳しく言及しませんが、ファイルのBlob化においてデータが不要になったタイミングでメモリへの参照を破棄しておくことです。

createObjectURLによってBlob化されたデータがunloadのタイミングで自動破棄されるまではメモリへの参照が維持されますから、アプリケーションを途中で落とさないためにも破棄するBlobに対してrevokeObjectURLをしておくことは必須です。

developer.mozilla.org

まとめ

ここまで読んでくださりありがとうございました。

本記事では、Next.jsのnext/imageを使用した画像プレビュー機能で発生するメモリリークについて遠回りや深追いをしてみました。

今回のような雑多な調査は時間がかかりますし、仕事の面では使う機会の少ない知識が多く入ってきます(※もちろんそうではない職域やポジションはあると思います。)

しかし、問題に対して仮説検証の末すぐに解決するのではなく、たまには事象そのものを深く追ってみたり、違う視点で見てみるのもよいのではないでしょうか?

今後のエンジニア人生でヒラメキの材料になったり技術トークの良いネタになるでしょう。

PS.本記事でお気づきの点があればぜひコメントください!

最後に

現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーなど各業種を募集しています。

メンバー全員で行動や技能の基準値を上げていくことで強い組織を目指しています。

一緒に働いてみたいと思った方がいましたら、是非ご連絡をお待ちしています!

埋め込み[dev.plex.co.jp]

関連リンク

Safari Web Inspector

developer.apple.com

Webkit

webkit.org

webkit.org

trac.webkit.org

Developer Apple

developer.apple.com

developer.apple.com

developer.apple.com

GitHub

github.com

Next.js

nextjs.org

*1:ChatGPTによってドキュメントの内容を簡略化してもらいました