豪鬼メモ

一瞬千撃

Fakebook: ゼロから作るSNS その2 メディアデータの管理

SNSの「映え」文化を支えるのは画像であり、画像の管理機能にはそこそこ気合を入れなければいけない。今回はその実装の詳細について解説する。DBを使わずにS3でメディアデータを管理する方法と、UIと連携する方法について主に述べる。実運用を想定したコスト試算についても述べる。

SNSにおける画像の利用形態

SNSにおいて、画像は大きく分けると二つの利用形態がある。ユーザプロファイルで利用される画像と、投稿に埋め込まれる画像だ。Fakebookにおいては、ユーザプロファイルで利用される画像はアバター画像のみだが、カバー写真やら背景画像やらを設定できるSNSもある。

アバター画像は、ユーザ名の横に表示される正方形の画像だ。デフォルトではユーザIDから生成される幾何学模様のアイコン(Identicon)が表示されるが、それを任意の画像に置き換えることができる。個々のユーザを一目で識別しやすくするためと、サイトに愛着を持ってもらうために、アバター画像は有用だ。プロファイルの画像はユーザ毎に固定で、一定の種類以上には増えず、更新は既存のものを上書きすることで行われる。

記事に埋め込まれる画像は、記事が書かれる度にどんどん増えていく。既存の画像を上書きすることはなく、新しい画像を追加していく使い方になる。

記事内の画像は、アイキャッチとしての役割も持つ。投稿一覧画面でスニペットの横にフローティング画像として置かれるのだ。記事を外部公開する際のOGPスニペットでもこの画像は使われる。つまり、シェア用のカードにも載せられる。

以上の機能を持つことから、SNS黎明期から画像は重要な役割を果たしている。画像管理機能の使い勝手はSNSのUXの質を大きく左右する。また、画像はデータ量が多いことから、スケーラブルかつコスパ良くデータ管理をするストレージ層も必要となる。

ユーザが使う機能

記事の投稿フォームの右上に、ユーザメンションボタンとともに、「既存画像埋め込み」ボタンと、「新規画像埋め込みボタン」がある。前者は、既にアップロードしてライブラリ内にある画像を選択して記事にマーカーを埋め込む機能だ。後者は、新たに画像をアップロードしてライブラリ内に登録すると同時に、その記事にマーカーを埋め込む機能だ。



画像の配置方法に関してはマークダウンの拡張で指定できるようにしていて、複数枚のグリッド表示や左右のフローティング表示ができるなど、自由度はかなり高い。ここのUXには結構な拘りがある。

ライブラリ内の既存の画像を確認するために、画像一覧画面もある。ここでは画像を消すこともできるし。一覧の中では画像は新しい順に並べられる。

アバター画像を登録する際には、自分のユーザ詳細画面で画像をアップロードして行う。その際に、画像の中のどこを切り取るかを指定する簡易クロップエディタが起動する。

画像管理サブシステムの前提条件

裏側の画像管理機能をどうやって実現しているか見ていこう。まずは基本方針を考える。

画像などのメディアデータを扱う場合、データベースにバイナリを入れたり、ファイルシステムにファイルを置いたりする方法だと、運用が面倒くさい。可用性の確保や容量制限やバックアップの作成に独自の手順を必要とするからだ。それよりは、いわゆるクラウドストレージを使ったほうが楽だ。

FakebookではストレージサービスとしてAmazon S3(Simple Storage Service)を使うことにしていて、開発中はMinIOのDockerインスタンスを立ててS3のエミュレーションをしている。ここではその構成でのデータ管理の概要について述べる。また、開発環境および本番環境での構築と運用についても述べる。

S3のデータ管理の概要

S3は、バケットという単位の中に任意の名前付きオブジェクトを格納する仕組みである。言い換えると、バケット毎にkey-valueストアがあり、キーがファイル名、valueがオブジェクトのバイナリということになる。キーには "/" で区切ったディレクトリ構造を模した文字列を使うことが通例だが、"/" に特別な意味はなく、オブジェクトはキーの完全一致で識別されるとともに、キーの前方一致によるリスト機能が提供されるだけである。

