社内システムでアバター機能を作る必要があり、s3を利用して実装することにした。
tl;dr
以下の点で躓きました。
- ローカルとデプロイ環境では設定が異なる
→ ECSが自動的に環境変数を処理してくれる - CORS設定
→ AWSコンソールで設定する必要あり - 署名方式
→ クライアントで送るとき複合化をする必要があった
なんでpresigned url?
クライアントで直接送りたい
サーバーで画像の処理を行うと、何台しかない状態でオーバーヘッドの起きる可能性がある。
その反面、クライアントはユーザーの数ほど存在するため、画像の処理をクライアントに任せたいと考えた。
外部からs3にアクセスする方法
調べると、以下のような選択肢があった。
- Public
→ 誰でもアクセスできてしまう。今回は社内システムなので外部に公開したくない - AWS SDK
→ IAM権限を持っているユーザーだけ - AWS CLI
→ IAM権限を持っているユーザーだけ - S3サイトホスト
→ 静的サイトを作っているわけではない - CloudFront
→ 設定したCloudFrontからはアクセス可能、ダイレクトに繋がらない - Presigned URL
→ 今回のケースにバッチリ
presigned urlとは?
pre(先に) + signed(署名した) + url
予めにs3に接続できるように署名したurlを作る仕組みである。
ざっくりした言い方なので公式を参考にした方が良いですね。
バケットポリシーを更新せずに、Amazon S3 内のオブジェクトへの時間制限付きのアクセス権を付与するには、署名付き URL を使用できます
構成
今回は以下のような実装を考えた。
クライアントでs3に画像をアップロードしようとすると、
① まずはサーバーにpresigned urlを発行するようにリクエスト
② サーバーはsdkを利用してs3からpresigned urlをもらう
③ クライアントはサーバーからpresigned urlをもらってリサイズした画像をアップロードする。
ハマった点
1. ローカル環境とデプロイ環境の違い
ローカル環境の設定ではawsからのconfidentialをenvファイルに書いたりして、railsでs3オブジェクトを読み込んだ。
しかし、デプロイ環境ではどこに環境変数を入れて、どのタイミングにs3オブジェクトが読み込まれるのかわからなかった。
正解はsdk側にあった。サーバーにはecsを使っていて、sdkが自動的にcredentialの情報を入れてくれた。SSMとかで入力するわけでもなかった。
awsの仕組みをちゃんと理解しないとわからなかった...
2. CORS
一番ハマったところである。
preflightで失敗しています、headerにCORSの設定入れてね
というエラーがずっと出ていたが、どれだけヘッダーをいじっても治らなかった。
結果的にはAWSのコンソールにCORS設定のJSONを書く必要があった。
しかし、書いても上手くいかないときもあった。特に開発中ここにlocalhost:3000
を許可していいものか、の問題もあった。結局、実装中はCORSを全開放したが、セキュリティ的にもっといい方法を考えたい。
3. 署名方法
CORSまで突破したら全てOKだと思っていたが、まだPUTができなかった。
エラーには署名方式が悪いと書いてあったため、UUIDのバージョンをv4にしたりしたが、それでも同じようなエラーが出ていた。
色々調べて、複合化をして送ることで直すことができた。今回の実装ではencodeURIComponent
を使用した。
実装例
以下が今回実装したvueとrailsの実装例である。
// リサイズにはcropperjsを使っている
// customeInstanceは名前通りカスタムなものを使っているのでご注意ください。
const handleAvatarUpload = async (): Promise<void> => {
try {
const croppedCanvas = cropper.value.getCroppedCanvas({
width: 256,
height: 256,
});
croppedCanvas.toBlob(async (blob: Blob) => {
const file = new File([blob], imageFile.value.name, {
type: imageFile.value.type,
});
// presigned URLを取得
const presignedData = await customInstance({
url: `サーバーのエンドポイント`,
method: 'PATCH',
params: { filename: encodeURIComponent(file.name) },
});
// ファイルのアップロード
const response = await fetch(presignedData.presigned_url, {
method: 'PUT',
body: file,
headers: {
...presignedData.headers,
'Content-Type': file.type,
},
});
}, imageFile.value.type);
} catch (e) {
console.error('Upload error:', e);
}
};
def update_avatar_url
user = User.find(params[:id])
bucket_name = 'バケット名'
s3_client = Aws::S3::Client.new(region: 'リージョン')
signer = Aws::S3::Presigner.new(client: s3_client)
rand_path = SecureRandom.uuid.delete('-')
key = ['avatar', rand_path, user.id, params[:filename]].join('/')
url, headers = signer.presigned_request(
:put_object,
bucket: bucket_name,
key: key,
acl: 'private',
expires_in: 3000,
content_type: 'image/jpeg',
)
user.update!(avatar_url: url)
render json: {
presigned_url: url,
headers: headers,
public_url: "https://#{bucket_name}.s3.amazonaws.com/#{key}"
}, status: :ok
end