記事一覧に戻る
Web技術系

S3直接アップロードからCloudFront配信まで、AWS側の設定とCORSの落とし穴

画像変換アプリで行ったS3・IAM・CloudFrontの設定を、署名付きPOST、OAC、CORS、一時画像の削除まで整理。画像は見えるのにPNG保存が失敗した原因と、Chromeで確認した対応も残す。

ノートと鉛筆のイラスト

画像変換Webアプリの保存先を、アプリサーバーのローカルディスクからS3へ移した。元画像はブラウザからS3へ直接アップロードし、生成後の画像もS3へ保存する。表示とPNG保存にはCloudFrontを使う構成にした。

アプリのコードを変更するだけでは終わらず、AWS側でもIAMの許可、S3のCORS、CloudFrontの読み取り許可を設定する必要があった。最後には「画像は表示できるのに、保存ボタンを押すとCORSエラーになる」という問題にも当たった。

この記事は、その設定と切り分けの記録。2026年11月5日時点のAWSコンソールと、次の開発環境を前提にしている。バケット名・アカウントID・配信ドメインは例示用の値に置き換えてある。

項目 今回の環境・方式
アプリ Laravel 13.39、PHP 8.4、WSL2上で開発
S3連携 AWS SDK for PHP 3.402.5、Flysystem S3アダプター 3.35.3
ブラウザ Windows上のChrome 156
ストレージ 東京リージョンのS3汎用バケット。設定例はSSE-S3を前提にする
アップロード 署名付きPOST。発行する許可の有効期間は5分
配信 CloudFrontの公開URL。閲覧者向けの署名は付けない

直接アップロード自体の考え方は以前の設計記事、S3への送信が403になる場合はアップロード時のCORS・署名エラーの記事にもまとめている。今回は、実際に使うAWS設定を中心に進める。

画像がどこを通る構成にしたか

元画像について、アプリが受け取るのはファイル名・サイズ・チェックサムなどのJSON。画像本体はS3へ直接送る。

sequenceDiagram
    accTitle: S3直接アップロードとCloudFront配信の流れ
    accDescr: ブラウザがS3へ画像を送り、アプリがOpenAIへ閲覧用URLを渡す。生成結果はメモリを経由してS3へ保存し、CloudFrontからブラウザへ配信する。
    participant B as ブラウザ
    participant A as アプリ
    participant S as S3
    participant O as OpenAI API
    participant C as CloudFront
    B->>A: アップロード許可を申請(JSON)
    A-->>B: 署名付きPOSTのURL・フォーム項目
    B->>S: 元画像を直接アップロード
    B->>A: 完了を通知
    A->>S: 保存済みのサイズなどを確認
    A->>S: 元画像を確定用のキーへコピー
    A->>O: 元画像の署名付きGET URLで生成を依頼
    O-->>A: 生成結果
    Note over A: メモリ上で扱い、ローカルディスクへ保存しない
    A->>S: 生成画像を保存
    B->>C: 画像の表示・PNG保存
    C->>S: キャッシュにない画像を取得
    C-->>B: 画像を配信

ここでいう「サーバーに画像を置かない」は、アプリサーバーのディスクに保存しないという意味。OpenAI APIから返る生成結果はアプリのメモリを通ってS3へ送る。生成結果をアプリが一切受信しない構成ではない。

OpenAIの画像編集APIには、JSONのimages[].image_urlでHTTPS URLやデータURLを渡せる。今回は元画像にS3の署名付きGET URLを使った。OpenAI公式ドキュメント:画像編集API

S3の保存先を、一時アップロードと確定画像に分ける

S3のオブジェクトキーは、次の3つのプレフィックスに分けた。プレフィックスはキーの先頭部分で、画面上ではフォルダーのように見える。

generations/uploads/      一時アップロード
generations/originals/    生成に使う確定済みの元画像
generations/results/      生成後のPNG

バケット自体を誰でも読み書きできる設定にはしない。ブラウザの書き込みは署名付きPOST、CloudFrontの読み取りは後述するOACとバケットポリシーで許可する。S3のブロックパブリックアクセスも有効にした構成にできる。AWS:S3オリジンへのアクセス制限

一時領域と確定領域を分けるのは、アップロード後に画面を閉じた場合など、履歴に採用されなかった画像を片付けるため。CloudFrontに読み取りを許可する範囲にも、一時領域は含めない。