投稿内に埋め込む画像は "fakebook-images" バケット内に置かれる。その中に、以下の構造でオブジェクトが置かれる。元画像はクライアントから直接アップロードされ、サムネイルはシステム側で自動的に作られる。

  • {userId}/masters/{revYYYYMM}/{time8}{hash8}.{ext}
  • {userId}/thumbs/{revYYYYMM}/{time8}{hash8}_image.webp

`{userId}` はユーザIDである。`{revYYYYMM}` は、作成日時のYYYYMM値を999999から引いた値である。`{time8}` は月内のタイムスタンプを最大値から引いた8桁の16進数である。`{hash8}` は衝突回避のための8桁の16進数である。以下に例を示す。`{ext}` は画像形式に対応する拡張子である。

  • 0001000000000002/masters/797491/892af0b246bf3ec1.jpg
  • 0001000000000002/thumbs/797491/892af0b246bf3ec1_image.webp

S3では、キーは文字列の辞書順で並べられる。前方一致検索ができるので、キーにユーザIDを接頭させると、ユーザごとのオブジェクトを検索できるようになる。また、その後に固定長の日付をつければ、ユーザ毎に日付の順番にオブジェクトが並べられることになる。逆順に辿るAPIは無いので、新しい順で見たい場合には、日付の最大値から現在の日付を引いた値を使えば良いことになる。また、YYYYMMを単位とすることで、月ごとにオブジェクトが分類できるので、月のクォータ管理ができる。

{revYYYYMM}接頭辞は、年単位と月単位の範囲検索にも使える。例えば2025年に投稿したオブジェクトのみに絞り込みたいなら、999999-202400=797600なので、"0001000000000002/masters/7976" で前方一致をかければ良い。月単位の場合は月クォータの算出と同様に月の接頭辞全体を使えばよい。UI上では、YYYY/MMを直接入力させてもよいし、カレンダー風の表示をしても良いし、年を指定すると月ごとのオブジェクト数を集計してドリルダウンで絞り込ませても良い。全体の集計をしないのが要点である。月単位や年単位のオブジェクト数は限定されるので、実質的に計算量はO(1)とみなせる。

アバター画像など、個々のユーザが一つずつしか持たないプロファイル系の画像は、"fakebook-profiles" というバケット内に置かれる。その中に、以下の構造でオブジェクトが置かれる。元画像はクライアントから直接アップロードされ、サムネイルはシステム側で自動的に作られる。

  • {userId}/masters/{type}.{ext}
  • {userId}/thumbs/{type}_icon.webp

`{userId}` はユーザIDである。`{type}` は、データの種類を表すが、現状では "avatar" のみである。`{ext}` は画像形式に対応する拡張子である。以下に例を示す。

  • 0001000000000002/masters/avatar.png
  • 0001000000000002/thumbs/avatar_icon.webp

プロファイル系の画像は、ユーザと種別ごとに単一なので、画像単体のサイズのみが制限され、クォータの制限はない。

以上の命名規則によって、DBでキーやメタデータを管理することなく、ストレージサービスのみで、メディアデータを管理することができる。

S3単体管理 vs DBでのメタデータ管理

どのユーザがどのファイルを登録したかというメタデータをDBのテーブルで管理すれば、キーの前方一致検索しかできないというS3の制限に対する回避策は必要なくなる。しかし、そうしないといけないという理由がないのなら、DBでのメタデータ管理は導入したくない。S3側とDB側にまたがるトランザクションの整合性を確保するのが結構面倒くさいからだ。例えば、新しいオブジェクトを登録するなら、DB側にメタデータを入れる予約をして、それに基づいてS3にオブジェクトを作って、成功したらメタデータを確定させるという処理になる。そのそれぞれの過程の中間状態でシステムクラッシュが起きうるので、予約状態のメタデータに対応するS3オブジェクトを破棄したり、予約状態のメタデータを破棄したりといったゴミ掃除も必要になる。S3単体だと、中間状態がないので、管理が簡単だ。オブジェクトの登録・更新・削除の処理の原子性はS3が確保してくれる。それ以外のデータとの整合性を気にしなくて良いならば、面倒くさい多層コミット的な処理は必要ないし、明示的なゴミ掃除も必要ない。

