🦍

画像表示最適化のために、CDNの挙動をリバースエンジニアリングした話

2024/07/01に公開

フロントエンド領域(ネイティブアプリやWebサイト)において、画像の表示速度を速め、ユーザー体験を向上させる重要性は年々増している。画像の最適化にあたっては、imgix等の画像処理に特化したCDNを用いることで、オンデマンドに必要なサイズの画像を、低い開発工数で生成できる。

弊社で開発しているオンライン家庭教師サービス「マナリンク」において日々先生と生徒間で多くのチャットが行われており、ノートや教科書の写真が送信されている。これらの写真は先生が内容を把握するために高精細である必要がある一方、大量に受信したときに速く表示するために一覧時点ではサムネイルサイズで表示したい、といった最適化Issueが存在する。

そこで弊社のReact Nativeアプリではimgixを用いて臨機応変に種々の画像サイズを生成していたが、コスト観点からimagekitへの移行を試みたところ、他の実装や生成される画像サイズを一切変えていないにも関わらず、表示速度が大幅に遅延する事象が発生した。

本記事では、画像最適化CDNを移行しただけで表示速度が遅延したIssueに対して、それぞれのCDNの挙動とReact Nativeアプリ内の実装の分析から、原因の特定と最適な改善案の実装に至った過程を説明する。

この記事を読むと以下のことを知れます

  • React Nativeアプリにおいて、画像表示速度を最適化するための複数の手法
  • imgixやimagekitといった、画像最適化に特化したCDNとその用途について
  • SaaSとして提供されているCDNのリクエストを通してドキュメントに明記されていないキャッシュキー設定を推論し、複雑な事情が絡んだパフォーマンスチューニングを乗り切る方法

事前知識

https://docs.expo.dev/versions/latest/sdk/image/#prefetchurls-cachepolicy

  • Webフロントエンド領域でも、画像のpreloadでLCPを改善するといった最適化手法が存在するが、そのネイティブアプリ版の一種と考えても良いと思う

https://web.dev/articles/preload-responsive-images?hl=ja

  • imgixという画像最適化CDNを用いると、S3に置いているサイズの大きな画像をオンデマンドでトリミングして最適なサイズでGETできる
    • S3の前段に置くCDNに、あらかじめ画像最適化処理が付いているイメージ
    • 例)https://imgix.example.com/hoge.png?w=500&h=500 のようにリクエストすると元画像が1200×1800×だったとしても縦500×横500の画像が得られるといった感じ。それ以外にもChromeがUser-AgentだったらWebp/Avif返すなどのフォーマット最適化も行ってくれる
    • 画像処理にはもちろん処理時間によるレイテンシーが発生するが、一度生成した画像はimgix側にキャッシュされるため、同じリクエストに対しては即座に画像を返すことができる

https://zenn.dev/manalink_dev/articles/manalink-imgix

  • これによって弊社では、チャット画面において相手から画像が送られてきた瞬間に表示するのはimgixで小さくトリミングされた画像で高速表示しつつ、同時に大きなサイズの画像をExpoImageでPrefetchしておくことで、画像をタップして拡大したときに一瞬で画像表示することを可能にしている
    • 例:以下動画において、一覧で見えている画像はとても粗いが、拡大した画像は精細になっており、別々のサイズであることが分かるし、拡大画像がラグなく表示されていることも分かる
  • 以上から、アプリにおける「画像キャッシュ」は以下の3種類あることが見えていた
    • ExpoImage内キャッシュ
      • ExpoImage.prefetch(url)を実行したURLをキーに、画像の実体をアプリ内Diskに保存
    • CDN内キャッシュ
      • imgixが前段に置いているCDNに、トリミング済み画像をキャッシュ。同じWidth/Height/etcを指定したGETに対してはトリミング処理(1.5sほど掛かる)を実行せずに即座に画像を返す
    • OS標準キャッシュ
      • CDNやExpoImageの利用如何に関わらず、一度端末にDLした画像は端末内に自動でキャッシュされることも多い
  • 画像表示速度
    • Prefetchしない場合:画像拡大時にCDNへのリクエスト + CDN内画像処理が動く。1.5s
    • Prefetchしている場合:画像拡大時にExpoImage内キャッシュから取得。ほぼ0.0s
    • Prefetch処理の速度:その画像URLへの初リクエストの場合1.5s、2回目以降(チャット部屋を開き直したり、その部屋の別の参加者が開いた場合)0.030s(一般的なCDNへのリクエスト速度)