IAMポリシーは、作成した後にアタッチする

最初につまずいたのは、アプリからS3への書き込みで返ったAccessDeniedだった。IAMポリシーを作成しても、アプリが使うアクセスキーのIAMユーザーに適用されていなければ権限は増えない。

管理ポリシーを使う場合の確認箇所は、IAMの「ユーザー → 対象ユーザー → 許可」。ここで「許可を追加」からポリシーを直接アタッチし、「許可ポリシー」の一覧に載ったことを確認する。ロールを使う環境では、そのロールへ適用する。AWS:IAMへの許可の追加

アプリ側の操作に使うポリシーの例は次の形。amzn-s3-demo-bucketは自分のバケット名へ置き換える。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "GenerationImages",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:PutObjectAcl",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/generations/*"
        },
        {
            "Sid": "ListGenerationImages",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::amzn-s3-demo-bucket"
        }
    ]
}

オブジェクトを操作するARNには/generations/*を付け、バケットの一覧操作にはバケット自体のARNを指定する。HeadObjectで保存結果を調べる場合も、必要なのはs3:GetObject。s3:HeadObjectというIAMアクションを追加するわけではない。AWS:HeadObjectの権限

s3:PutObjectAclは、今回のアダプターがbucket-owner-full-controlを指定する設定に合わせたもの。すべての直接アップロードに必須という意味ではない。新規バケットで標準の「Bucket owner enforced」を使う場合はACLが無効になり、ACLを付けない書き込みか、bucket-owner-full-controlを指定した書き込みを受け付ける。AWS:S3 Object Ownership

なお、画像を読み書きする権限と、バケットの設定を変更する権限は別。CLIでCORSやライフサイクルまで設定するなら、設定を行う主体に次の権限も必要になる。通常の画像生成処理だけなら、これらの設定変更権限をアプリへ持たせる必要はない。

設定する内容 設定用のIAMアクション 対象Resource
CORSの取得・更新 s3:GetBucketCORS、s3:PutBucketCORS バケットのARN
ライフサイクルの取得・更新 s3:GetLifecycleConfiguration、s3:PutLifecycleConfiguration バケットのARN

操作と必要な許可の対応は、AWSのS3権限一覧で確認できる。コンソールで設定する方法と、アプリの認証情報で設定APIを呼ぶ方法を混同しないようにする。

JSONを貼る画面は、3か所で役割が違う

今回、やり取りの途中で混ざりやすかったのが、どのJSONをどの画面へ保存するかという点だった。

設定 書き込む場所 決めること
IAMポリシー IAMのポリシー。ユーザーやロールへ適用 アプリなどの実行主体が何を操作できるか
S3のCORS 対象バケットの「アクセス許可 → CORS」 別オリジンのブラウザが応答を利用できるか
S3バケットポリシー 対象バケットの「アクセス許可 → バケットポリシー」 CloudFrontなど、誰にバケット内の読み取りを許可するか

AllowedOriginsやAllowedMethodsの配列はCORSの設定で、IAMの許可ポリシーではない。反対に、IAMポリシーをアタッチしても、ブラウザ向けのCORS設定が自動で追加されるわけではない。AWS:S3のCORS設定とアクセス許可

S3のCORSでは、直接アップロードのPOSTを許可する

S3コンソールで対象バケットを開き、「アクセス許可 → Cross-origin resource sharing(CORS)→ 編集」へ進む。次は、今回使うメソッドに絞ったS3コンソール用の設定例。

[
    {
        "ID": "image-app-browser-access",
        "AllowedOrigins": [
            "https://app.example.com",
            "http://127.0.0.1:8000",
            "http://localhost:8000"
        ],
        "AllowedMethods": ["GET", "HEAD", "POST"],
        "AllowedHeaders": ["*"],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 300
    }
]

https://app.example.comは本番のアプリのオリジンへ置き換える。localhostと127.0.0.1は別のオリジンなので、開発中に両方を使うなら両方を指定する。既存のPUTアップロードなどがある場合は、その用途のルールも残す。

以前の設計例では署名付きPUTを扱ったが、今回の実装は署名付きPOST。送信先URLに加え、署名・ポリシーなどのフォーム項目をアプリが返し、ブラウザはそれをFormDataに入れて送る。S3のCORS設定に、実際に送るPOSTが必要になる。AWS:S3 CORSの各設定項目

FormDataを送るときは、HTTPのContent-TypeヘッダーをJavaScriptで手動設定しない。フォームを区切るboundaryを含め、ブラウザに設定させる。ポリシーで指定する画像のContent-Typeは、S3へ渡すフォーム項目のほうに入れる。MDN:FormDataを送信するときのContent-Type

署名付きPOSTのポリシーでは、保存先キー、Content-Type、サイズ、チェックサムを固定した。有効期間は5分、画像は最大10MiBとし、許可を発行するときに受け付けたサイズをcontent-length-rangeの下限・上限の両方へ設定する。AWS:POSTポリシー

SHA-256はx-amz-checksum-algorithmとx-amz-checksum-sha256のフォーム項目で渡す。これは送ったデータの整合性を確かめる仕組みで、画像が安全かどうかを判定するものではない。AWS:POST Objectのチェックサム項目

S3のAllowedMethodsにOPTIONSを追加する必要はない。一方、CloudFrontにも「許可されたHTTPメソッド」という設定がある。S3のCORSの値と、CloudFrontが受け付けるメソッドは別の設定として扱う。

一時アップロードだけにライフサイクルを設定する

S3コンソールの「管理 → ライフサイクルルール」で、generations/uploads/だけを対象にする。確定済みの元画像や生成画像まで同じ期限で消さないよう、プレフィックスを分けておく。

一時領域の削除用に用意したライフサイクル設定は、次の内容。

項目 設定
対象プレフィックス generations/uploads/
現行オブジェクトの有効期限 作成から1日
非現行バージョンの有効期限 非現行になってから1日
未完了マルチパートアップロードの中止 開始から1日

非現行バージョンの設定は、バージョニングを使う場合に関係する。今回のブラウザ送信はマルチパートアップロードAPIではないので、最後の項目は同じ領域を別の送信方式で使う場合への設定になる。

記事作成時のアプリ用認証情報ではライフサイクル設定の取得もAccessDeniedとなったため、現在の適用状態までは再確認できていない。この表は用意した設定内容であり、設定完了の確認は権限のある管理者のコンソールで行う。

ここでの「1日」は、アップロードから正確に24時間後に消えるという保証ではない。S3はUTCの日付で期限を評価し、実際の削除も非同期で行う。期限までに必ず消す必要があるデータには、別の削除処理も必要になる。AWS:ライフサイクルの期限と実行タイミング

CloudFrontからS3を読めるよう、OACとバケットポリシーを合わせる

CloudFrontのオリジンには、S3の通常のバケットエンドポイントを指定する。S3の静的Webサイト用エンドポイントは、このOACの構成とは異なる。

設定する場所は次の順番。

  1. CloudFrontで対象ディストリビューションを開く。
  2. 「オリジン」でS3オリジンを選び、「編集」を開く。
  3. オリジンアクセスをOACにし、リクエストへ署名する設定を使う。
  4. 今回は「オリジンパス」を空欄にする。
  5. そのディストリビューションからの読み取りを、S3のバケットポリシーで許可する。

今回、最初のバケットポリシーは別用途のprocessed/*だけを許可していた。アプリが使うgenerations/originals/*とgenerations/results/*が対象外だったため、画像が403になっていた。既存の許可を残し、必要な2つのプレフィックスを追加した。

次は、その2つを許可するバケットポリシーの例。111122223333はAWSアカウントID、E123EXAMPLEはディストリビューションIDへ置き換える。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontRead",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::amzn-s3-demo-bucket/generations/originals/*",
                "arn:aws:s3:::amzn-s3-demo-bucket/generations/results/*"
            ],
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E123EXAMPLE"
                }
            }
        }
    ]
}

PrincipalはCloudFront、AWS:SourceArnは許可するディストリビューションのARN。バケット全体を一般公開する許可とは異なる。OACとバケットポリシーの対応は、AWSのOAC設定例で確認できる。

また、オリジンパスが空欄なら、CloudFrontの/generations/results/example.pngはS3の同じキーを参照する。配信先のURL、オリジンパス、バケットポリシーのResourceが、同じ保存先を指しているかを照合する。

CloudFrontのビヘイビアに、レスポンスヘッダーポリシーを付ける

ディストリビューションの「動作(Behaviors)」で、画像のパスに一致するビヘイビアを選んで編集する。今回、最終的に使った主な値は次のとおり。

項目 今回の設定
ビューワープロトコルポリシー Redirect HTTP to HTTPS
許可されたHTTPメソッド GET, HEAD
ビューワーのアクセスを制限する No
キャッシュポリシー CachingOptimized
オリジンリクエストポリシー 未指定
レスポンスヘッダーポリシー CORS-With-Preflight

レスポンスヘッダーポリシーは、CloudFrontからブラウザへ返す応答にCORSヘッダーを付けるために使う。オリジンリクエストポリシーは、CloudFrontからS3へ転送するリクエストの設定なので、選ぶ欄が違う。AWS:レスポンスヘッダーポリシー

今回はGETで画像を取得し、ブラウザからOPTIONSが発生しないことを確認したため、許可メソッドはGET, HEADのままにした。CORS-With-Preflightを選んだことと、ビヘイビアでOPTIONSを許可したことは同じではない。 別の処理でプリフライトが発生するなら、そちらのメソッド設定と応答も確認する。

カスタムポリシーを作れても、選べるとは限らなかった

最初はカスタムのレスポンスヘッダーポリシーを作成したが、使用中のFree planの選択欄には「Available with the Business plan」と表示され、適用できなかった。今回の画面ではマネージドポリシーを選べたため、そちらを使った。

管理画面でポリシーの詳細を作れたことと、ディストリビューションへ関連付けられたことは分けて確認する。ポリシーの詳細画面で「ディストリビューション:なし」のままなら、まだ配信には使われていない。

アプリの配信URLをCloudFrontへ向ける

今回のアプリでは、S3への接続設定に加え、独自の環境変数AWS_CLOUDFRONT_URLを用意した。

FILESYSTEM_DISK=s3
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=amzn-s3-demo-bucket
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_CLOUDFRONT_URL=https://d111111abcdef8.cloudfront.net

AWS_CLOUDFRONT_URLはAWSやLaravelが自動で使う標準変数ではない。アプリ側で設定値を読み、S3のキーと結合して画像URLを作っている。アクセスキーなどの認証情報は実行環境で設定し、記事やリポジトリには載せない。

S3が非公開でも、今回のCloudFront URLは公開URL。 URLを知っている人は、元画像・生成画像を閲覧できる。OACはCloudFrontからS3へのアクセスを制御するもので、閲覧者の認証ではない。閲覧者ごとの制限が必要なら、CloudFrontの署名付きURL・Cookieなどを使う構成にする。AWS:CloudFrontの非公開コンテンツ

画像の表示と、JavaScriptでの保存は条件が違う

CloudFront経由で表示できるようになった後も、「PNGを保存」は失敗した。コンソールには、画像のGETが200なのにAccess-Control-Allow-Originがない、というエラーが出ていた。

画像をimg要素で表示できることと、fetch()で画像データをJavaScriptへ読み込めることは別。さらに、別オリジンのCloudFront URLでは、リンクにdownload属性を付けるだけで希望する保存動作になるとは限らない。download属性は同一オリジンのURLやblob:などが対象になる。MDN:download属性

今回はブラウザで画像を取得し、BlobのURLを作って保存する処理にした。基本部分は次の形になる。

async function savePng(imageUrl, filename) {
    const url = new URL(imageUrl, window.location.href);
    url.searchParams.set('download', '1');

    const response = await fetch(url.href, {
        credentials: 'same-origin',
        referrerPolicy: 'no-referrer',
        signal: AbortSignal.timeout(60000),
    });

    if (!response.ok || !response.headers.get('Content-Type')?.startsWith('image/')) {
        throw new Error('画像を取得できませんでした。');
    }

    const objectUrl = URL.createObjectURL(await response.blob());
    const link = document.createElement('a');
    link.href = objectUrl;
    link.download = filename;
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
}

この処理でも、CloudFrontが正しいCORSヘッダーを返す必要がある。mode: 'no-cors'にするとJavaScriptから画像の応答を読み取れないため、Blob保存の解決にはならない。MDN:Fetch APIのCORS

?download=1は、表示用URLと保存用URLでブラウザのキャッシュを分けるために付けている。これ自体がCORSを直すわけではない。また、今回使うCachingOptimizedはクエリ文字列をキャッシュキーに含めないので、CloudFrontのキャッシュ削除にもならない。AWS:CachingOptimizedの設定

SimpleCORSではcurlが通っても、Chromeで失敗した

最初はマネージドのSimpleCORSを選び、curlなどでCORSヘッダーが返ることを確認した。それでもChromeでは保存に失敗した。URLの取り違えや古いJavaScriptを確認した後、WindowsのChromeでも同じ失敗を再現し、送信ヘッダーを比較した。

今回、同じ画像に対してOriginとAccept-Encodingを付けたGETを行うと、Priorityヘッダーの有無で次の差が出た。応答はCloudFrontのキャッシュにヒットしていた。

レスポンスヘッダーポリシー リクエストのPriority Access-Control-Allow-Origin
SimpleCORS なし *
SimpleCORS u=1, i 付かなかった
CORS-With-Preflight u=1, i *

ChromeではPriority: u=1, iが自動で付いていた。そこで、ビヘイビアのレスポンスヘッダーポリシーをCORS-With-Preflightへ変更したところ、Chromeからの画像取得と、PNGの保存用Blob・リンクの作成まで確認できた。

これは今回の配信設定・キャッシュ・ブラウザで観測した結果。すべての環境でSimpleCORSが同じ失敗をするという意味ではない。AWSの説明では、SimpleCORSは単純なCORSリクエスト向け、CORS-With-Preflightはプリフライトを含むCORSリクエスト向けとされている。Priority固有の差分は、今回の検証結果として区別しておく。AWS:マネージドレスポンスヘッダーポリシー

HEADだけでなく、ブラウザに近いGETで確認する

Linux・WSLで応答を比較するなら、例えば次のように画像本体を捨てつつ、GETのヘッダーを確認できる。URLは、自分の公開配信先にある確認用画像へ置き換える。

image_url='https://d111111abcdef8.cloudfront.net/generations/results/example.png?download=1'

curl --silent --show-error \
    --dump-header - --output /dev/null \
    --header 'Origin: http://127.0.0.1:8000' \
    --header 'Accept-Encoding: gzip, deflate, br, zstd' \
    "$image_url"

curl --silent --show-error \
    --dump-header - --output /dev/null \
    --header 'Origin: http://127.0.0.1:8000' \
    --header 'Accept-Encoding: gzip, deflate, br, zstd' \
    --header 'Priority: u=1, i' \
    "$image_url"

確認するのはHTTPステータス、Access-Control-Allow-Origin、Content-Type、X-Cache。curl --headはHEADなので、今回のGETと同じ検証にはならない。curl自体はCORS制限を実施しないため、最後はブラウザでも確認する。

CloudFrontのレスポンスヘッダーポリシーは、オリジンから取得した応答にも、キャッシュから返す応答にも適用される。キャッシュにヒットしたときのヘッダーも確認し、「一度だけ取れた」で判断しないようにしたい。AWS:CloudFrontでの応答ヘッダーの変更

既存画像の移行と、削除後のキャッシュも扱う

保存先を変える際は、既存履歴の所有者や画像との対応を維持したままS3へ移した。ローカルの元ファイルを消すのは、S3側とSHA-256が一致したことを確認してからにした。

管理画面で削除する処理も、ローカルディスクだけを消す実装から、各履歴が記録する保存先を使う実装へ変更した。一時アップロードのコピーが残る場合は、それも削除対象に含める。

ただし、S3から削除してもCloudFrontのキャッシュが即時に消えるわけではない。期限より前に配信を止めたい場合は、CloudFrontのInvalidationを使うなど、キャッシュの扱いも設計に含める。今回のアプリ側の削除処理には、CloudFrontのキャッシュ無効化までは組み込んでいない。AWS:CloudFrontのファイル無効化

今回の設定で特に混ざりやすかったのは、アプリがS3を操作する許可、ブラウザが応答を読めるCORS、CloudFrontがS3を読める許可の3つだった。保存に成功しているか、画像を表示できるか、JavaScriptで保存できるかを順に分けて確かめると、直す場所を絞りやすかった。