Secret Staircase

CloudFrontとLambdaを使ったサーバーレス画像最適化

CloudFrontとLambdaを使ったサーバーレス画像最適化

とあるシステムで、CloudFrontとLambdaを使ってサーバーレスで画像最適化処理を実現しました。ここで言う画像最適化処理とは、主に元画像をリサイズして最適なフォーマットに変換する処理です。

Webで同じ画像をサイズを変えて複数の場所で表示したり、端末に合わせてサイズを変更することはよくありますよね。事前に複数バージョンの画像ファイルを用意しておくのは解法のひとつですが、手間なんですよね。事前に用意するだけでなく、後から別のサイズが追加されたときに既存の画像すべて分を作る必要があり、それもまた手間です。

そこで、画像のリクエストにしたがって動的に画像を生成しましょうというのが今回採用した方法です。たとえば https://image.example.com/my-image?size=600x600 などとURL指定されたサイズ情報を元に画像が生成されます。欲しいときに欲しいタイプの画像に変換できるため画像の管理コストを下げることができます。

ただしこの手法ではリクエスト時に画像が生成されるため少し時間がかかります。そこで生成した画像をキャッシュして高速化する必要があり、それらのキャッシュの管理も合わせて必要になります。今回どのようにして実現したか、以降で説明します。

実現する機能

以下の機能を実現しました。

  • リサイズ。リサイズ可能なサイズは定義済みのものに限定する(大量の種類の画像を作成されるのを避けるため)
  • クライアントがwebpをサポートしている場合はwebpにして返す
  • 画像の中央での切り取り(crop)機能を提供する
  • 元画像を削除したら配信を停止する (停止までに一定期間を要する。後述します)

同様の機能を提供するSaaS

同様の機能を提供するSaaSもあります。たとえば imgix はそのひとつです。

正直なところ、自分で実装するよりもimgixを利用する方がおすすめです。

自分のサービスでも当初はimgixを使っていて、機能や性能は十分で満足でしたが料金がかさんでしまったため自前で作りました。imgixは元画像の枚数に応じて課金する方式なので、枚数が多いと結構かかるんですよね。

料金や機能に不都合がなければ自前で作る必要はないです。

アーキテクチャの検討

生成した画像をCloudFrontだけでなくS3でのキャッシュの有無によりアーキテクチャが大きく変わります。S3でキャッシュしなければアーキテクチャはシンプルになりますが、CloudFrontのキャッシュにない場合のアクセスが全て低速な処理となります。

S3でキャッシュする場合はアーキテクチャがかなり複雑になります。その分画像を生成するのは1度だけで済むため、ほとんどの場面で高速にレスポンスを返すことができます。

S3キャッシュ初回アクセス同一エッジロケーションへの2回目以降のアクセス異なるエッジロケーションへの初回アクセスCloudFrontのキャッシュ期間が過ぎた後の初回アクセス
なし低速高速低速低速
あり低速高速高速高速

実際のパフォーマンスは画像のサイズ、Lambdaの設定(メモリ容量)等により変動しますが、このシステムではおよそ次のようなパフォーマンスが出ています。

  • 画像を生成して返す → 1秒程度
  • S3のキャッシュから返す → 0.2秒程度
  • CloudFrontのキャッシュから返す → 0.1秒程度

※ 東京からアクセスした場合。S3とLambdaが東京リージョンにある

S3にキャッシュしないアーキテクチャ

この場合はとてもシンプルで、origin-requestで動作するLambda@Edgeで元画像が格納されているS3バケットから画像を取得して処理するだけです。

Lambda@Edgeで作成された画像はCloudFrontのキャッシュに乗るため、キャッシュが有効な間、同じエッジロケーションは高速にレスポンスを返すことができます。

元画像のS3バケットはどこにあってもよいですが、このシステムでは東京リージョンに置いています。

S3にキャッシュするアーキテクチャ