スケーラビリティや導入・保守コストのことも考えるべきだ。S3単体運用ならば、金さえ出せば、何も考えなくても、どこまでもスケールするし、可用性も勝手に担保される。単純なクエリしか受け付けないので、クエリ分析のような概念からも解放される。一方で、メタデータ管理用のDBを導入すると、スケーラビリティがそれで制限されてしまう。スキーマを定義し、クエリを最適化し、レプリケーションで可用性を担保し、バックアップを管理する必要がある。サービスの拡大に応じて自前で垂直分割や水平分割を実施しなければならない。それでは、S3を使っている旨味が半減してしまう。

しかし、S3だけで運用するということは、S3の貧弱な検索機能を受け入れるという意味でもある。リスト表示機能は、予め決めておいた単一の順序でしか行えない。今回はファイルを新しい順に表示するUIだけを提供すると割り切っている。古いファイルを探すには何ページもめくってサムネイルを眺める必要がある。アップロードしたデータのローカルでのファイル名は失われているので、ファイル名で文字列検索することもできない。

SNSでの画像置き場としての利用では、S3単体で問題ないと判断している。基本的には記事を執筆するUIで画像をアップロードして、その瞬間に画像を参照するマーカーが記事に埋め込まれるので、画像単体を検索できる必要はあまりない。記事の方を検索すればよいのだ。ほとんどのユーザは、メールやメッセージアプリの添付ファイルのようなノリで画像を記事に貼り付けて、その記事を関係者に閲覧させる。そしてその記事の賞味期限が過ぎたら、貼り付けた画像のことは忘れてしまう。わざわざ古い画像を検索して再利用した記事を書く頻度は低いだろう。記事を消したとしても、そこで使った画像をわざわざ消すような律儀なユーザはほとんどいないだろう。なので、新しい順で画像一覧が表示できて、かつユーザごとの容量管理ができれば良く、それらはS3単体で実現できる。

ローカルのファイル名をS3メタデータとして持たせることは可能だ。メタデータによる検索はできないにしても、ローカルのファイル名を表示するだけでも便利な場合があるかもしれない。しかし、検討の末、その機能は除外した。なぜなら、潜在的なセキュリティリスクになるからだ。「ゴミ顧客.jpg」みたいなファイル名の画像を迂闊にアップロードした場合、それはHTTPのヘッダに含まれて他のユーザにも見られてしまうことになる。EXIFのコメント等でも同様のリスクがあるが、EXIFのコメントをわざわざ書くユーザにはそれなりのリテラシがあることが期待できる。ファイル名は誰しもつけねばならないので、不特定多数が使うSNSでファイル名を暴露するのはリスクが高すぎると判断した。

画像アップロード処理

画像をS3にアップロードするにあたっては、一定のプロトコルが必要になる。巨大なデータをS3にアップロードするとなると、バックエンドサーバが一旦データを預かってからS3に転送するという方法は取りたくない。よって、クライアントが直接S3にデータをアップロードすることになるが、好き勝手にアップロードさせるわけにはいかない。

そこで、presigned-POSTという方法を採る。最初に、「どのキーにどんなデータをアップロードするか」を決めて、それを示すpresignをS3に発行させる。実際には、ステージング領域にデータをアップロードするというpresignを作る。そして、クライアントにpresignを渡し、クライアントはpresignのトークンを使って、許可されたPOSTのアップロード操作をS3に行う。それが完了したら、バックエンド側の責任で、ステージング環境のデータを本番環境に移動させる。具体的な流れを以下に箇条書きする。

  • ユーザは、ローカルファイルシステムから、アップロードしたいファイルを選ぶ。
  • クライアントは、アップロードするファイルの情報をバックエンドに送る。
  • バックエンドは、S3直PUTの署名付きPOST情報のpresignをS3から取得し、クライアントに返す。前処理として以下を行う:
    • ファイル単体のサイズが制限値(10MB)以下か確認する。
    • 新規のファイルサイズと当月全ての登録ファイルの合計が月間クォータ(100MB)の制限内か確認する。
    • 拡張子に対応するMIMEタイプがJPEGPNG、WEBP、HEICのどれかであるか確認する。
  • クライアントは、署名情報に基づき、S3のステージング領域へ直接アップロードする。
  • クライアントは、バックエンドに操作完了を報告する。
  • バックエンドは、ステージング領域のデータを本番領域に移動させるfinalize操作を行う。前処理として以下を行う:
    • パスがステージング領域のものか確認する。
    • 単体のデータサイズと月間クォータが制限値以内か再確認する。
    • ファイルの先頭データを見て、ファイル形式を判定する。
      • クライアントが報告したMIMEと、拡張子のMIMEと、ファイル先頭から判定したMIMEの全てが同一か。
    • エラーがあれば、ステージングのデータを削除して終了する。
  • バックエンドは、登録画像に対応するサムネイルを作るジョブキューをRedisに登録する。
  • メディアワーカーは、ジョブキューを読んで非同期的にサムネイルを作成する。

