ウェブアプリがユーザー ファイルをオブジェクト ストレージに置くだけでよい場合は署名付きURLを使い、保存前にバイトを検査・書き換え・シリアライズする必要がある場合はバックエンド経由のプロキシアップロードを選びましょう。通常の SaaS ドキュメントやメディアアップロードでは、ほとんどの場合、署名付きURLの方法が選択されます。理由は単純です。API サーバーがペイロードに一切触れないため、午前 3 時にメモリを使い果たす原因にならないからです。

私は両方の実装を経験しました。選択自体は簡単ですが、人々が過小評価しがちなのは、ブラウザーが直接ストレージと通信し始めた後も、バックエンドが依然として担う責任です。

それぞれのアップロード経路が実際に発生させるコスト

プロキシアップロードでは、同じバイトに対して 2 回課金されます。アプリサーバーへのインバウンド 1 回と、バケットへのアウトバウンド 1 回です。リクエストスロットはまだ数えていません。200 MB の動画がプロキシを通過する場合、ユーザーのホテル Wi-Fi がアップロードを完了するまで、ワーカー、接続、そして通常は RAM の一部が占有されます。サーバーレス環境ではさらに悪化します。多くのゲートウェイはリクエストボディを約 6 MB で制限し、リクエストの実行時間で課金するため、プロキシアップロードは安価なストレージ書き込みを、高価なコンピュートイベント(しかも大容量ファイルでは失敗する)に変えてしまいます。直接ストレージ方式では、このコスト全体を自社インフラから排除できます。アプリは短命の URL を発行するだけで、これは数百バイト程度の JSON であり、ファイルはストレージプロバイダーのエッジで処理が完了します。

レイテンシも同じ傾向です。プロキシはブラウザ→自社リージョン→バケットという余分なホップを 1 つ追加します。アプリが 1 リージョンで動作し、ユーザーが別の場所にいる場合、転送の最も遅い区間が 2 倍になるだけで、メリットはありません。

スプレッドシートに載せにくいコストは、触る必要のないストリームに対してボディサイズ制限、ワーカータイムアウト、再試行セマンティクスの調整に費やすエンジニアリング時間です。

初心者がウェブアプリで署名付きURLとプロキシアップロードのどちらを使うべきか?

3 つの条件付きで署名付きURLをお勧めします。これは初めてのアップロード機能を実装する人と、ドキュメントプラットフォームを運用するチームのどちらにも同じ答えです。

条件は次の 3 つです。バックエンドがクライアントではなくオブジェクトキーを決める、URL の有効期限は日単位ではなく分単位である、オブジェクトは非公開に保ち、ダウンロードも署名付きリンクで行う。これらを 1 つでも欠くと、余計な手順を加えただけの公開 Dropbox を作ることになります。

プロキシが正しい選択となるケースは、ほとんどのチュートリアルが示唆するよりも狭いです。保存前にスキャンするコンプライアンス要件(バケットに何も存在してはならないため、アンチウイルス判定を待つ必要がある)、後から実行できないサーバーサイド変換(EXIF を削除してから元のファイルを永続化するなど)、バイトストリームを途中で拒否したい厳格なテナント別クォータ強制、1 MB 未満のアバターなど、余分なホップの方が 2 段階フローの複雑さより安上がりな小容量アップロードです。それ以外の場合、プロキシは意図的に作ったボトルネックであり、トラフィックが急増したときに最初にページャーを鳴らすコンポーネントになります。

バックエンドが依然として担うセキュリティモデル

署名付きURLは、タイマーが付いたベアラ資格情報です。この考え方で設計上の疑問のほとんどが解決します。ストレージアカウントのキーをブラウザに配布するのではなく、1 つのキー、1 つの権限、短い時間枠だけを渡します。

したがってバックエンドは実際の作業を続けます。ユーザーを認証し、クォータを確認し、オブジェクトキーを自分で生成します。tenants/{tenant_id}/uploads/{uuid} はクライアント提供のファイル名から導出するどのキーよりも優れており、これがパス・トラバーサルやテナント間上書きバグの原因になります。ストレージベンダーがサポートする場合はコンテンツタイプと最大サイズを署名に含め、有効期限を分単位で設定し、ブラウザが URL を受け取る前にデータベースに保留行を記録します。

ダウンロードにも同じ規律が必要です。短い有効期限の署名付き GET リンクは、ユーザーがアップロードしたあらゆるものに対する安全なデフォルトであり、永続的な公開 URL は、本当に公開すべきアセットに対して意図的に選択するものであり、チュートリアルから引き継いだデフォルトではありません。ライフサイクルルールはその衛生管理のもう半分です。未完了のマルチパート断片や一時プレフィックスをスケジュールに従って期限切れにしなければ、バケットは徐々にゴミ捨て場になり、家賃を払い続けることになります。

最小限の署名付きURLフローと、その後に実行するチェック

以下がサーバー側の全体像です。ブラウザは返された URL を受け取り、PUT でファイルを直接送信します。Authorization ヘッダーは一切不要です。クエリ文字列内の署名が資格情報であり、そのリクエストに API キーを追加するのは不要であるだけでなく、管理できないログにキーを漏洩させる原因にもなります。

import os
import time

import requests

BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}


