notebook

都内でWEB系エンジニアやってます。

S3のマルチパートアップロードをpresignedUrlを用いてフロントエンドから行う

動画などの大容量のファイルをS3にアップロードする機能の話

単純にPUTオペレーションでアップロードする場合、1度のPUTオペレーションで最大5GBのオブジェクトをアップロードできる

オブジェクトのアップロード - Amazon Simple Storage Service

docs.aws.amazon.com

5GB以上のファイルをアップロードしたい場合はマルチパートアップロードを使えばアップロードできる

Amazon S3 マルチパートアップロードの制限 - Amazon Simple Storage Service

docs.aws.amazon.com

マルチパートアップロード

ユーザーガイド マルチパートアップロードを使用したオブジェクトのアップロードとコピー - Amazon Simple Storage Serviceから引用

マルチパートアップロードを使用すると、単一のオブジェクトをパートのセットとしてアップロードすることができます。各パートは、オブジェクトのデータの連続する部分です。これらのオブジェクトパートは、任意の順序で個別にアップロードできます。いずれかのパートの送信が失敗すると、他のパートに影響を与えることなくそのパートを再送することができます。オブジェクトのすべてのパートがアップロードされたら、Amazon S3 はこれらのパートを組み立ててオブジェクトを作成します。通常、オブジェクトサイズが 100 MB 以上の場合は、単一のオペレーションでオブジェクトをアップロードする代わりに、マルチパートアップロードを使用することを考慮してください。

要は大きいファイルを分割してアップロードできるよ、最後にS3側へつなげるリクエストを投げてS3側で組み立ててオブジェクトを作成してくれるよ

っていう感じのもの

複数のパートを並列で上げることができるのでパフォーマンス的にも嬉しい点が多そう

制限については下記に記述がある

Amazon S3 マルチパートアップロードの制限 - Amazon Simple Storage Service

docs.aws.amazon.com

AWSのS3 SDK

AWSのSDKでマルチパートアップロードを行うには低レベルのAPIと高レベルのAPIを使う2つパターンがある

低レベルのAPI

  • マルチパートアップロードの開始(IDの発行)
  • それぞれのパートのアップロード
  • マルチパートアップロードの完了通知

それぞれの処理をSDKからリクエストする

高レベルのAPI

低レベルAPIで行っていた一連の処理を1つのAPIで行う

Rubyのソースのぞいてみたがパートのアップロードでエラーがあった場合はマルチパートアップロードをabortしたり、マルチスレッドでパートをアップロードしたりとよしなにやってくれる

通常であれば高レベルのAPIを使う

使う側は容量などを意識しなくて良いようにするのが楽

公式でもそのようにアナウンスされている

aws-sdk-ruby/file_uploader.rb at version-3 · aws/aws-sdk-ruby

2022-03-24時点では下記のようなソースになっている

def upload(source, options = {})
  if File.size(source) >= multipart_threshold
    MultipartFileUploader.new(@options).upload(source, options)
  else
    # remove multipart parameters not supported by put_object
    options.delete(:thread_count)
    put_object(source, options)
  end
end

サーバから何かするみたいなときはこのメソッドを使えば、使う側は特に意識せずアップロードできる

ちなみにRubyのSDKだとマルチパートアップロードのしきい値(デフォルト100MB, 呼び出し時に指定可能multipart_threshold)以上のファイルはマルチパートアップロード、未満の場合は通常のput_objectでアップロードするような実装のメソッド(upload)が用意してあった

client = Aws::S3::FileUploader.new(region: 'ap-northeast-1')
client.upload('./README.md', {
  bucket: ENV['AWS_BUCKET'],
  key: 'README.md'
})

しきい値を変えたい場合はmultipart_thresholdにバイト数を指定すればOK

client = Aws::S3::FileUploader.new(region: 'ap-northeast-1', multipart_threshold: 200 * 1024 * 1024) # 200MB
client.upload('./README.md', {
  bucket: ENV['AWS_BUCKET'],
  key: 'README.md'
})

フロントエンドからの直接アップロード

フロントエンドから認証情報なしでアップロードしたい場合、PresignedなURL(有効期限を短くしたURL)を発行し、発行したURLに対してPUTでアップロードが可能

ただ、高レベルなAPIは使えなさそう

低レベルAPIで1つ1つのパートに対してPresignedなURLを発行して直接アップロードし、最後に完了リクエストを送るという流れならファイルのアップロードの部分に関してはサーバを介す必要なくできそう

