• home
  • ブラウザからS3に巨大なファイルを低メモリで送りつけるアレ

ブラウザからS3に巨大なファイルを低メモリで送りつけるアレ

ブラウザから8GB以上の巨大なファイルや多量のファイルをS3に送りつけるには、MultiPart Uploadの機能を使っていく必要がある。
具体的には、データを5MB以上のchunkに分け、分割して送りつけることになる。
データが8GBいかないケースでも、ファイル読み込み量を分割できる機構が使えるので、メモリにも優しい。
それなりに使う場面はありそう。

1.とりあえずドキュメント通り実装して徳を積む

マルチパートアップロードの概要
Multipart Upload API を使用したオブジェクトのアップロード

(1) S3のCORS設定

  • ExposeHeader => ETag を指定する。
  • AllowHeader
<AllowedHeader>Authorization</AllowedHeader>
<AllowedHeader>Content-Type</AllowedHeader>
<AllowedHeader>User-Agent</AllowedHeader>
<AllowedHeader>x-amz-date</AllowedHeader>
<AllowedHeader>x-amz-user-agent</AllowedHeader>
<AllowedHeader>x-amz-storage-class</AllowedHeader>
<AllowedHeader>x-amz-acl</AllowedHeader>

この辺を設定する。PUT権とかはあとで動的に付与する。

(2) ブラウザのfile api

ブラウザのfile api でファイル名やbyte数取得。
 
この段階ではFileReaderによるメモリ上への展開は行わないこと。
 
余裕があればWebWorkerで別スレッド立ててUIスレッドへの影響範囲を小さくする。
 

(3) サーバーから署名付きURL取得

ブラウザからのsignedURL発行要求を認可,返却。
S3のbucketやオブジェクトに対し、PUTやHEADリクエストが通るようにする。
 
この辺までは割といつも通り。
 

(4) S3::createMultipartUpload

S3の該当keyに対して createMultipartUpload 要求。uploadIdを取得。
要するに「今から分割ファイルでアップロードするぜ?」という宣言みたいなの。
このuploadIdは今後のリクエストすべてに同梱することとなる。
 

(5) 分割したblobを送りつける S3::uploadPart

 
FileReaderにいきなり巨大なfileオブジェクト食わすと多分2GBあたりでブラウザが音も立てず死ぬ。
chunk送信用のループ内でFile::slice(startByte , endByte)してblob化して都度捨てるのがミソ

 
この部分しかメモリ上に乗らないようになる。これが可能なのよく知らなかった。
並列アップロード数のロジックとかchunkごとの大きさ(5MB以上)とかは様子見ながら好きにカスタムしたらいいんじゃないかな。
 
この時帰ってくるETag値と、送ったPartNumber値はこういう形式でとっておく。

const multipartMap= { Parts: [] };

//...ループ内
multipartMap.Parts[partNumber - 1] = {
    ETag: 返ってきたETag値,
    PartNumber: partNumber
};

(6) 完了 S3::completeMultipartUpload

 
すべて送り終わったら、completeMultipartUploadリクエストを送る。
この時、先ほどのmultipartMapを同梱して送りつける。
これで、初めてS3のバケット上にオブジェクトができる。
 

Code

4-6まではこんな感じになります。今回はaws-sdk使用。全部Promise返すモードがいつの間にかついてて良い。
(並列アップロードのロジック、chunkごとの大きさ指定(5MB以上)とかエラー処理は記事のためあえて省いてる。)

const upload = async (s3 ,  s3Params , file)=>{

    const mime = Mime.lookup(file.name);
    const multiPartParams = s3Params.ContentType ? s3Params : {ContentType : mime , ...s3Params};
    const allSize = file.size;

    const partSize = 1024 * 1024 * 5; // 5MB/chunk

    const multipartMap = {
        Parts: []
    };

    /*  (4)   */
    const multiPartUploadResult = await s3.createMultipartUpload(multiPartParams).promise();
    const uploadId = multiPartUploadResult.UploadId;

    /*  (5)  */
    let partNum = 0;
    const {ContentType , ...otherParams} = multiPartParams;
    for (let rangeStart = 0; rangeStart < allSize; rangeStart += partSize) {
        partNum++;
        const end = Math.min(rangeStart + partSize, allSize);

        const sendData = await new Promise((resolve)=>{
            let fileReader =  new FileReader();

            fileReader.onload = (event)=>{
                const data = event.target.result;
                let byte = new Uint8Array(data);
                resolve(byte);
                fileReader.abort();
            };
            const blob2 = this.file.slice(rangeStart , end);
            fileReader.readAsArrayBuffer(blob2);
        })

        const progress = end / file.size;
        console.log(`今,${progress * 100}%だよ`);

        const partParams = {
            Body: sendData,
            PartNumber: String(partNum),
            UploadId: uploadId,
            ...otherParams,
        };
        const partUpload = await s3.uploadPart(partParams).promise();

        multipartMap.Parts[partNum - 1] = {
            ETag: partUpload.ETag,
            PartNumber: partNum
        };
    }

    /* (6) */
    const doneParams = {
        ...otherParams,
        MultipartUpload: multipartMap,
        UploadId: uploadId
    };

    await s3.completeMultipartUpload(doneParams)
        .promise()
        .then(()=> alert("Complete!!!!!!!!!!!!!"))
}

 
理屈上、fileObjectの参照が残ってれば,中断やキャンセル、断片ごとのリトライも可能なはず。
S3 Multipart Upload Request のタイムアウト設定を長めにしておいてもいいかも。
 
 

2.めんどくさいのでOSSを使う

 
上記までをライブラリ化しようと思ってたけど、ある意味当然のごとく既に存在した。。
仕様も非同期チェーンも特に覚えたくない人は、早くお家帰りたいのでこっちのありものを使う。
 
EvaporateJS
 
一時停止やキャンセル、リトライ、chunkサイズの指定や同時送信数も指定可能。
webWorker上の別スレッドで動かすモードはついてないみたいだけど、よくまとまっててすごい。
ただしPromise使うので、typescriptやbabel等のプリプロセッサ通さない人は、es6-promiseあたりのポリフィルを念のため入れること。
以上です。