画像変換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の構成とは異なる。
設定する場所は次の順番。
- CloudFrontで対象ディストリビューションを開く。
- 「オリジン」でS3オリジンを選び、「編集」を開く。
- オリジンアクセスをOACにし、リクエストへ署名する設定を使う。
- 今回は「オリジンパス」を空欄にする。
- そのディストリビューションからの読み取りを、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で保存できるかを順に分けて確かめると、直す場所を絞りやすかった。