「ユーザーが画像をアップロードできる機能」は、Webアプリの定番要件です。GCPで設計する場合、考えることは大きく2つに分かれます。画像本体をどこに保存するかと、画像にまつわる情報(誰の画像か、どこにあるか)をどこに保存するかです。
この記事では、Cloud Storageとデータベースの役割分担を軸に、アップロードの2つの実装パターン、配信、後続処理、整合性の注意点までを一通り設計します。これまでのシリーズで整理した「保存先の選定」を、1つの機能に統合する実践編です。
保存先サービス全体の分類は『GCPのストレージ・データベースはオブジェクトストレージ・アプリケーションDB・DWHの3分類で選ぶ』で扱っています。
本体はCloud Storage、メタデータはDBが原則
最初に結論です。役割分担は以下で固定して考えます。
| データ | 保存先 | 理由 |
|---|---|---|
| 画像ファイル本体 | Cloud Storage | 容量が読めないバイナリの置き場はオブジェクトストレージが最適 |
| メタデータ(所有者、パス、サイズ、状態など) | DB(Cloud SQLやFirestore) | 「このユーザーの画像一覧」のようにクエリする対象だから |
それぞれ逆をやるとどうなるかを押さえると、この分担の必然性が分かります。
- 画像本体をDBに入れない(BLOBカラムに保存しない)
- DBのストレージは高価で、バックアップも肥大化します
- 画像の読み書きのたびにDBコネクションと帯域を消費し、本来のクエリ処理を圧迫します
- メタデータをCloud Storageだけで管理しない
- オブジェクトストレージは「キーを指定して出し入れする」のが基本で、属性を条件にした検索ができません
- 「ユーザーAの画像を新しい順に20件」はDBのクエリであって、バケットの一覧操作でやるものではありません
DB側には、たとえば以下のようなメタデータを持たせます。
- 所有ユーザーのID
- バケット名とオブジェクトパス(画像本体への参照)
- コンテンツタイプ、サイズ
- アップロード状態(後述する
pending/uploadedなど) - 作成日時
DBにCloud SQLとFirestoreのどちらを使うかはアプリ全体のDB選定の話なので、『Cloud SQL・Spanner・Firestore・Bigtableの違いと使い分け』に委ねます。以降の設計はどちらでも成り立ちます。
アップロードの2パターン
アップロード経路には、アプリケーションサーバを経由させるかどうかで2パターンあります。
アプリ経由でアップロードする
クライアントがアプリケーションサーバ(Cloud RunやGKE上のAPI)にファイルを送り、サーバがCloud Storageへ書き込む構成です。
sequenceDiagram participant Client as クライアント participant App as アプリ (Cloud Run) participant DB as DB (メタデータ) participant GCS as Cloud Storage Client->>App: 画像本体を送信 App->>App: 認証・バリデーション App->>GCS: 画像本体を書き込み App->>DB: メタデータを保存 App-->>Client: 完了を返す
- 利点
- 実装が素直で、バリデーション(ファイルタイプ、サイズ制限)やウイルススキャンをサーバで完結できます
- クライアントにCloud Storageの存在を見せずに済みます
- 欠点
- 画像のバイト列がアプリサーバを通過するため、アップロードのたびにサーバのCPU・メモリ・帯域を消費します
- 大きいファイルや高トラフィックでは、アプリのスケールがファイル転送に引きずられます
小規模なうちはこれで十分です。問題になるのは転送量が増えてからです。
署名付きURLでクライアントから直接アップロードする
署名付きURL(signed URL)は、「このオブジェクトパスに、この期限まで、このメソッドでアクセスしてよい」という許可を埋め込んだURLです。サーバはURLの発行だけを行い、ファイル本体はクライアントからCloud Storageへ直接送ります。
sequenceDiagram autonumber participant Client as クライアント participant App as アプリ (Cloud Run) participant DB as DB (メタデータ) participant GCS as Cloud Storage Client->>App: アップロードしたい (ファイル名・タイプ) App->>App: 認証・バリデーション App->>DB: メタデータ作成 (status=pending) App->>GCS: 署名付きURLを発行 App-->>Client: 署名付きURLを返す Client->>GCS: 画像本体を直接PUT Client->>App: アップロード完了を通知 App->>GCS: オブジェクトの存在を確認 App->>DB: status=uploaded に更新
流れは以下です。
- クライアントが「アップロードしたい」とアプリに申告します。ファイル本体はまだ送りません
- アプリが認証と申告内容のバリデーション(許可するコンテンツタイプ、サイズ上限)を行います
- アプリがDBにメタデータのレコードを先に作ります。状態は
pending(アップロード待ち)です - アプリが保存先のオブジェクトパスに対する署名付きURLを発行します。パスはサーバ側で決め、クライアントに決めさせません
- アプリが署名付きURLをクライアントに返します
- クライアントが署名付きURLに向けて画像本体を直接PUTします。アプリサーバはこの転送に関与しません
- クライアントがアプリに完了を通知します
- アプリがCloud Storage上にオブジェクトが実際に存在するか(サイズやタイプが申告どおりか)を確認します
- 確認できたらメタデータを
uploadedに更新し、以降このレコードが有効になります
- 利点
- ファイル転送がアプリサーバを通らないため、アプリのリソースをファイルサイズから切り離せます
- 欠点
- フローが多段になり、後述する「途中で止まった場合」の考慮が必要になります
転送量が大きいサービスでは、このパターンが定石です。
保存先が2つになることによる整合性の注意点
本体をCloud Storage、メタデータをDBに分けるということは、1つのアップロードで2つの保存先に書き込むということです。トランザクションで括れないため、途中で止まると不整合が起きます。
- オブジェクトだけあってレコードがない(孤児オブジェクト)
- 先にファイルを置いてからDBに書く順序だと、DB書き込み失敗でこれが起きます
- レコードだけあってオブジェクトがない(孤児レコード)
- 先にDBに書いてからファイルを置く順序だと、アップロード中断でこれが起きます
対策は、順序と状態遷移で「不整合を検出できる形」にしておくことです。上のシーケンスがまさにその形になっています。
- レコードを
pendingで先に作る(手順3)ので、アップロードが中断しても「pendingのまま古いレコード」として残り、何が起きたか後から判別できます - 完了通知を受けてもすぐ信用せず、オブジェクトの実在を確認してから
uploadedにします(手順8・9) pendingのまま一定時間を過ぎたレコードは、定期処理で掃除します。逆方向の保険として、DBに参照されないオブジェクトをCloud Storageのライフサイクル管理や定期ジョブで削除する運用も組み合わせます
「2つの保存先に書くときは、順序を決めて、途中で止まった状態を検出可能にする」という考え方は、画像に限らずファイル+メタデータ構成すべてに使い回せます。
表示するときの配信経路
アップロードした画像の表示にも、公開範囲に応じた選択肢があります。
- 誰でも見てよい画像(公開コンテンツ)
- バケット(またはパス配下)を公開設定にし、URLで直接配信します
- 配信量が多いなら手前にCloud CDNを置いてキャッシュします
- 本人や許可されたユーザーだけが見る画像(非公開コンテンツ)
- 読み取り用の署名付きURLを都度発行して返します。アップロード時と同じ仕組みを読み取り方向に使うだけです
- アプリ経由で画像バイト列をプロキシする構成は、アップロード同様にサーバ資源を食うため、署名付きURLで済むなら避けます
サムネイル生成などの後続処理は非同期にする
アップロード後には、サムネイル生成・リサイズ・画像検査などの後続処理がつきものです。これらはアップロードの応答と同期でやらないのが基本です。
Cloud Storageは「オブジェクトが作成された」というイベントを発行できるため、これを起点に非同期処理をつなげます。
- Cloud Storageのオブジェクト作成イベントをEventarcで受けてCloud Runのサムネイル生成処理を起動する
- 複数の後続処理(サムネイル生成、検査、通知)に配りたい場合はPub/Subでファンアウトする
このとき、Pub/Subに流すのはファイル本体ではなくオブジェクトパスなどの参照情報です。イベント駆動の構成とEventarc / Pub/Subの使い分けは『GCP Pub/Subと周辺サービスの役割分担』で扱っています。
生成したサムネイルもCloud Storageに置き、メタデータ(元画像との対応、生成状態)をDB側に持たせます。役割分担は元画像と同じです。
まとめ
本記事の内容をテーマごとに振り返ります。
まず、保存の役割分担です。
| データ | 保存先 |
|---|---|
| 画像本体 | Cloud Storage |
| メタデータ(所有者・パス・状態など) | DB(Cloud SQL / Firestore) |
アップロード経路は転送量で選びます。
| 経路 | 向いている場面 |
|---|---|
| アプリ経由でアップロード | 小規模。バリデーションやスキャンをサーバで完結できる |
| 署名付きURLで直接アップロード | 転送量が大きい。ファイル転送をアプリサーバから切り離せる |
配信は公開範囲で分けます。
| 公開範囲 | 配信方法 |
|---|---|
| 公開コンテンツ | 公開バケット+Cloud CDN |
| 非公開コンテンツ | 読み取り用の署名付きURL |
サムネイル生成などの後続処理は、Cloud Storageのオブジェクト作成イベントを起点に、Eventarc / Pub/Subで非同期に行います。
設計の肝は2点です。ファイルのバイト列をアプリサーバとDBに通さないこと、そして保存先が2つに分かれる以上、順序と状態遷移(pendingからuploadedへ)で不整合を検出可能にしておくことです。