STGYのメディアストレージ

本記事では、STGYにおいて画像などのメディアデータをどのように管理するかについて説明する。Amazon S3または互換システムであるMinIOのAPIを使って単純かつ堅牢なデータ管理をするにはどうするかについて述べる。

画像一覧画面

前提条件

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

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

S3のデータ管理の概要

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

投稿内に埋め込む画像は "stgy-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)とみなせる。

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

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

サムネイル作成

サムネイルの作成処理は、mediaWorkerという別プロセス(またはoneWorkerのスレッド)が担当する。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はブラウザに高負荷の計算を強いるスクリプティング攻撃の余地があるため、ユーザからの投稿は受け付けないようにする。JPEG、PNG、WEBPに関してはスクリプティングの問題はないが、ピクセル数を上げることでメモリを消費させる余地がある。よって、ファイルサイズを10MBに制限するだけではなく、総ピクセル数を50MPに制限し、一辺の最大長を10000Pに制限する。

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

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

乱用防止策

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

その対策としては、まず、単体のファイルサイズ制限、月間クォータ、1時間あたりに登録できるファイル数、ファイル形式のホワイトリスト管理などの基本的な制限をする。その上で、S3側にRefererの制限をかける。つまり、STGYのメディアデータは、STGY上の記事に埋め込んだ場合にしか、表示できないようにする。ただし、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形式で、総ピクセル数4.5MP以下かつ長辺2400P以下に縮小したものだ。大抵、どの画像でも500KB前後のファイルサイズになる。色空間はsRGBに統一し、ICCプロファイルは剥ぎ取る。そして、元画像のファイルサイズが2MBを超えているか、総ピクセル数が6MBを超えているか、長辺が2800ピクセルを超えているか、最適化画像のバイト数が元画像より半分以下になった場合、デフォルトで縮小画像をアップロードするようにチェックがつけられる。チェックを外せば、元画像をアップロードすることもできる。

バックエンド側で対応している画像形式はJPEG、PNG、WEBPだが、アップロード画像の選択時にはHEICとTIFFとGIFとBMPとSVGをサポートしている。ブラウザ間の相互運用性を重視して、JPEG、PNG、WEBP以外は強制的に最適化画像をアップロードするようにしている。

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

TIFFの読み込みはブラウザのCanvas機能が対応していないので、別途UTIFライブラリを使って前処理をしている。それがICCプロファイルの処理に対応していないので、ICCプロファイルの付いたTIFF画像を入力すると色ずれが起こる。よって、TIFFを入力する場合にはsRGB色空間にしておく方が良い。あるいは、ICCプロファイル付きのPNG画像を代用しても良い。

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

STGYは、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ドルを超える事がわかる。

ユーザ数ストレージ容量容量コスト操作コスト転送量転送コスト総コスト
11000110.280.58195.322.2723.12
22000330.831.16390.644.5346.52
33000661.651.74585.966.8070.19
440001102.752.32781.289.0694.13
550001654.122.90976.6111.33118.35
660002315.783.481171.9133.59142.85
770003087.704.061367.2155.86167.62
880003969.904.641562.5178.12192.66
9900049512.385.221757.8200.39217.99
101000060515.125.801953.1222.66243.58
111100072618.156.382148.4244.92269.45
121200085821.456.962343.8267.19295.60
1313000100125.037.542539.1289.45322.02
1414000115528.888.122734.4311.72348.71
1515000132033.008.702929.7333.98375.68
1616000149637.409.283125.0356.25402.93
1717000168342.089.863320.3378.52430.45
1818000188147.0310.443515.6400.78458.25
1919000209052.2511.023710.9423.05486.32
2020000231057.7511.603906.2445.31514.66
2121000254163.5312.184101.6467.58543.28
2222000278369.5812.764296.9489.84572.18
2323000303675.9013.344492.2512.11601.35
2424000330082.5013.924687.5534.38630.79
2525000357589.3814.504882.8556.64660.52
2626000386196.5315.085078.1578.91690.51
27270004158103.9515.665273.4601.17720.78
28280004466111.6516.245468.8623.44751.33
29290004785119.6216.825664.1645.70782.15
30300005115127.8817.405859.4667.97813.24
31310005456136.4017.986054.7690.23844.61
32320005808145.2018.566250.0712.50876.26
33330006171154.2819.146445.3734.77908.18
34340006545163.6219.726640.6757.03940.38
35350006930173.2520.306835.9779.30972.85
36360007326183.1520.887031.2801.561005.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を使った方が良いかもしれない。

まとめ

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

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

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

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

Next: STGYのその他の機能