0
0

S3 Presigned URLを使ってハマった話

Posted at

社内システムでアバター機能を作る必要があり、s3を利用して実装することにした。

tl;dr

以下の点で躓きました。

  1. ローカルとデプロイ環境では設定が異なる
    → ECSが自動的に環境変数を処理してくれる
  2. CORS設定
    → AWSコンソールで設定する必要あり
  3. 署名方式
    → クライアントで送るとき複合化をする必要があった

なんでpresigned url?

クライアントで直接送りたい

サーバーで画像の処理を行うと、何台しかない状態でオーバーヘッドの起きる可能性がある。
その反面、クライアントはユーザーの数ほど存在するため、画像の処理をクライアントに任せたいと考えた。

外部からs3にアクセスする方法

調べると、以下のような選択肢があった。

  1. Public
    → 誰でもアクセスできてしまう。今回は社内システムなので外部に公開したくない
  2. AWS SDK
    → IAM権限を持っているユーザーだけ
  3. AWS CLI
    → IAM権限を持っているユーザーだけ
  4. S3サイトホスト
    → 静的サイトを作っているわけではない
  5. CloudFront
    → 設定したCloudFrontからはアクセス可能、ダイレクトに繋がらない
  6. Presigned URL
    → 今回のケースにバッチリ

presigned urlとは?

pre(先に) + signed(署名した) + url
予めにs3に接続できるように署名したurlを作る仕組みである。

ざっくりした言い方なので公式を参考にした方が良いですね。

バケットポリシーを更新せずに、Amazon S3 内のオブジェクトへの時間制限付きのアクセス権を付与するには、署名付き URL を使用できます

構成

今回は以下のような実装を考えた。

クライアントでs3に画像をアップロードしようとすると、
① まずはサーバーにpresigned urlを発行するようにリクエスト
② サーバーはsdkを利用してs3からpresigned urlをもらう
③ クライアントはサーバーからpresigned urlをもらってリサイズした画像をアップロードする。

スクリーンショット 2024-08-25 19.14.47.png

ハマった点

1. ローカル環境とデプロイ環境の違い

ローカル環境の設定ではawsからのconfidentialをenvファイルに書いたりして、railsでs3オブジェクトを読み込んだ。
しかし、デプロイ環境ではどこに環境変数を入れて、どのタイミングにs3オブジェクトが読み込まれるのかわからなかった。

正解はsdk側にあった。サーバーにはecsを使っていて、sdkが自動的にcredentialの情報を入れてくれた。SSMとかで入力するわけでもなかった。

awsの仕組みをちゃんと理解しないとわからなかった...

2. CORS

一番ハマったところである。
preflightで失敗しています、headerにCORSの設定入れてねというエラーがずっと出ていたが、どれだけヘッダーをいじっても治らなかった。

結果的にはAWSのコンソールにCORS設定のJSONを書く必要があった。

しかし、書いても上手くいかないときもあった。特に開発中ここにlocalhost:3000を許可していいものか、の問題もあった。結局、実装中はCORSを全開放したが、セキュリティ的にもっといい方法を考えたい。

スクリーンショット 2024-08-25 19.28.07.png

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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0