ということで、処理をサンプルとして実装してみた

流れ

  • (フロント)ファイルの読み取り、バックエンドへマルチパートアップロード開始のリクエスト
  • (サーバ)マルチパートアップロードの開始リクエスト、upload_idを返す
  • (フロント)ファイル容量を読み分割数を決定
  • (フロント)添付ファイルを特定サイズごとに分割して読み込み
  • (フロント)分割数分のpresigned URLの発行リクエストをバックエンドへ送信
  • (サーバ)presigned URLを発行しurlを返す
  • (フロント)分割したデータをpresigned URLにPUT
  • (フロント)レスポンスのETag, partNumberを取得(パートデータ)
  • (フロント)分割数分のパートデータをまとめてupload_idと合わせてバックエンドへリクエスト
  • (サーバ)受け取ったパートデータとupload_idを使ってcompleteリクエストをAWSへ送る

ざっくり上記の流れで実装した

サーバ側

Rubyで実装した

とりえあずRackで書いた

  • rack.ru
require 'aws-sdk-s3'
require 'awesome_print'

class SimpleServer
  def call(env)
    request = Rack::Request.new(env)

    body = request.body.read
    params = body == '' ? nil : JSON.parse(body)

    case request.path
    # index.html
    when '/'
      [
        200,
        {
          'Content-Type'  => 'text/html',
          'Cache-Control' => 'public, max-age=86400'
        },
        File.open('./index.html', File::RDONLY)
      ]
    # マルチパートアップロードの開始
    when '/create_multipart_upload'
      client = Aws::S3::Client.new(region: 'ap-northeast-1')
      key = "multipart_upload/#{params['object_name']}"

      multipart_upload = client.create_multipart_upload(
        bucket: ENV['AWS_BUCKET'],
        key: key
      )

      [
        200,
        { 'Content-Type' => 'application/json' },
        [{ upload_id: multipart_upload.upload_id, object_key: key }.to_json]
      ]
    # presigned URLの発行
    when '/get_presigned_url'
      signer = Aws::S3::Presigner.new
      signed_url = signer.presigned_url(:upload_part,
        {
          bucket: ENV['AWS_BUCKET'],
          key: params['object_key'],
          expires_in: 3600,
          part_number: params['part_number'],
          upload_id: params['upload_id']
        }
      )

      [
        200,
        { 'Content-Type' => 'application/json' },
        [{ url: signed_url }.to_json]
      ]
    # マルチパートアップロードの終了
    when '/complete_multipart_upload'
      client = Aws::S3::Client.new(region: 'ap-northeast-1', http_wire_trace: true)

      parts = {parts: params['parts'].map{|part| {part_number: part['part_number'], etag: part['etag']}}}

      res = client.complete_multipart_upload(
        bucket: ENV['AWS_BUCKET'],
        key: params['object_key'],
        multipart_upload: parts,
        upload_id: params['upload_id'],
      )

      [
        200,
        { 'Content-Type' => 'application/json' },
        [res.to_json]
      ]
    else
      [404, {}, ['Not Found']]
    end
  end
end

run SimpleServer.new

バックエンド側は単純に1つずつ低レベルAPIでリクエストしているだけ

フロント側

HTMLの中にJavaScriptを直接書いた