起こった問題

  • チャット画像のように大量の画像をimgixで扱うと、Quotaを激しく消費して高額のプランを必要とした。そこで、imagekitという、Bandwidthに比例して課金する別サービスへの移行を始めた

https://imagekit.io/

  • imagekitはimgixと思想が同じで、クエリパラメータ次第で好きにトリミングした画像をGETできる
  • しかし、開発環境&Stagingにてimagekitに移行したところ、画像をタップして拡大したときに画像表示が1.5s程度掛かるようになってしまった
  • なお、一度ある端末で表示された画像は、もう一度拡大したときは爆速になっており、遅くなる問題が起きているのは初回の拡大時のみ
  • 画像最適化CDNを移行しただけで、React NativeアプリのPrefetchが期待した動作をしなくなった。がIssueとなった

最初の仮説と調査

  • CDNの移行によってキャッシュの動作が期待されたものではなくなったと解釈した
  • 1.5s掛かっている時点で、ネットワークリクエストだけじゃなく画像最適化処理が動いてしまっているから、「同一のURLへのGETでも、キャッシュヒットさせるのがimgix、させないのがimagekitである可能性が高い」と考えた
  • 手元のTerminalからCurlコマンドでimagekitへのリクエストを実行しまくって、どの要因でキャッシュヒット・ヒットしないが決まるのか試した。幸いにもimagekitが包含するCDNはCloudFrontだったので、慣れ親しんでいるレスポンスが得られた。-Hオプションと-Iオプションを用いて、特定のヘッダを使ってリクエストしたときにX-Cache: Hit from cloudfrontになるかMissになるか調べた
    • 前提知識としてCloudFrontに指定できるキャッシュキーにはURL、クエリパラメータ、Cookie、Headerなどがあると知っていた
    • imagekitのキャッシュに関するドキュメントを見たが、執筆当時は具体的なキャッシュキーは明記されていなかった
  • 画像最適化CDNだからAcceptヘッダーかUser-Agentヘッダーあたりが怪しいだろうと思ったら、Acceptヘッダーがキャッシュキーであることが挙動からリバースエンジニアリングできた。逆に、他のヘッダーはいくら変えても元のURLが変わらない限りキャッシュヒットした
    • CookieやAuthorizationも普通のCDN設定ではキャッシュキーにされがちだが、画像最適化CDNだからか、それらのヘッダーは変更してもHitしたままだった

仮説と調査結果から導いたこと

  • imgixとimagekitがCDNのキャッシュキー設定が異なることは分かったが、アプリの実装ではあくまでExpoImage.prefetch実行後に<ExpoImage />をRenderしているだけなので、prefetchがちゃんと動いているならprefetch以降はExpoImage内のキャッシュヒットするので、前節のキャッシュキーによる問題はそもそも起きないのでは?(CDNへのリクエストすら起きない)
  • 以上から、【ExpoImage以外から画像へのHTTPリクエストが発生しており、それがExpoImage.prefetchとAcceptヘッダーの内容を異なっている。それによりキャッシュヒットせず画像処理が走っていて遅くなるのではないか?】というさらなる仮説にいたる。

分かったこと

  • 画像拡大表示用に使っていたModalライブラリが、内部的に画像サイズ取得のためにImage.prefetchを実行していた
  • このImageは'react-native'パッケージのImageであり、ExpoImage(expo-image)とは異なる
    • ExpoImageのほうがDL後のRenderも高速といわれておりパフォーマンス面で優位だが、ライブラリはExpoではなくRNレイヤーのライブラリかつ昔のライブラリなので内部でImage.prefetchをハードコードしている

https://reactnative.dev/docs/image#prefetch

  • この時点で、以下のことが分かった
    • 大前提、ExpoImage.prefetchとImage.prefetchがそれぞれディスク内に保存する場所は異なるので、別々に実行するとCDNへのリクエスト自体は起きる(≒ExpoImageは単なるImageのWrapperでは無いと思われる)
    • imgix利用時はExpoImageのPrefetchとImage.prefetchはCDN目線で同じ挙動をする。つまりExpoImage.prefetch('hoge.com?w=800')実行後にImage.prefetch('hoge.com?w=800')を実行すると、CDN内キャッシュから爆速で返ってくる
    • imagekit利用時は、ExpoImage.prefetchが先に実行されていても、Image.prefetch実行時にはCDN内での画像最適化処理がもう一度動いてしまい1.5s程度の時間が掛かる
  • ExpoImage.prefetchとImage.prefetchをそれぞれlocalhostに向けて実行しHTTPヘッダーを比較すると、仮説通り、Acceptヘッダーが異なっていた。これでimgixとimagekitを切り替えただけで表示遅延が起きた理由が証明された
  • ExpoImage.prefetchとImage.prefetchを両方実行する案も試したが、Disk容量を2倍のペースで食ってしまい望ましくないことから、Modalライブラリの挙動をProps等を通して改善する方向に舵を切った