アバター画像に許される画像形式も通常画像と同様にJPEGPNGなどである。ただし、ファイルサイズの上限は1MBである。アバター画像はユーザ毎に1枚であり、新しいアバター画像が登録される際には、古いものは削除される。よって、クォータの管理は行わない。

サムネイル作成

サムネイルの作成処理は、mediaWorkerという別プロセスが担当する。mediaWorkerはRedisのキューを監視し、新規の通常画像やアバター画像が登録された直後にそれを読み出して、対応する場所にサムネイル画像を生成する。通常画像のサムネイルのサイズは512*512のサイズで、アバター画像のサムネイルのサイズは128*128である。入力画像が正方形ではない場合、長辺が制限一杯の長さになるように縮小される。画像形式はWEBPになる。入力のピクセル数は50MPに制限される。

投稿一覧画面やユーザ一覧画面で表示されるアバター画像は32*32に縮小表示され、ユーザ詳細画面に表示されるアバター画像は64*64に縮小表示される。それでも、サムネイルは128*128の解像度で作成される。最近の高解像度環境ではディスプレイ倍率が1.5や2以上に設定されることが多いので、表示設定が64*64であっても、128*128の解像度にしておくと、より精細に表示されることになる。縮小処理の補間アルゴリズムはブラウザ依存だが、一般論として、縮小倍率が整数倍であるほうが滲みの少ない結果が得られる。

サムネイルの作成処理はSharpというライブラリで行う。Node.jsはシングルスレッドだが、Sharpは内部でネイティブスレッド(libvips)を用いて並列実行する(並列度は環境/設定依存)。ワーカー側ではキューからの取り出しで非同期処理を同時に複数進めることでアプリケーションレベルの並列性も確保しており、同時実行数のデフォルトは2である。Redisのキューは複数プロセス/複数ホストで監視できるため、必要に応じてワーカー数を増やして全体の並列度を上げられる。

サムネイルを作る際には、各画素のRGB値を変換したsRGB色空間に直した上で、ICCプロファイルを剥奪している。したがって、sRGB色空間より広い色域のプロファイルを持っているマスター画像の色はsRGBの領域に丸められ、若干色味が変わる可能性がある。しかし、きちんと座標系を変換しているので、単にICCプロファイルを剥ぎ取った場合のような色ズレは発生しない。

サムネイルの作成タスクは、Redis上でのみ管理される。したがって、画像がアップロードされてから、それに対応するサムネイル作成タスクがワーカーに受領されるまでにバックエンドサーバやRedisが死んだ場合、サムネイルは作成されない可能性がある。サムネイル作成の途中でワーカーが死んだ場合も同様である。これに関しては、何ら対策をしない。万が一、サムネイルの作成に失敗しても、再アップロードしてもらえば良いと割り切る。ユーザがそのファイルを消してしまったとしても、マスターはサーバ側にあるので、それをダウンロードしてから再アップロードしてもらえば良い。

その他の処理

画像を削除する際には、マスター画像を削除するとともに、サムネイルも削除する。また、ユーザを削除する際には、そのユーザが持っている画像を全て削除する。

画像を一覧する際には、"{userId}/masters/" の前方一致でオブジェクトのリストを取得する。S3におけるキーのリスト取得のAPI(ListObjectsV2Command)では、取得数(MaxKeys)と継続トークン(ContinuationToken)をパラメータとして渡すことになっている。2ページを表示する際には、1ページ目を表示する際に返された継続トークンを渡すというインターフェイスになっている。なので、2ページ目以降をいきなり表示する場合、前のページまでのリスト取得を暗黙的に繰り返す必要がある。

通常の運用では、オブジェクトの作成はpresigned-POSTを介してクライアントがS3に対して直接通信して行い、オブジェクトのデータの取得は、公開URLを介してクライアントがS3に対して直接通信して行われる。しかし、管理用に、バックエンドがS3に対して直接データの保存やデータ取得を行うAPIも用意してある。