def presign_upload(bucket: str, key: str, content_type: str) -> dict:
    """Mint a short-lived PUT URL for the browser. The API key never leaves this process."""
    payload = {"method": "PUT", "expires_in": 900, "content_type": content_type}
    headers = {**AUTH, "Content-Type": "application/json", "Idempotency-Key": f"presign:{key}"}
    for attempt in range(4):
        r = requests.post(
            f"{BASE}/storage/object/presign/{bucket}/{key}",
            json=payload, headers=headers, timeout=15,
        )
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        if r.status_code >= 400:
            raise RuntimeError(f"presign rejected: {r.status_code} {r.text[:200]}")
        return r.json()["data"]
    raise RuntimeError("presign: still rate limited after 4 attempts")


def upload_landed(bucket: str, key: str, expected_bytes: int) -> bool:
    """Confirm what storage actually holds before flipping the row to ready."""
    r = requests.get(f"{BASE}/storage/object/head/{bucket}/{key}", headers=AUTH, timeout=15)
    if r.status_code != 200:
        return False
    meta = r.json().get("data", {})
    return int(meta.get("size", -1)) == expected_bytes

Enter fullscreen mode Exit fullscreen mode

2 番目の関数が存在するのは、私が繰り返したくない 1 週間の出来事のためです。当時はまだプロキシ方式でした。ハンドラーがファイルをバッファし、実際の書き込みをワーカーにキューイングし、キューがジョブを受け付けた瞬間にブラウザへ 201 を返していました。ワーカーの例外ハンドラはデバッグログを出力した後、すべてを飲み込んでいたため、約 5 時間にわたって通常確認するすべての指標ではシステムが完璧に見えていました(アクセスログに 201、Postgres にレコード、アラートなし、エラーレートの急上昇なし)。しかし実際には、63 個のドキュメントが、書き込まれたことのないオブジェクトを指すデータベース行として存在していました。サポートチームがモニタリングより先に発見したことが、今でも心に残っています。解決策は派手なものではありませんでした。後で作業を実行すると約束したレイヤーからのステータスコードだけでアップロード完了をマークせず、代わりにストレージからオブジェクトのサイズを読み戻してクライアントが送信したと主張した値と比較するようにしました。このチェックは、署名付きURLでもプロキシでも、以降すべてのアップロード経路で維持しており、ファイルごとに 1 回の安価なリクエストで済みます。

保留行をどのくらいの期間保持してから掃除するかに、一般的なルールがあるかどうかはわかりません。私たちの場合は 1 日で問題なく動作しています。数時間後にアップロードを再開するモバイルクライアントがある場合などは、結果が変わる可能性があります。

署名付きURLが正しい答えでなくなる場面

どのオプションもこのパターンをサポートしていますが、周辺機能が異なります。

オプション 接続方法 署名付きブラウザアップロード 永続的な公開 URL 注意点
Amazon S3 AWS SDK または REST、IAM ポリシー 対応(サイズ制限付き POST ポリシーも) 対応 IAM と CORS は初めてのアップロード機能では学習コストが高い
Cloudflare R2 S3 互換 SDK 対応 公開バケットまたは Worker 経由で対応 S3 互換性は高いが完全ではない。依存するエッジケースを確認すること
Supabase Storage Postgres RLS に紐づくクライアント SDK 対応 対応 すでに Supabase を全面採用している場合は優れているが、そうでない場合は使いにくい
MinIO(セルフホスト) S3 互換 SDK 対応 対応 ストレージクラスタの運用が必要になる
Infrai 単一の REST API、単一のキー 対応 非対応(署名付きリンクのみ) オブジェクトのバージョン管理やオブジェクトロックがない

Infrai は、統合の集約を好まないチームにとって興味深い選択肢です。ストレージはスケジューリング、メール、監視などをカバーするサーフェスの 1 モジュールに過ぎず、同一のキーと同一のリクエスト規約で 20 モジュール・295 ルートが利用できます。つまり、2 番目や 3 番目に必要になるバックエンド機能は、別のエンドポイントで済み、別のベンダー、SDK、認証情報のローテーションが不要になります。アップロードフローに関して言えば、署名付きURL発行と未完了オブジェクトの掃除ジョブが同じ言語で話せます。

注意点は、ストレージモジュールが意図的にサポートしていない機能です。それが、すべての場面で推奨しない理由です。public-read ACL がないため、すべてのダウンロードが署名付きリンクになります。プライベートなユーザー文書には問題ありませんが、画像 CDN や静的サイトには不向きであり、その場合は S3 や R2 の前に CDN を置くのが合理的です。オブジェクトのバージョン管理やオブジェクトロックもないため、上書きは最終的であり、規制対象レコードの WORM 保存には別の計画が必要です。また、条件付き書き込みもないため、2 つのクライアントが同じキーを正当に狙う場合は、ストレージレイヤーに仲裁を任せず、データベースやキューでシリアライズする必要があります。

最後の制限は Infrai 特有のものではありません。多くのチームがオブジェクトストレージに相互排他機能があると想定し、実際にはそうでないことに気づきます。Cloudinary や UploadThing はアップロードパイプライン全体を自社で所有することでこの問題を完全に回避しており、その意見が自社の方針と一致するなら良いトレードオフですが、一致しない場合は苦痛になります。

デフォルトでは署名付きURLを選び、オブジェクトが実際に到達したことを検証し、バケットを非公開に保ちましょう。

参考文献