対応したこと

  • Modalライブラリのコードを調査し、画像サイズ取得のためのImage.prefetch処理を動かさないようにした

補足

  • Modalライブラリについて
    • 画像拡大時にアスペクトを維持して表示したり、ピンチインピンチアウトで拡大縮小するためにオリジナル画像のWidth/Heightを必要としていた
    • 本改修では、ライブラリにはスクリーン全体のWidth/Heightを渡し、かつ画像レンダー部分を外から注入してobjectFit=containを設定することで、アスペクト比を維持しつつ動的に画像サイズを取得しなくて済むようにした

まとめ

最終的に修正されたコードはModalライブラリの引数を変えたのみだったが、それが必要になった前提知識や調査内容が多く、「ザ・エンジニアの仕事」という感じの案件だった。

勘の鋭い方は分かったかも知れませんが、実は「既存でExpoImage.prefetchを使っているから画像の拡大表示したとき爆速になっていた」は微妙に偽で、正確には「ライブラリ内部のImage.prefetchがimgixのキャッシュにヒットしたから0.030sになって爆速だった」が正で、今回の改修でCDNキャッシュからExpoImageの内部キャッシュ(のみ)に変わったので、0.030sが0.0sになったので実はCDNの移行によるコスト改善に加えて、0.030秒速くなったことが言えます。


参考文献


タイムライン

Time やったこと わかったこと
6月24日夜18時頃 Imagekitに移行してStagingアプリで動作確認 画像拡大表示が遅くなった。Bizと話して「まあこの程度の遅さなら耐えられるけど、チャットは重要な機能だし明日1日使って解決するか試してください」と依頼を受ける
6月24日夜の風呂入浴中 ぼんやり、「なんでCDN移行しただけで画像拡大遅くなるんだろう〜」と考える 「HTTPヘッダーをキャッシュキーに入れる挙動が違うんじゃね?」に至る
6月25日朝10時〜10時半 cURLコマンドでImagekitを通して画像にGETリクエストを飛ばして検証 Acceptヘッダーがキャッシュキーに含まれていることをリバースエンジニアリングできた
6月25日朝10時半 朝会にて「遅い理由が87%くらい分かった。あと13%が分からない」という発言をした
6月25日10時半〜11時半 〜ソースレビュー等の雑務〜
6月25日11時半〜12時半 ExpoImage.prefetchをlocalhostに向けて動作させ、localhostの最前段にHTTPヘッダーを全出力するnginxを配置 まともに埋まっているヘッダーがそもそもAcceptとCookieくらいしか無いことを把握。cURL実験結果から、Acceptヘッダーが起因していること自体は特定
6月25日12時半〜13時 ExpoImageの内部処理を読んだり、ExpoImageのRender側の内部処理に起因している可能性を考え、Android/iOS両方で起きるかを確認 起きたのでExpoImageの内部処理ではなさそうと判断
6月25日13時〜13時半 axios.getでprefetch相当の処理を代行してみて挙動が変わるか実験。このときExpoImageのRender側でもHTTPヘッダーを指定して、Acceptヘッダーを揃えてみる 結果変わらず遅い。そもそもprefetchが効いているならこの検証は意味がない
6月25日13時半〜14時 これはもう自分の知らないところでHTTPリクエストが飛んでいるとしか思えないと考え、画像表示ライブラリのコードを読むと、React NativeのImage.prefetchを発見。Image.prefetchとExpoImage.prefetchをlocalhostに対して実行してnginxログを監視 Acceptヘッダーの値が異なっていた!
6月25日14時〜15時 勝利を噛みしめながらランチ
6月25日15時〜16時 Image.prefetchを実行すると爆速になった ExpoImage.prefetchと二重実行になり端末内ディスクを食い過ぎてしまったので没
6月25日16時〜17時 Modalライブラリ内でImage.prefetchさせないように調査・実装 実装が完了し、画像拡大を爆速にできた
6月25日夜 スッキリしたので筋トレ行ってパスタ山盛り食べた
マナリンク Tech Blog

Discussion