本人にしか見せない画像を、CDN 越しに届ける
モバイルアプリのアイテム登録機能に、CloudFront Signed Cookie を使った画像配信基盤を導入した話。設計と実装、そして途中で踏んだ3つの地雷について書きます。
モバイルアプリでは、アイテムを写真つきで登録すると、ポイントや保証の管理に使えるようになっている。今回、この「アイテム写真」と「購入証明書の写真」を、これまでのような単純な公開URLではなく、本人だけがアクセスできる形で配信する基盤を作り直しました。この記事では、その設計と、実装中に遭遇した細かいけれど厄介な問題についてまとめます。
01背景と要件
これまで、アイテム画像はクライアントが用意した URL 文字列をそのまま DB に保存するだけの、ごく単純な仕組みでした。しかしアプリの機能拡張にともない、次の要件が持ち上がりました。
- → 画像はサーバー経由でアップロードするクライアントから直接ストレージを叩くのではなく、アプリサーバーが受け取って保存する形にしたい(バリデーションと保存先を一元管理するため)。
- → ユーザー自身の画像しか見えないアイテム写真・購入証明書は個人情報に近い。URLを知っていれば誰でも見られる状態は避けたい。
- → 配信はCDN経由にするS3から直接返すのではなく、CloudFrontでキャッシュ・配信したい。ただし「認証つきCDN配信」と「本人限定」を両立させる必要がある。
- → モバイル側の実装コストを抑えるリクエストのたびに署名付きURLを都度発行するのではなく、ログインしてしまえばあとは普通に画像を読み込めるようにしたい。
「本人限定」と「CDN配信」を両立させる方法として選んだのが、CloudFront の Signed Cookie です。署名付きURLだと画像1枚ごとに個別の署名が要りますが、Signed Cookie ならログイン時に一度発行しておけば、あとはブラウザ(アプリ)が自動的にCookieを送ってくれるので、モバイル側の実装がシンプルになります。
02アーキテクチャ
全体は「アップロード」と「配信」の2つの流れに分かれます。どちらも同じ非公開S3バケットとRailsアプリサーバーを経由しますが、認証の仕組みが異なります。
ポイントは、Signed Cookie の検証ロジックはアプリケーションコードのどこにも存在しないということです。Railsが担うのはCookieを「正しく発行する」ことだけで、検証はすべてCloudFrontのエッジ側で完結します。裏を返すと、発行時にちょっとでも間違った値を作ってしまうと、それを検出できるのは実際にCloudFrontにリクエストが飛んだ瞬間だけ、ということでもあります。これが後述するハマりどころに直結します。
Cookieはいつ発行するか
Signed Cookie は次の2つのタイミングで発行・再発行します。
- ログイン時 — 毎回無条件に新規発行する
- 認証つきAPI呼び出し時 — リクエストに3種類のCookieが揃っていない場合だけ、その場で再発行する(アプリの再インストールでCookieだけ消えた場合の保険)
有効期限は10年という、一見雑に見える値を設定しています。アプリ側のログイントークン自体に有効期限の概念が無いため、それに合わせて「実質失効しない」十分に長い値にした、という判断です。
03実装したこと
アップロードAPIは、これまでの「クライアントがURL文字列を送る」方式から、「クライアントが画像バイナリそのものを multipart で送る」方式に変更しました。サーバー側は受け取った画像を検証してS3に保存し、DBには完全なURLではなくS3キーだけを保持します。レスポンスを組み立てる際に、このS3キーをCloudFront配信URLへ変換します。
users/{user_id}/items/{item_id}/case/{timestamp}-{random}.jpg
users/{user_id}/items/{item_id}/purchase_certificate/{timestamp}-{random}.jpg
DBに完全なURLではなくキーだけを持たせたのは、配信ドメインを後から変更しやすくするためです。実際、開発環境と本番環境で配信ドメインが異なるため、この一段のクッションが効いています。
04ハマったところ
設計自体は素直でしたが、実装の過程で3つ、地味に厄介な問題にぶつかりました。いずれも「ローカル開発環境には本物のCloudFrontが無い」ことが発見を遅らせた共通点です。
ワイルドカードは「カスタムポリシー」でないと効かない
問題Signed Cookieには「既定ポリシー(canned policy)」と「カスタムポリシー(custom policy)」の2種類があります。既定ポリシーはCookieに有効期限しか含まず、CloudFront側が実際にリクエストされたURLからポリシーを再構築して検証します。今回のように users/{user_id}/* というワイルドカードを含むリソースを許可したい場合、既定ポリシーでは検証時に組み立て直したURLと署名対象が一致せず、問答無用で403になります。
気づいたきっかけローカル開発環境には実物のCloudFrontが存在しないため、この不具合はテストでは一切表面化しませんでした。実際にAWS SDKのCookie発行ロジックを手元で動かし、生成された署名を実際に検証してみて初めて気づく類の問題です。
解決ポリシーJSONを自前で組み立てて明示的に渡す、カスタムポリシー方式に切り替えました。発行されるCookie名も変わる(CloudFront-Expires → CloudFront-Policy)ため、関連するテストやドキュメントもあわせて追随させています。
フレームワークがCookieの値を勝手にURLエンコードしていた
問題CloudFrontの署名付きの値は、通常のBase64ではなく+ = / をそれぞれ - _ ~ に置き換えた独自形式です。ところがRailsのCookie機構は、値をSet-Cookieヘッダーに書き出す際、内部でURLエンコード処理を通しており、この~が%7Eに変換されてしまっていました。
Signature=abc~def~ghi
Signature=abc%7Edef%7Eghi
ブラウザ(アプリ)はSet-Cookieの値をそのままのバイト列として保存・送信するだけなので、%7Eは~には戻りません。CloudFront側はこの3文字を「1文字の~」として解釈できず、署名データの一部を読み違えて検証に失敗します。
気づいたきっかけログインもアイテム登録も普通に成功し、返ってくる画像URLも一見正しく見えるため、切り分けが非常に難しい壊れ方でした。実際にレスポンスの生ヘッダーを見て、値の中に%7Eが複数含まれていることに気づいたのがきっかけです。
解決RailsのCookie機構(フレームワークによる自動エスケープ)を経由せず、Set-Cookieヘッダーの文字列を自前で組み立てて直接レスポンスに載せるように変更しました。値自体はエスケープが不要な文字種(英数字と- _ ~のみ)だったため、素通しで問題ありません。
multipartはネストしたJSONを運べない
問題購入証明書登録APIでは、画像とあわせてAI判定結果(金額や店舗名などを含むJSONオブジェクト)も送る必要があります。しかし multipart/form-data は「名前=文字列」しか運べないため、ネストしたオブジェクトを送ろうとすると、数値が文字列になり、nullが空文字になり、空配列がキーごと消える、という劣化が起こります。
ai_result[price]=1200 ai_result[dates][]=
ai_judgment_result=
{"price":1200,"dates":[]}
解決ネストしたオブジェクトをブラケット記法に分解するのではなく、JSON文字列化した1つのフィールドとして送ってもらい、サーバー側でパースする方式に統一しました。型情報を保ったまま運べるうえ、クライアント側の実装もシンプルになります。
05まとめ
今回いちばん学びが大きかったのは、「検証ロジックがアプリの外(CDNのエッジ)にある」仕組みは、ローカル環境だけでは正しさを保証できないということです。3つのハマりどころのうち2つは、実際にAWSのSDKを手元で動かして署名やヘッダーを1バイトずつ確認するまで発覚しませんでした。テストが通っていても、境界の外側で何が起きているかは別の話だと痛感しました。
一方で、Signed Cookieという仕組み自体は、いったん正しく発行できてしまえばアプリ側の実装は驚くほどシンプルです。画像1枚ごとに署名付きURLを発行する必要も、モバイル側で特別なヘッダーを付け回す必要もありません。ログインという1点さえ正しく作り込めば、あとは普段どおりの画像読み込みで「本人限定配信」が実現できる。地味ですが、良い設計だったと思っています。
この基盤は現在、社内のモバイルアプリチームと連携しながら実際のクライアント実装を進めているところです。実機での検証がまとまり次第、続報を書きたいと思います。