この記事は、 PLEX Advent Calendar 2024の4日目の記事です。
こんにちは
株式会社プレックスでWebアプリケーションの開発をしているtetty0217 です。
はじめに
画像データを取り扱うフォームにおいて、入力した画像をプレビューで表示する機能が付属していることは珍しくないでしょう。
今日はそんなプレビュー機能の画像描画をnext/imageで無秩序に実装すると発生するクラッシュについて、Safariブラウザを題材として「なぜメモリリークが原因だったのか」「ブラウザでは何が起きていたのか?」という点をブラウザのレイヤー(Safari Web Inspector)とネイティブのレイヤー(Xcode)から追っていきます。
対象読者
- Next.jsにおいて特に決まりなくnext/imageを使用している方
- next/imageによるメモリリーク発生時のブラウザや端末では何が起こっているのか気になる方
インデックス
- 【破壊編】クラッシュを起こしてみよう
- 【調査編1】クラッシュの原因を追う
- 【調査編2】メモリリークの発生タイミングを追う
- 【#メモリ君を救いたい編】様々なアプローチ検証
- まとめ
前提
大事ですね前提。本記事では主に下記を前提として検証をしています。
計測について
各測定については3回行ったうちの中間の値を利用しています(パフォーマンス測定ツールについては観測ごとに差分が出るため)
検証環境
- 検証端末
- ブラウザ
- ツール
- Xcode 15.4
- 検証アプリケーション
- create-next-app@latest
検証アプリケーション
ボタンをクリックしてから画像ファイルを選択すると、Blob化された画像データをGrid Layoutでnext/imageを通じて描画するだけのシンプルなアプリケーションです。
他の検証パターン
- next/image → imgタグへの置き換え
- next/imageの描画数を3つに限定
- 画像を圧縮してからBlob化
検証アプリケーションのコード
【破壊編】クラッシュを起こしてみよう
まずはこのGIFをご覧ください。
これは検証アプリケーションのホームアプリで1.4MBの画像ファイルを60枚入力し意図的にクラッシュを発生させたものです。
お手元にiPhoneをお持ちでしたら、実際に検証アプリケーションにアクセスして数十枚〜程度の画像ファイルを入力してみるのもよいでしょう。
昨今のスマートフォン端末のカメラアプリで撮影した写真データはサイズが大きく(※数MB程度)、ある程度のファイル数をBlob化したものをnext/imageで一挙に描画しようとするとこのようになります。
【調査編1】クラッシュの原因を追う
この章では破壊編において発生したクラッシュにはどのような原因があるのかをXcodeを活用して観測していきます。
メモリリーク...っぽいけど断定できる理由は何か
でかいサイズのファイルを大量に描画しようとしてクラッシュしているんだから見ればわかるじゃないですかー。
まあ、それはそうなんですが、クラッシュした原因がメモリリークであるという論拠があってもいいですよね。
ということでXcodeのツールを使って探してみます。
なぜXcodeを使うのか
クラッシュしたかどうかは実際の画面でもSafari Web Inspectorでもわかるのですが、システムがなぜクラッシュしたかというのはネイティブのレイヤーを見ないとわからないのです。
よって、Safari App自体のプロセスを観測するためにXcodeを使用します。
クラッシュ時のログを見にいく
1.準備
2.コンソールアプリを開く
Xcodeのメニューバーから Window > Devices and Simulators
を選択します。
Devices and Simulatorsのウィンドウが開いたら、MacBookと接続している端末を選択し表示された Open Console
を選択するとコンソールアプリが開きます。
3.コンソールアプリでログを出力する
コンソールアプリが開けたら 開始
を選択してから破壊編の動作を再度行います。
破壊編のクラッシュを引き起こしたところで停止をすると下記画像のように出力されたログを確認することができました。
4.クラッシュ原因には何がある?
クラッシュした原因を探そうにも何をどう見れば良いのか…ということでクラッシュレポートのタイプを特定するためにDeveloper AppleのUnderstanding 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
が引っかかりました。
どうやら「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の開発者モード有効化に関しては下記をチェック!
2. 実機端末のWebインスペクタを有効化
設定 > アプリ > Safari
を開く- 画面最下部の
詳細
を開く - 表示された
Webインスペクタ
にチェックを入れると準備完了です
3. 実機のSafariブラウザとMacBookのWebインスペクタを接続
- MacBookでSafariを開く
- スマートフォン端末で検証アプリケーションを開く
- Safariのメニューバーから
開発 > ${スマートフォン端末名称}
を選択する
実機のSafariブラウザとMacBookのWeb Inspectorを接続できました!
試しにalert関数を実行してみると実機側にも反映されます。
破壊編の様子をツールで見てみる
実際にメモリリークが発生するまでをWeb InspectorのPerformance Timelineでプロファイルして見ていきましょう。
画像を入力してからFileをBlobに変換して描画をするまでを観測した図です。
CPUとメモリの使用率が急に0%になっている地点が破壊編で観測したメモリリークの発生箇所になります(崖になっているところ)
Performance Timelineを見ていく
WebkitのPerformance TimelineはChromeのPerformance Timelineより詳細度が低いですが、各DevToolsの中では情報がまとまって見やすいのではなかろうかと思います。
Timelineは各イベントを時系列でプロットしたイベントビューとイベントごとの詳細を表示する詳細ビューの2つのセクションに分かれています。
※プロファイリング中はJITの最適化が解除されていることに注意が必要です。
スクリーンショット
観測範囲に時系列でViewPortのスクリーンショットを表示しています。
詳細ビューでは下記画像のようにViewPortの変遷を見ることができるのですが、どうやら入力した画像が描画され始めた辺りでメモリリークが発生したように伺えます。
ネットワーク要求
観測範囲において時系列で発生したネットワークのアクティビティを表示しています。つまるところネットワークタブをより簡潔にしたビューです。
今回は画像の入力のみを行っていますから、アプリケーションのAsset関連の取得を除くと画像のBlobデータが60個表示されます。
レイアウトとレンダリング
観測範囲において時系列で発生した描画と描画に関連する処理が列挙されます。
ここではブラウザ上での再レンダリングによるスタイルの再計算や60個の画像データの処理がコンポジットに表示されました。
メディアとアニメーション
観測範囲において時系列で発生したメディア要素とCSSのAnimationおよびTransitionの情報が列挙されます。
今回はスタイリングに使用したMUIの内部で処理されたTransitionが表示されています。
JavaScriptイベント
観測範囲において時系列で発生したJavaScriptのアクティビティやスタック、非同期処理、イベントの情報をサマリーとして列挙されます。
CPU
観測範囲において処理された関連するすべてのスレッドの情報のサマリーが列挙されます。
ここではどのスレッドでどの程度のCPUが使用されたのか、処理のピーク、メインスレッドの各アクティビティの割合など統計を確認することができるので、パフォーマンスを確認する上でまず見るべきタイムラインになってきます。
メモリ
観測範囲におけるメモリ使用量の大枠が内訳が表示されます。
上記のような確認を経て、ブラウザのクラッシュ発生時は「ViewPortに画像が描画され始めた箇所」でCPU/メモリの使用を維持できずに(クラッシュしたことで)Web Inspector上での観測が中断されたことがわかりました。
タイムラインの内容を分析する
もうnext/imageの箇所直して終わりじゃん!いいえ、まだまだ追っていきます。
今回はメモリリークが原因ということで特に数値が大きいCPU Timelineのプロファイルにフォーカスして見ていきます。
使用率の内訳
このようにCPU Timelineの詳細では観測範囲における総計を見ることができます。
メインスレッドは40%弱使用している中、その他(WebKit thread + Other thread)ではなんと200%以上もCPUを使用しています。
比較のためnext/image → imgタグに置き換えた画面を使用してみると下記のような結果になりました。
メインスレッドは大きく変わっていませんが、その他のスレッドの特にWebkit ThreadがCPU使用率が激減しています。
Webkit Threadとは
WebKitが持つバックグラウンドの最適化処理などを実行するスレッドのまとまりを表しているものです。
調査報告
今回の主題である画像の最適化処理に関して、Webkit2にはバックグラウンドで処理を実行してブラウザのレスポンスに影響をなるべく及ぼさないようにするsplit process modelという概念がありました。
next/imageのようにimgタグに対して画像描画の最適化を図るオーバーヘッドを持っているようなコンポーネントを多く描画することがこの別スレッドを大きく使用することになるみたいですね(もちろん画像サイズも。)
Webkitあたりはちょっと記憶が薄いところを掘り起こしてきましたので、情報が古かったり誤っていたら是非ご指摘をいただけると幸いです。
【#メモリ君を救いたい編】様々なアプローチ検証
この章では破壊編と同じデータ量を使い、画像の加工方式や描画量、next/image以外を使うなどアプローチを変えて事象を見てみます。
【パターン1】next/imageからimgタグに置き換える
next/imageは上述の通り、imgタグを描画するにあたってさまざまな最適化を行うことから画像の描画にあたってのオーバーヘッドが乗っているコンポーネントになります。
今回のプレビュー機能にはユーザーに対し、これから投稿される画像を「事細かに確認してほしい」というメッセージはありません。
よって選択した画像が大まかにあってそうか確認してもらえればよいという解釈の元、next/imageではなくネイティブのimgタグを使用してみました。
https://bulk-image-preview.vercel.app/img-tag
結果としては同じデータ量にも関わらずクラッシュすることはありませんでした。
これは調査編2で観測した通り、CPUとメモリの使用率が大きく下がったことが要因としてあります。
そのため、試しに同じサイズの画像ファイルを200件に増やして追加してみても無事描画されました。
【パターン2】入力サイズは同じだが描画範囲を減らしてみる
サイズの大きいリストを表示するためには、画面のパフォーマンスと体験を両立させるために仮想スクロールといったDOMの描画を制限するという手法があります。
next/imageとデータ量は同じとして描画量を減らしてみるとどうなるでしょうか?簡単に描画するnext/imageを3つに減らしてみましょう。
https://bulk-image-preview.vercel.app/narrow-image
CPUタイムラインの総計
描画するDOMを激減させたので、それは大丈夫でしょうと思われるかもしれません。私もそう思います!
実際に処理される画像データは変わりありませんが、next/imageが描画される量によって違いがあるということで、やはりnext/imageをたくさん使うと大変だということがわかります。
imgタグに対して行った200件を追加してみると、少し時間がかかりつつも描画されたのでnext/imageのオーバーヘッドがいかに大きいかがわかります。
【パターン3】画像ファイルを圧縮してからBlob化する
最後に、単純にnext/imageが描画される量が単に多いだけがメモリリークの理由なのか?ということを見ていきたいと思いますので、Blob自体のサイズを削減してみましょう。
今回は例としてblueimp-load-imageを利用して画像ファイルの圧縮を行います。
https://bulk-image-preview.vercel.app/compressed-image
CPU Timeline
どうやら、サイズの大きいBlobほどnext/imageにおけるオーバーヘッドが大きくなるようですね。 圧縮処理を追加しているため破壊編の例よりはメインスレッドの使用率が高く、Blobサイズが小さくなったためnext/imageのオーバーヘッドが小さくなっていることがわかります。
ちなみに限界まで試したところ150件でOOM(Out of Memory)がかかりました。
調査報告
メモリリークを引き起こさないためには色々なアプローチが取れそうだということがわかったので、サービスの体験に応じて最適化を考えていければと思います。
本記事では詳しく言及しませんが、ファイルのBlob化においてデータが不要になったタイミングでメモリへの参照を破棄しておくことです。
createObjectURLによってBlob化されたデータがunloadのタイミングで自動破棄されるまではメモリへの参照が維持されますから、アプリケーションを途中で落とさないためにも破棄するBlobに対してrevokeObjectURLをしておくことは必須です。
まとめ
ここまで読んでくださりありがとうございました。
本記事では、Next.jsのnext/imageを使用した画像プレビュー機能で発生するメモリリークについて遠回りや深追いをしてみました。
今回のような雑多な調査は時間がかかりますし、仕事の面では使う機会の少ない知識が多く入ってきます(※もちろんそうではない職域やポジションはあると思います。)
しかし、問題に対して仮説検証の末すぐに解決するのではなく、たまには事象そのものを深く追ってみたり、違う視点で見てみるのもよいのではないでしょうか?
今後のエンジニア人生でヒラメキの材料になったり技術トークの良いネタになるでしょう。
PS.本記事でお気づきの点があればぜひコメントください!
最後に
現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーなど各業種を募集しています。
メンバー全員で行動や技能の基準値を上げていくことで強い組織を目指しています。
一緒に働いてみたいと思った方がいましたら、是非ご連絡をお待ちしています!
埋め込み[dev.plex.co.jp]
関連リンク
Safari Web Inspector
Webkit
Developer Apple
GitHub
Next.js
*1:ChatGPTによってドキュメントの内容を簡略化してもらいました