セキュリティ向上策

ユーザがアップロードした任意の画像ファイルが他のユーザのブラウザに表示されるので、悪意のあるユーザがブラウザクラッシャーを埋め込まないように対策する必要がある。SVGはブラウザに高負荷の計算を強いるスクリプティング攻撃の余地があるため、ユーザからの投稿は受け付けないようにする。JPEGPNG、WEBP、HEICに関してはスクリプティングの問題はないが、ピクセル数を上げることでメモリを消費させる余地がある。よって、ファイルサイズを10MBに制限するだけではなく、総ピクセル数を50MPに制限し、一辺の最大長を10000Pに制限する。

アップロードされた画像ファイルの最終的な検査は、finalize操作にて行われる。ステージング環境に保存されたファイルのS3のメタデータを調べて、単体のファイルサイズが制限以下であることと、月間の合計のファイルサイズが制限以下であることを確認する。さらに、画像の先頭512KBを読み込んで、実際のファイル形式を判定する。S3のメタデータにあるMIME形式と、拡張子から判断するMIME形式と、ファイルの内容から判定するMIME形式の全てが合致していることを確認する。また、ファイル形式ごとのルールでバイナリを解釈して、縦横のピクセル数と総ピクセル数を判定する。

検査の途中で違反を検知すれば、ステージング環境のファイルを消してから、エラーを報告する。よって、ステージング環境には処理途中のファイル以外は存在しないことになる。しかし、処理途中で何らかの理由でサービスが死んだ場合、ステージング環境にゴミが残ることになる。それに関しては、S3のLifecycle設定で、古いファイルを消すことで対処する。

乱用防止策

デフォルトの設定では、各アカウントが月毎に合計100MBの画像ファイルをS3上に登録できる。アカウントはメアドさえあれば作れるし、Gmailの `+xxx` 接尾辞などでメアドは際限なく作れるので、やろうと思えば容量無制限のファイルサーバとして利用できることになる。

その対策としては、まず、単体のファイルサイズ制限、月間クォータ、1時間あたりに登録できるファイル数、ファイル形式のホワイトリスト管理などの基本的な制限をする。その上で、S3側にRefererの制限をかける。つまり、Fakebookのメディアデータは、Fakebook上の記事に埋め込んだ場合にしか、表示できないようにする。ただし、Refererの制限はあくまでChrome等の主要ブラウザが自主的に守る規約にすぎないので、Refererを偽装するユーザエージェントには効果がない。そもそも、Refererを偽装するような「やる気」があるなら、ログインして正規ルートでデータをダウンロードすれば良いので、どんな対策も効果がない。Refererによる制限の目的は、他サイトのimg要素のsrc属性の値として気軽に指定されないことである。S3の設定に関しては、本番環境の設定の記事で詳述する。

最悪のケースを考えてみよう。ユーザアカウントを10000個作られて、それぞれで100MBのデータを保存されて、1TBの容量を数時間以内に消費されてしまうかもしれない。その分だけ、運営者はAmazonに料金を払わねばならなくなる。メアドのドメインで制限しようにも、接続元のIPアドレスで制限しようにも、対策は後手に回ってしまう。大手サービス並の多重の乱用検知システムを作れば対抗できるかもしれないが、いずれにせよそれなりの費用がかかってしまう。

どんな対策を施しても乱用を完全に防ぐことはできないが、他のサービスよりも乱用のコスパが悪い状態にすることで、狙われにくいようにすることはできる。例えばS3の総容量を300GBに制限しておけば、最悪の出費はそこまでに抑えられる。その制限に到達すると全ユーザの画像アップロードがエラーになるだろうが、一時的には仕方ない。それを検知して、乱用者のデータを消したりIPアドレスドメインをブロックして回ることになる。乱用者の立場としても、頑張ってもたかが300GBの容量しか利用できないのであれば、粘着する動機づけは低く、他のサービスを狙った方が美味しいという判断をするだろう。とはいえ、ガチなサービスではアップロード停止は許されないので、結局は多重の乱用検知システムを運用することになるだろう。