<html>
  <head>
    <meta charset="utf-8">
  </head>
  <script type="text/javascript">
  // ファイルの分割単位
  const FILE_CHUNK_SIZE = 50_000_000;

  // マルチパートアップロードの開始リクエスト
  async function createMultipartUpload(objectName) {
    const path = '/create_multipart_upload';
    const headers = {
      'Content-Type': 'application/json',
    };

    const body = JSON.stringify({object_name: objectName});

    const multipartUpload = await fetch(path, {
      method: 'POST',
      headers: headers,
      body: body,
    }).then(response => response.json());

    return multipartUpload;
  }

  // presigned URLの発行リクエスト
  async function generateSignedUrl(params) {
    const path = '/get_presigned_url';
    const headers = {
      'Content-Type': 'application/json',
    };

    console.log(params);
    const signedUrl = await fetch(path, {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(params),
    }).then(response => response.json()).then(json => json.url);

    return signedUrl;
  }

  // パートデータのアップロード
  async function putPartData(signedUrl, partData, partNumber) {
    const headers = {
      'Content-Type': 'multipart/form-data'
    };

    const etag = await fetch(signedUrl, {
        method: 'PUT',
        headers,
        body: partData,
      }).then(response => response.headers.get('ETag'));

    return {
      etag: etag.replaceAll("\"", ""),
      part_number: partNumber,
    }
  }

  // パートデータの読み出し
  async function readPartData(fileBlob, offset) {
    const partData = await new Promise((resolve)=>{
      const reader = new FileReader();

      reader.onload = function(e) {
        const data = new Uint8Array(e.target.result);
        resolve(data);
        reader.abort();
      }

      const slice = fileBlob.slice(offset, offset + FILE_CHUNK_SIZE, fileBlob.type);

      reader.readAsArrayBuffer(slice);
    });

    return partData;
  }

  // presigned URLの発行、ファイルの分割読み込み、アップロード
  async function generateSignedUrlAndPutPartData(uploadId, objectKey, fileBlob, index) {
    const partData = await readPartData(fileBlob, index * FILE_CHUNK_SIZE);
    const partNumber = index + 1;

    const signedUrl = await generateSignedUrl({
      upload_id: uploadId,
      object_key: objectKey,
      part_number: partNumber,
    });

    const part = await putPartData(signedUrl, partData, partNumber);

    return part;
  }

  // マルチパートアップロードの終了リクエスト
  async function completeMultipartUpload(multipartUploadId, multipartMap, objectKey) {
    const path = '/complete_multipart_upload';
    const headers = {
      'Content-Type': 'application/json',
    };

    const body = JSON.stringify({
      upload_id: multipartUploadId,
      parts: multipartMap,
      object_key: objectKey,
    });

    return fetch(path, {
      method: 'POST',
      headers: headers,
      body: body,
    }).then(response => response.json());
  }

  // ファイルが添付されたときに実行される
  async function upload() {
    const extension = document.querySelector("#largeFile").value.split(".").slice(-1)[0];
    const file = document.querySelector("#largeFile").files[0];

    const multipartUpload = await createMultipartUpload(file.name);
    console.log('multipartUpload', multipartUpload);
    const uploadId = multipartUpload.upload_id;
    const objectKey = multipartUpload.object_key;

    const count = Math.ceil(file.size / FILE_CHUNK_SIZE);
    const multipartPromises = (Array.from(new Array(count))).map((_,i) => {
      return generateSignedUrlAndPutPartData(uploadId, objectKey, file, i)
    });

    const multipartMap = await Promise.all(multipartPromises);
    console.log('multipartMap', multipartMap);

    const completed = await completeMultipartUpload(uploadId, multipartMap, objectKey);

    console.log('completed',completed);
  }
  </script>
  <body>
    <input type="file" name="sample" accept="image/png,image/jpeg,image/gif,video/mp4,application/zip" id="largeFile" onchange="upload()">
  </body>
</html>

パートデータの読み出し部分はFileのデータをsliceで区切って、FileReaderを使ってArrayBuffer形式でファイルのデータを読んでいる

他は普通にバックエンドのAPIへリクエスト、presigned URLへのPUTをしているだけ

つまずいたところ

CORS設定

CompleteのリクエストにEtagが必要だったのでResponseHeaderからETagが取れないなーと何度か試してみていたが

S3のCORSの設定を設定する必要があるようだった

ExposeHEadersにETagを追加しヘッダにETagが返ってくるようにした

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": [
            "Content-Disposition",
            "ETag"
        ],
        "MaxAgeSeconds": 3000
    }
]

DevToolsから見たらETag返ってきているように見えたんだけどなー…

実際にアップロードする

適当な容量のファイルを用意しアップロードしてS3にアップロードできたことを確認した

制限のページに5 MiBからとあったので5 MiB未満のファイルはアップロードできないのかと思っていたが最後のパートには最小サイズの制限はありませんということで1つのパートで最後なので問題ないようで普通にアップロードできた

まとめ

S3への大容量ファイルのアップロード手段の1つとしてマルチパートアップロード+presigned URLでフロントエンドから直接大容量ファイルをアップロードするサンプルを書いてみた

大容量のファイルだとサーバを介してアップロードするにもサーバのプロセスを専有してしまうためユーザーの通信速度によっては余計にサーバを用意しないといけない感じになるのを解決できると感じている

何かの参考になれば幸いです

サンプルは下記のリポジトリに置いた

swfz/s3-direct-multipart-upload-with-presigned

github.com

参考

S3 へのファイル・アップロードに、マルチパートアップロードと署名付き URL を利用してみる

zenn.dev

フロント側の実装は少しメソッド分けたりしているがほとんどそのまま拝借させてもらいました