動画などの大容量のファイルをS3にアップロードする機能の話
単純にPUTオペレーションでアップロードする場合、1度のPUTオペレーションで最大5GBのオブジェクトをアップロードできる
オブジェクトのアップロード - Amazon Simple Storage Service
5GB以上のファイルをアップロードしたい場合はマルチパートアップロードを使えばアップロードできる
Amazon S3 マルチパートアップロードの制限 - Amazon Simple Storage Service
マルチパートアップロード
ユーザーガイド マルチパートアップロードを使用したオブジェクトのアップロードとコピー - Amazon Simple Storage Serviceから引用
マルチパートアップロードを使用すると、単一のオブジェクトをパートのセットとしてアップロードすることができます。各パートは、オブジェクトのデータの連続する部分です。これらのオブジェクトパートは、任意の順序で個別にアップロードできます。いずれかのパートの送信が失敗すると、他のパートに影響を与えることなくそのパートを再送することができます。オブジェクトのすべてのパートがアップロードされたら、Amazon S3 はこれらのパートを組み立ててオブジェクトを作成します。通常、オブジェクトサイズが 100 MB 以上の場合は、単一のオペレーションでオブジェクトをアップロードする代わりに、マルチパートアップロードを使用することを考慮してください。
要は大きいファイルを分割してアップロードできるよ、最後にS3側へつなげるリクエストを投げてS3側で組み立ててオブジェクトを作成してくれるよ
っていう感じのもの
複数のパートを並列で上げることができるのでパフォーマンス的にも嬉しい点が多そう
制限については下記に記述がある
Amazon S3 マルチパートアップロードの制限 - Amazon Simple Storage Service
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
参考
S3 へのファイル・アップロードに、マルチパートアップロードと署名付き URL を利用してみる
フロント側の実装は少しメソッド分けたりしているがほとんどそのまま拝借させてもらいました