ユーザのブラウザをフリーズさせ、データ転送量を増やすことを目的とする悪戯として、投稿の本文に大量の画像を貼るという攻撃が考えられる。緩和策として、img属性にlazy属性やasync属性をつけるという緩和策を採っている。また、記事毎に貼れる画像の数も制限している。しかし、複数の記事かつ複数のアカウントで閾値ギリギリの攻撃をされるかもしれない。結局のところ、愉快犯による嫌がらせは避けられないが、それでも機能停止や致命的なコスト増加にならないように最低限の対処はしている。

クライアント側での最適化オプション

単体ファイルサイズ10MBで月間クォータ100MBという制限では、昨今のデジカメの高解像度の画像をそのままで多数保存することは現実的ではない。転送時のネットワーク帯域も無駄になる。Webブラウザ上で閲覧するにあたって、解像度が高くても縮小表示されるだけで意味がない。かといって、ユーザが自分で画像エディタを使って縮小処理をするのは面倒すぎる。よって、アップロード時に自動的にWeb用最適化を施すオプションをつけた。

画像をアップロードする際に、ブラウザのCanvas機能を使って勝手にWeb用縮小画像を作る。それは、WEBP形式で、総ピクセル数5MP以下かつ長辺2400P以下に縮小したものだ。大抵、どの画像でも500KBから1MBのファイルサイズになる。色空間はsRGBに統一し、ICCプロファイルは剥ぎ取る。そして、元画像のファイルサイズが2.5MBを超えているか、総ピクセル数が5MBを超えているか、長辺が2600ピクセルを超えている場合、デフォルトで縮小画像をアップロードするようにチェックがつけられる。チェックを外せば、元画像をアップロードすることもできる。

アバター画像も同様に、WEBP形式で、長辺1200P以下に縮小される。ユーザ詳細画面でアバター画像をクリックすると拡大表示されるため、その際に精細に表示されるように、アイコンサイズよりも大きい画像をアップロードできるようにしている。

ストレージサービスのラッパー

Fakebookは、S3やその互換のMinIOを利用することを前提としている。本番でAWS上で運用するならそれでよいが、GCP上だと困る。そこで、少しの変更でGCS(Google Cloud Storage)も利用できるように、ストレージ層を抽象化している。

// src/models/storage.ts -- other structures are also defined
export type PresignedPostRequest = {
  bucket: string;
  key: string;
  contentTypeWhitelist: string;
  maxBytes?: number;
  expiresInSec?: number;
};

export type PresignedPostResult = {
  url: string;
  fields: Record<string, string>;
  objectKey: string;
  maxBytes: number;
  expiresInSec: number;
};

export type StorageObjectId = {
  bucket: string;
  key: string;
};

// src/services/storage.ts
export interface StorageService {
  createPresignedPost(req: PresignedPostRequest): Promise<PresignedPostResult>;

  headObject(objId: StorageObjectId): Promise<StorageObjectMetadata>;

  publicUrl(objId: StorageObjectId): string;

  listObjects(objId: StorageObjectId, range?: StorageObjectListRange):
    Promise<StorageObjectMetadata[]>;

  loadObject(objId: StorageObjectId, range?: StorageObjectDataRange):
    Promise<Uint8Array>;

  saveObject(objId: StorageObjectId, content: Uint8Array, contentType?: string):
    Promise<void>;

  copyObject(srcId: StorageObjectId, dstId: StorageObjectId): Promise<void>;

  moveObject(srcId: StorageObjectId, dstId: StorageObjectId): Promise<void>;

  deleteObject(objId: StorageObjectId): Promise<void>;
}

// src/services/storageFactory.ts
export function makeStorageService(driver: string): StorageService { ... }

StorageServiceインターフェイスを実装するクラスのオブジェクトをmakeStorageServiceが返すようになっていて、現状ではS3のAPIを使う実装であるStorageServiceS3のみをサポートしている。GCSのAPIを使う実装であるStorageServiceGcpとかいうのを実装して返すようにすれば、他を一切変更しなくても対応できる。

S3の利用料金

AWSの東京リージョン(ap-northeast-1)を前提として考える。S3で1GBを保持すると、最初の50TBまでは、月に1GBあたり0.025ドルかかる。GETのコストはリクエスト1000回あたり0.0004ドルかかる、GET以外のメソッドのコストはリクエスト1000回あたり0.005ドルかかる。データ転送量は1GBあたり0.114ドルかかる。