さきほどのアーキテクチャと比べてずいぶん複雑になりました。ここではキャッシュ用のS3をCloudFrontのオリジンとして設定し、そこで見つからなかった場合にorigin-responseで設定したLambda@Edgeから東京リージョンのLambdaを呼びます。このLambdaが元画像を処理して返します。このとき結果をキャッシュ用のS3に記録しておき、その情報をDynamoDBにも保存しておきます。DynamoDBの用途は、元画像を削除した場合にS3上のキャッシュを削除できるように、元画像とキャッシュ画像の関連を保存しておくものです。

DynamoDBのテーブルはこのような属性を持っています。base_object_keyにはグローバルセカンダリインデックスを設定します。元画像の削除時はbase_object_keyで検索してヒットしたすべての項目についてobject_keyに該当するS3のキャッシュを削除します。

属性
object_keyキャッシュされた画像のオブジェクトキー
base_object_key元画像のオブジェクトキー

なお画像処理をLambda@EdgeではなくLambdaで実施しているのは、S3と読み込みと書き込みの2回やりとりがあるためネットワーク的に近い箇所で処理するためです。実装の都合でRustで書きたかったのもあります。(Lambda@EdgeではまだRustがサポートされていない)

元画像の削除とCloudFrontの無効リクエスト

S3にキャッシュするアーキテクチャでは元画像の削除のためにDynamoDBを導入しました。しかしS3からキャッシュを削除してもCloudFrontにはまだ残っています。こちらはどのように扱えばよいでしょうか。

可能であれば元画像が削除されたらCloudFrontのキャッシュもすぐに削除したいところです。しかしこれにはCloudFrontの料金面で難しい部分があります。

CloudFrontのキャッシュを削除する無効リクエストは1回につき0.005USDかかり、2020年2月14日現在これは0.55円です。通常の使い方なら問題ありませんが、悪意を持って濫用されると大きな金額になるかもしれません。ユーザーの操作により画像を削除できるシステムで、削除時にCloudFrontの無効リクエストを実行するシステムの場合は悪意のあるユーザーによる画像の削除を検討しておく必要があります。スクリプトを使えば膨大な回数でも簡単に実行できるかもしれません。仮に100万回削除すると55万円です。

無効リクエストはコストが高いため、ユーザーがサービスの管理者のみであるなど限定できる場合を除いて、ユーザーの操作を起点として実行するのは避けた方がよいでしょう。結局のところ無効リクエストを発行せず、CloudFrontのキャッシュの有効期限により自然と消えるのにまかせるのが無難です。

したがって、S3にキャッシュしないアーキテクチャでは元画像の削除については特になにもせず、キャッシュするアーキテクチャではS3のキャッシュだけを削除します。

CloudFrontのキャッシュ有効期間

CloudFrontのキャッシュ有効期間は、元画像の削除後にどれくらいの期間キャッシュに残っていても許容されるかを考慮して設定します。一般的なシステムでは元画像を削除したら画像へのリンクもなくなるためユーザーに配信されることはありません。それでもクローラーや魚拓的なサービスによるアクセスを考えると削除はしておきたいところです。

アーキテクチャの選択

このシステムではS3にキャッシュする構成を選択しました。異なるエッジロケーションからの初回アクセスも高速に配信したいこと、ユーザーによる削除があるためCloudFrontのキャッシュ期間を短めにしておきたいが、有効期間切れ後の初回アクセスも高速に配信したいことが理由です。

導入した感想

この機能を導入することで、画像のとりまわしがとても楽になりました。レイアウトに合ったサイズで気軽に配信できるのは思った以上に楽です。

アーキテクチャが複雑になったため、実装に時間がかかったのと運用の負荷も多少あると思います(今のところはほぼありませんが)。メリットとコストのトレードオフで正解はひとつではありませんが、シンプルな方にしておけばよかったかなと今でも考えます。結局のところimgixが使えれば一番よかったですね。

とにかく、実装自体は技術的には大変楽しかった。画像処理のLambdaはRustで書いていて、bindgen経由でlibwebpを呼んでいます。bindgen使ったの始めてでしたがとても簡単ですね。C言語で書かれたソフトウェアを使いたいときにRustは良い選択肢だと思います。