画像のストレージと変換を Cloudflare Images に任せる
# 開発ノートWeb ページに画像を一枚置くのは、フロントエンドでたぶん一番簡単なことだ。
<img src="YOUR_IMAGE_URL" />
一行で終わる。あまりに簡単なので、この src の裏に実はたくさんのディテールが隠れていることを忘れてしまう。
画像は Web ページの中で一番重い。テキストは数 KB、未処理の写真は平気で数 MB になる。ユーザーが速いと感じるかどうかは、多くの場合あなたの JavaScript ではなく、画像を読み込むあの数秒で決まる。
バイト数を削る第一歩は圧縮だ。JPEG は離散コサイン変換で人間の目が気づきにくい高周波の情報を捨て、ファイルを大幅に小さくする——非可逆圧縮だが、見た目ではほとんど分からない。
近年の WebP や AVIF は圧縮率をさらに大きく押し上げた。代償として一部の古いブラウザが未対応なので、<picture> で fallback を用意することになる。
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" />
</picture>
ブラウザは上から順に、対応している最初のフォーマットを選んでレンダリングする。width/height、srcset、sizes、art direction——このあたりは別の記事でわりと丁寧に書いたので、ここでは繰り返さない。
この記事で書きたいのはその「もう半分」だ。こういう異なるフォーマット・異なるサイズの画像は、そもそもどこから来るのか?
Web でより良い体験を出すために、たいてい画像には次のような処理をすることになる。
- 一覧用の thumbnail、詳細ページ用の中サイズ、大サイズに縮小する
- それぞれのサイズについて AVIF・WebP・JPEG を生成する
- デバイスの画面幅と DPR に応じて実際にどれを送るか決める、あるいはフロントエンドで動的に選ぶ
- これらのファイルを storage に置き、その前段に CDN キャッシュを挟む
数が決まった静的ファイルなら、build 時に必要なフォーマットをあらかじめ全部コンパイルしておける。でも UGC のプラットフォームでは数をコントロールできない。すべてユーザーがアップロードしたあと、サーバー側で処理することになる。
こうした処理は一つ一つ分解すれば難しくない。難しいのはちゃんと完成させたうえでトラフィックに耐えることで、この二つが重なるとコストは見合わないほど跳ね上がる。問題の核心はこうだ。画像の変換は CPU-bound な作業だ。
AVIF を例にしよう。AVIF は AV1 動画のキーフレームから派生したフォーマットで1、動画の符号化はもともと「エンコードは高い、デコードは安い」性質を持つ。再生時のデコードを滑らかにするために、CPU コストをわざとエンコード側に寄せているのだ。
AVIF のファイルは小さく、ブラウザは高速にデコードでき、しかも同じサイズでの品質はかなり良い。一方で一枚を AVIF に圧縮するのは非常に CPU を食う。Jake Archibald が実測しているが、libavif の最大 effort だと、たった一枚のエンコードに十分以上かかることもある1。実務でそこまで上げることはないが、この数字が桁感を教えてくれる——AVIF のエンコードが JPEG より数倍高いのは、仕様上そうなっているからだ。
だから「自前のサーバーで動的に変換する」という、一見一番素直な方法が危うくなる。画像が増え、たまたまトラフィックのピークに重なると、この変換作業が CPU を一気に食い尽くし、他のビジネスロジックのコードを押しのけ、しかも OOM も起きやすくなる。
たとえば Next.js の next/image は、デフォルトでサーバーリソースを使って on-demand で変換する。裏で動いているのはまさに Sharp だ。便利なのは本当に便利だが、これは CPU を食う公開エンドポイントを自分のアプリの中に置くことに等しい。
公式ドキュメント自身が qualities や remotePatterns の allowlist を設定しろと言っている。さもないと、大量変換に使われてメモリを吹き飛ばされる穴になる2。個人的にも、フレームワークがこのレイヤーに手を突っ込んでくるのは好きではない。自分で制御しきれない複雑さが一段増える。
**高トラフィックなシステムでは、CPU-bound で大量のメモリを食うタスクにはとにかく注意すること。**トラフィックが増えた途端、サーバーは簡単にボトルネックにぶつかり、他の業務ロジックのコードまで巻き込みかねない。
では Lambda や非同期の仕組みで画像処理を別の場所に offload すれば? できる。ただそうすると、扱うべき複雑さが指数関数的に増える。
AWS は自前でこれ用のソリューションをパッケージ化している(かつては Serverless Image Handler、今は Dynamic Image Transformation for Amazon CloudFront に改名)3。構成を見るだけで水の深さが分かる——キャッシュ層に CloudFront、入り口に API Gateway、変換に Sharp を動かす Lambda、原画とログ置き場に S3、スマートクロップが欲しければさらに Amazon Rekognition、直リンク対策に署名をやりたければ Secrets Manager。どれも設定して、メンテして、金を払う対象だ。
もう一つ見落としがちなコストがある。帯域だ。画像はトラフィックの大口で、クラウドの外向き転送(egress)は別料金だ。
S3 から直接インターネットに配るのは特に高いので、標準的なやり方は前段に CloudFront を挟み、キャッシュで origin へのリクエストを止めることになる。ただしここに罠がある。切り出す派生ファイルが増える(サイズ × フォーマット)ほどキャッシュは分散し、一度ミスすれば S3 を読み直し、もう一度変換し、もう一度送る——同じトラフィックの料金を二度払うことになる。帯域を節約するために作った多フォーマット・多サイズが、逆にキャッシュの効きを薄めてしまうのだ。
つまり難しさは個々のステップにあるのではない。長期的なメンテを要し、トラフィックに応じてコストが膨らんでいくラインである、という点にある。会社の規模がすでに開発コストを吸収でき、かつ自前で持つ十分な戦略的意味があるのでない限り、こういうものに対する最善の戦略は、自分で作らないことだ。
Cloudflare Images が全部やってくれた
画像処理の要件があるなら、僕は今、基本的に Cloudflare Images を使っている。ここまで挙げたことは、全部これが片づけてくれる。似たサービスには BunnyCDN や ImageKit などもある。
原画は一枚アップロードするだけでいい。異なるサイズ・フォーマットが欲しければ、数十個のファイルを事前に切り出す必要はなく、URL にパラメータを付ければCloudflare がエッジで即座に変換して返す。
flexible variants を有効にすると、URL はこうなる4。
https://imagedelivery.net/<account_hash>/<image_id>/w=400,quality=80
フォーマットのネゴシエーションは自動だ。配信 URL を使う場合、Cloudflare がブラウザの送ってくる Accept ヘッダを読み、AVIF に対応していれば AVIF、WebP しか無ければ WebP、どちらも無ければ元のフォーマットに fallback する5。自分で <picture> にフォーマットを並べる必要も、判定する必要もない。
たとえばこの画像の URL(https://image.kalan.dev/b856ee69-6431-48ed-7f22-3311e7d01600/normal)をブラウザで開いて Network を見ると、返ってくるフォーマットが AVIF か webp か、ブラウザの対応次第で変わるのが分かる。でも実際には、この画像は最初から最後まで原画一枚だけで、僕は変換を一切していない。
よく使う操作も一通り内蔵されている。リサイズ、クロップ(顔を基準にしたクロップも含む)、ぼかし、反転、回転、明るさ・コントラスト調整。
もちろん自分の domain を割り当てることもできる。僕の画像は全部 image.kalan.dev に置いている。条件は、その domain が同じ Cloudflare アカウント配下の zone であること、それだけだ6。
CDN キャッシュはデフォルトだ。変換済みの画像はそのまま Cloudflare のエッジキャッシュに入り、同じ(原画 + パラメータ)の組は二度目以降、最寄りのノードから返り、二度と origin には届かない。
実は使い方は二通りある。画像を Cloudflare に直接置く方式(Hosted Images)か、画像は自分の storage(R2 でも S3 でも)に残し、エッジの変換能力だけ借りる方式(今は Transformations と呼ばれている)だ。
課金もこの二つに対応する。向こうに置けば保存量と配信量が加算され、変換だけ借りれば変換回数だけ、しかも無料枠がある7。現在の料金は次のとおり。
| Metric | Pricing |
|---|---|
| Images Transformed | First 5,000 unique transformations included + $0.50 / 1,000 unique transformations / month |
| Images Stored | $5 / 100,000 images stored / month |
| Images Delivered | $1 / 100,000 images delivered / month |
あと、ローカルから Cloudflare Images に画像をアップロードするのが楽になる簡単な cli も書いた。必要なら参考にどうぞ。
Footnotes
-
AVIF has landed — Jake Archibald。AVIF は AV1 のキーフレームから派生している。記事では libavif の最大 effort で一枚のエンコードに十分以上かかると実測している。 ↩ ↩2
-
next/image 公式ドキュメント。on-demand 最適化、メモリ上限、
qualities/remotePatternsの allowlist について。 ↩ -
Dynamic Image Transformation for Amazon CloudFront(前身は Serverless Image Handler)。 ↩
-
Cloudflare Images pricing。実際の無料枠と料金は公式ページを確認のこと。 ↩
関連記事
- Access Keyはもう使うなAccess KeyはAWSで見落とされがちなセキュリティリスクだ。OIDCとIAM Roleを組み合わせれば、GitHub Actionsはsecretなしで安全にAWSリソースを操作できる
- データベースの主キー: AUTO_INCREMENT、UUID、UUIDv7バックエンド開発では主キーをどうするかをよく決める必要がある。auto increment か UUID か、衝突はどうするか、UUIDv7 と created_at + index の性能差はどれくらいか。実際に 2000 万件のデータで検証し、設計判断までまとめる。
- Zeabur を使ってみた一般的な独立開発者がサービスをデプロイする際には、Vercelのようなプラットフォームを選択することが多いですが、データベース接続のようなより高度なニーズが必要になると、Vercelはあまり便利ではありません。また、一般的なクラウドサービスプロバイダーの価格は独立開発者にとって高額です。この記事では、Zeaburを使用した際の体験を共有し、皆さんにおすすめします!
- キーボードについて - ファームウェア編本篇為 IT 2023 鐵人賽文章:鍵盤入坑指南 - 韌體篇