ユーザ動向を平均すると、次のようになるという仮定を置く。各アクティブユーザは、月に1MBの画像を10枚追加する。また、サムネイル画像1000枚を受信し、元画像200枚を受信する。実際のサムネイル画像の閲覧回数はそれより遥かに多いだろうが、キャッシュが利くものとする。記事投稿の際や画像管理画面での既存画像リストのリクエストは微々たるものなので無視する。

画像1枚をアップロードすると、ステージング領域へのpresigned-POSTが1回、ステージング領域から保存領域へのCOPYが1回実行される。それを月に10回やるなら、(0.005*2*10/1000) = 0.0001ドルである。サムネイルと元画像の受信の度にGETが走るので、0.0004*(1000+200)/1000 = 0.00048ドルである。合計すると0.00058ドルである。つまり、アクティブユーザ1万人あたり月5.8ドルの操作コストがかかる。サムネイルを1枚100KB、元画像を1枚500KBとすると、サムネイルの転送量は100MBで、元画像の転送量は100MBで、合計0.2GBなので、0.2*0.114 = 0.0228ドルである。つまりアクティブユーザ1万人あたり月228ドルの転送コストがかかる。容量コストに関しては、現在保持しているデータ量が1TBとすると、1000*0.025 = 25ドルかかる。

サービスが順調に成長する場合のシミュレーションをしてみよう。アクティブユーザ0人から、月に1000人増えると仮定して、各月の運用コストがどう増えるかを検討する。初月は23ドルで済むが、ユーザ数の純増とともにどんどんコストが膨らんで、3年後には月1000ドルを超える事がわかる。

ユーザ数 ストレージ容量 容量コスト 操作コスト 転送量 転送コスト 総コスト
1 1000 11 0.28 0.58 195.3 22.27 23.12
2 2000 33 0.83 1.16 390.6 44.53 46.52
3 3000 66 1.65 1.74 585.9 66.80 70.19
4 4000 110 2.75 2.32 781.2 89.06 94.13
5 5000 165 4.12 2.90 976.6 111.33 118.35
6 6000 231 5.78 3.48 1171.9 133.59 142.85
7 7000 308 7.70 4.06 1367.2 155.86 167.62
8 8000 396 9.90 4.64 1562.5 178.12 192.66
9 9000 495 12.38 5.22 1757.8 200.39 217.99
10 10000 605 15.12 5.80 1953.1 222.66 243.58
11 11000 726 18.15 6.38 2148.4 244.92 269.45
12 12000 858 21.45 6.96 2343.8 267.19 295.60
13 13000 1001 25.03 7.54 2539.1 289.45 322.02
14 14000 1155 28.88 8.12 2734.4 311.72 348.71
15 15000 1320 33.00 8.70 2929.7 333.98 375.68
16 16000 1496 37.40 9.28 3125.0 356.25 402.93
17 17000 1683 42.08 9.86 3320.3 378.52 430.45
18 18000 1881 47.03 10.44 3515.6 400.78 458.25
19 19000 2090 52.25 11.02 3710.9 423.05 486.32
20 20000 2310 57.75 11.60 3906.2 445.31 514.66
21 21000 2541 63.53 12.18 4101.6 467.58 543.28
22 22000 2783 69.58 12.76 4296.9 489.84 572.18
23 23000 3036 75.90 13.34 4492.2 512.11 601.35
24 24000 3300 82.50 13.92 4687.5 534.38 630.79
25 25000 3575 89.38 14.50 4882.8 556.64 660.52
26 26000 3861 96.53 15.08 5078.1 578.91 690.51
27 27000 4158 103.95 15.66 5273.4 601.17 720.78
28 28000 4466 111.65 16.24 5468.8 623.44 751.33
29 29000 4785 119.62 16.82 5664.1 645.70 782.15
30 30000 5115 127.88 17.40 5859.4 667.97 813.24
31 31000 5456 136.40 17.98 6054.7 690.23 844.61
32 32000 5808 145.20 18.56 6250.0 712.50 876.26
33 33000 6171 154.28 19.14 6445.3 734.77 908.18
34 34000 6545 163.62 19.72 6640.6 757.03 940.38
35 35000 6930 173.25 20.30 6835.9 779.30 972.85
36 36000 7326 183.15 20.88 7031.2 801.56 1005.59

表を見ると、まず、転送コストが大きいのが目に付く。CloudFrontを使ってもこの問題は変わらない。S3とCloudFrontの間の転送コストは無料だが、CloudFrontからインターネットへの転送コストはS3からの直接配信と同等の0.114ドル/GBだから、CloudFront上でキャッシュが効いたとしても何の解決にもならない。

累積的に増え続ける容量コストも地味に効いてくる。S3にはIntelligent Tieringという機能があり、アクセスが少ない古いデータは遅くて安いストレージに移してくれる機能があるが、それを有効にすると、少しはマシになる。低頻度アクセス層に入れられれば月0.0138ドル/GBと半分になり、アーカイブアクセス層に入れられれば月0.005/GBと25%になる。それでも容量コストが累積的であることには変わらないので、何も対策をしないといつか破綻する。いつかは、古いデータを消すなり、容量に応じて課金するなりの対策が必要になる。

コストモデルとビジネスモデルが合致しないと、スケールするシステムとは言えない。転送コストはアクティブユーザ数に比例するので、アクティブユーザ数に比例して収入が得られるビジネスモデルがあれば良いということになる。しかし、S3の転送コストがかなり高く、1ユーザあたり0.228ドルというのは結構な圧迫だ。それを緩和するには、Cloudflare R2やBackblaze B2等のより安いCDNサービスに乗り換えるのも現実的な解決策だ。それらはS3互換のAPIを提供しているので、STGYの現行の構成のままで利用できる。移行作業がそれなりに面倒であることを考えると、最初から安いCDNを使った方が良いかもしれない。

シミュレーションのコードを以下に示す。こんな簡単な試算でも、最初にやっておくとやっておかないとでは全然違う。

POST_PER_USER = 10
COPY_PER_USER = 10
MISC_OP_COST = 0.005 / 1000

GET_THUMBS_PER_USER = 1000
GET_MASTERS_PER_USER = 200
GET_OP_COST = 0.0004 / 1000
STORAGE_INC_PER_USER = 0.011

THUMB_GB = 0.1 / 1024
MASTER_GB = 0.5 / 1024

USERS_INC = 1000
STORAGE_COST_PER_GB = 0.025
SEND_COST_PER_GB = 0.114

month = 0
users = 0
storage = 0

while month < 36:
  month += 1
  users += USERS_INC
  storage += users * STORAGE_INC_PER_USER
  storage_cost = storage * STORAGE_COST_PER_GB
  op_cost = ((POST_PER_USER + COPY_PER_USER) * MISC_OP_COST +
             (GET_THUMBS_PER_USER + GET_MASTERS_PER_USER) * GET_OP_COST) * users
  send = ((GET_THUMBS_PER_USER * THUMB_GB) + (GET_MASTERS_PER_USER * MASTER_GB)) * users
  send_cost = send * SEND_COST_PER_GB
  sum_cost = storage_cost + op_cost + send_cost
  print(f"|{month}|{users}|{storage:.0f}|{storage_cost:.2f}|{op_cost:.2f}|{send:.1f}|{send_cost:.2f}|{sum_cost:.2f}|")

まとめ

画像などのメディアデータを管理するには、オブジェクトストレージであるS3を使うのが一般的だ。ファイルシステムというよりはむしろkey-valueストレージに近いAPIを備えていて、多数のレコードを管理するにはキーの命名規則をきちんと練っておく必要がある。ユーザ毎にリストを分け、月ごとにクォータを算出し、新しい順にレコード一覧を見るには、ユーザIDと月毎の逆順ラベルとタイムスタンプの逆順ラベルを繋げたキーを使うと良い。

アップロード処理はpresigned-POST方式を取り、クライアントとS3の間で直接アップロード操作をさせるとともに、バックエンド側ではファイル形式の確認をすべきである。悪意のあるユーザからの各種の攻撃に耐えるために、クォータの管理やRefererの制限などの策も実施すべきだ。

S3以外のストレージサービスも使えるように抽象化したAPIを使うようにすると、GCPなどでも同じ構成で運用できて便利だ。

S3運用の最大の問題はコストであり、特に転送コストが大きい。サービスの成長やユーザの行動を事前に想定した上で事前に料金をシミュレートしておくことが重要であり、場合によってはS3以外の互換サービスを利用することも検討すべきだ。