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

公開:2020/12/25
更新:2021/01/08
14 min読了の目安(約12900字TECH技術記事

AWS 上では多くの Web アプリケーションが動作しているかと思いますが、今回は Web アプリケーションの中で動画などの大容量 ( ものによっては 5G バイト以上まるかと ) のファイル・アップロードを必要とするシステム構築を行う際に利用されるマルチパートアップロード処理について AWS SDK for JavaScript を使って実施してみようと思います。

S3 へのファイル・アップロード

S3 へファイル・アップロードを行う場合、通常は単一ファイルサイズが 5G を上限に putObject API などを使って処理が行なえますが、単一ファイルサイズが 5G 以上の場合はファイルを 5G 未満に分割してファイルをアップロードし、アップロードしたファイルを S3 上で結合することで、最大 5TB までのファイルをアップロードすることが可能です。 ドキュメントとしては faq などに記載されています。

例えば、 AWS SDK を使ってマルチパートアップロードする際に呼び出すメソッドの順番としては、 createMultipartUploaduploadPart 、最後に completeMultipartUpload となります。これらのメソッドを正常にブラウザで実行するためには、適切な呼び出し権限が付与されているクレデンシャルをブラウザに読み込む必要が出てきます。

createMultipartUpload と completeMultipartUpload メソッドは、マルチアップロード処理でファイルをアップロードする前後の処理であり、これらはブラウザだけでなくクラウドでも実行することも可能ですが、uploadPart だけは実際にファイル・アップロード処理を行うということもあり、ブラウザ側で実行する必要があります、が代替手段を用いることでブラウザ側にクラデンシャルを保持することなくファイル・アップロード処理を実施できます。具体的な処理としては、クラウドで getSignedUrl メソッドなどを使って署名付き URL を生成し、生成された URL をブラウザへ送信します。ブラウザではクラウドから送信された署名付き URL を使って fetch API などでファイルをアップロードすることが可能となります。(正確にはクレデンシャルは署名付き URL に組み込まれている)

今回、検証した構成では各種 API の呼び出しをクラウド ( Lambda を利用 ) で実行することで、ブラウザ側にクレデンシャルを保持することなく、S3へのファイル・アップロードを実現しました。

全体像

マルチパートアップロードを実施する全体像

構築

各種 AWS リソースの作成については記載を省略します。

EC2

<html>
  <head>
    <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
    <script src="https://sdk.amazonaws.com/js/aws-sdk-2.812.0.min.js"></script>
    <script type="text/javascript" src="https://unpkg.com/cuid@1.0.1/src/cuid.js"></script>
    <script><!--
      // 分割アップロードの最大ファイル・サイズ
      const FILE_CHUNK_SIZE = 50_000_000;
      // S3 バケット名
      const BUCKET_NAME = 'large-capacity-image-bucket';

      $(document).ready(function(){
        // Upload ボタンを押したときの処理
        $('#uploadButton').click(async function() {
          if ($('#uploadFile')[0].files.length == 0) return;
          const file = $('#uploadFile')[0].files[0];
          let objectName = file.name;
          let fileSize = file.size
          let fileCount = Math.ceil(fileSize / FILE_CHUNK_SIZE)

          // マルチパートアップロード初期処理の呼び出し
          const uploadId = await createMultipartUpload(objectName);
          // 署名付き URL 作成とそれを使ったファイル・アップロード処理の呼び出し
          const multipartMap = await generateUrlAndUploadParts(uploadId, objectName, file, fileCount);
          // マルチパートアップロード完了処理の呼び出し
          completeMultipartUpload(uploadId, multipartMap,  objectName);
        });
      });


      // マルチパートアップロード初期処理
      async function createMultipartUpload(objectName) {
        const path = '/api/createMultipartUpload';
        const headers = {
            'Content-Type': 'application/json'
        };
        const body = JSON.stringify({ObjectName: objectName});
        const uploadId = await fetch(path, {
            method: 'POST',
            headers,
            body,
          })
          .then(res => {return res.json()})
          .then(json => {return json.UploadId});
        return uploadId;
      };


      // 署名付き URL 作成とそれを使ったファイル・アップロード処理
      async function generateUrlAndUploadParts(uploadId, objectName, blob, cnt) {
        const path = '/api/getSignedUrl';
        let commonBody =
          {
            UploadId: uploadId,
            ObjectName: objectName,
          };


        const multipartMap = {
          Parts: []
        };

        let offset = 0;
        let promises = [];
        const PROMISE_CHUNK_SIZE = 5;
        for (let i = 0; i < cnt; i++) {
          const data = await new Promise((resolve)=>{
              let reader = new FileReader();
              reader.onload = function(e) {
              const data = new Uint8Array(e.target.result);
                resolve(data);
                reader.abort();
              }
              slice = blob.slice(offset, offset + FILE_CHUNK_SIZE, blob.type);
              offset += FILE_CHUNK_SIZE;
              reader.readAsArrayBuffer(slice);
            });

            console.log((i + 1) + '/' + cnt);
            // 署名付き URL 作成
            const url = await fetch('/api/getSignedUrl', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({...commonBody, PartNumber: i + 1}),
              })
            .then(res => {return res.json()})
            .then(json => {return json.Url});

            // 署名付き URL によるファイル・アップロード
            const headers = {
              'Content-Type': 'multipart/form-data'
            };
            const res = fetch(url, {
                method: 'PUT',
                headers,
                body: data,
              });
            promises.push(res);
            if ((i + 1) % PROMISE_CHUNK_SIZE == 0 || (i + 1) == cnt) {
              await Promise.all(promises)
              .then((res) => {
                 const baseIndex = (i / PROMISE_CHUNK_SIZE | 0) * PROMISE_CHUNK_SIZE ;
                 for (let j = 0; j < res.length; j++) {
                   multipartMap.Parts.push({
                     ETag: res[j].headers.get('ETag'),
                     PartNumber: baseIndex + j +  1,
                   });
                 }
                 promises.splice(0);
               });
            }
        }
      return multipartMap;
    }

    // マルチパートアップロード完了処理
    async function completeMultipartUpload(uploadId, multipartMap, objectName) {
      const path = '/api/completeMultipartUpload';
      const body =
         {
           UploadId: uploadId,
           ObjectName: objectName,
           MultipartMap: multipartMap,
         };
      await fetch(path, {
          method: 'POST',
          headers: {'Content-Type': 'application/json'},
          body: JSON.stringify({...body}),
        })
        .then(() => {alert("Complete!!");});
    };

//   --> </script>
  </head>
  <body>
    <form>
      <input type="file" id="uploadFile"/>
      <input type="button" id="uploadButton" value="Upload"/>
    </form>
  </body>
</html>

Lambda

  • ブラウザからの三種類の fetch API リクエストを処理します。
    • 処理1 : SDK の AWS.S3 クラスにある createMultipartUpload メソッドを実行して UploadId を返します
    • 処理2 : SDK の AWS.S3 クラスにある getSignedUrl メソッドを実行して署名付き URL を返します
    • 処理3 : SDK の AWS.S3 クラスにある completeMultipartUpload メソッドを実行してアップロードしたファイルを結合します
const AWS = require('aws-sdk')
const BUCKET_NAME = 'large-capacity-image-bucket'

const s3 = new AWS.S3({signatureVersion: 'v4'});

exports.handler = async (event) => {
    console.log(event)

    let path = event.path;
    if (path == '/api/createMultipartUpload') {
      // SDK の AWS.S3 クラスにある createMultipartUpload メソッドを実行して UploadId を返します
      const objectName = JSON.parse(event.body).ObjectName;
      const params = {
        Bucket: BUCKET_NAME,
        Key: objectName,
      }
      const r = await s3.createMultipartUpload(params).promise();
      const response = {
          statusCode: 200,
          statusDescription: '200 OK',
          isBase64Encoded: false,
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify({UploadId: r.UploadId}),
      };
      return response;

    } else if (path == '/api/getSignedUrl') {
      // SDK の AWS.S3 クラスにある getSignedUrl メソッドを実行して署名付き URL を返します
      const objectName = JSON.parse(event.body).ObjectName;
      const uploadId = JSON.parse(event.body).UploadId;
      const partNumber = JSON.parse(event.body).PartNumber;
      const params = {
        Bucket: BUCKET_NAME,
        Key: objectName,
        UploadId: uploadId,
        Expires: 60,
        PartNumber: partNumber,
      }
      const url = await s3.getSignedUrl('uploadPart', params);
      const response = {
          statusCode: 200,
          statusDescription: '200 OK',
          isBase64Encoded: false,
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify({Url: url}),
      };
      return response;

    } else if (path == '/api/completeMultipartUpload') {
      // SDK の AWS.S3 クラスにある completeMultipartUpload メソッドを実行してアップロードしたファイルを結合します
      const objectName = JSON.parse(event.body).ObjectName;
      const uploadId = JSON.parse(event.body).UploadId;
      const multipartMap = JSON.parse(event.body).MultipartMap;
      const params = {
        Bucket: BUCKET_NAME,
        Key: objectName,
        MultipartUpload: multipartMap,
        UploadId: uploadId,
      };
      const response = await s3.completeMultipartUpload(params).promise()
        .then(res => 
          {
            return {
                statusCode: 200,
                statusDescription: '200 OK',
                isBase64Encoded: false,
            };
          })
        .catch(err => 
          {
            console.log(err);
            return {
                statusCode: 500,
                statusDescription: err,
                isBase64Encoded: false,
            };
          });
          return response;
    }
};
  • 各メソッドを正常に実行できるように、 Lambda のIAM ロールに以下のインライン・ポリシーを付与します ( ポリシー名はお好きにどうぞ )
    • large-capacity-image-bucket はこの後作成する S3 のバケット名です
    • マルチパートアップロード API を実行する際に必要となる権限については こちら が非常に参考になります
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::large-capacity-image-bucket/*"
        }
    ]
}

ALB

  • Web サーバが動作している EC2 と API を実行するための Lambda の呼び出しを同一ドメインで受け取り振り分けを行う ( EC2 上で アプリケーションサーバを動かしてもいいが、手間なので今回は Lambda を利用しました )
    • ここでの手順記載は省略しますが、 HTTPS 通信できるようにしておきます

S3

  • large-capacity-image-bucket という名前のバケットを作成します
  • 作成したバケットへ異なるドメインの Web アプリケーションから fetch API リクエストが行えるように CORS を設定します
    • large-capacity-image-lb-130122881.ap-northeast-1.elb.amazonaws.com は先に作成した ALB のドメインです
[
   {
       "AllowedHeaders": [
           "*"
       ],
       "AllowedMethods": [
           "PUT",
           "POST"
       ],
       "AllowedOrigins": [
           "https://large-capacity-image-lb-130122881.ap-northeast-1.elb.amazonaws.com"
       ],
       "ExposeHeaders": [
           "ETag"
       ]
   }
]

実行

  • 画面( アップロードが完了した画面。非常に寂しい )
    ファイル・アップロード画面

  • ブラウザのコンソール

    • アップロード開始時
      コンソールログ1
    • アップロード終了時
      コンソールログ2

その他

マルチパートアップロードでは SDK の completeMultipartUpload メソッドが呼びだされずアップロード処理を終了した場合、コンソールなどからは見えない場所に分割アップロードされたデータが保存されます。そして、こちらのデータ課金対象となってしまうため、この不要なデータを削除する必要があります。
一つの方法として、 AWS SDK for JavaScript の listMultipartUploadsabortMultipartUpload メソッドの利用がありますが、それらを実行するための環境構築として IAM ユーザ作成してクレデンシャルを PC に設定してや、 EC2 を起動して IAM ロールを付与してなど色々めんどくさいです。そこで AWS re:Invent 2020 で発表された AWS CloudShell が非常に役に立ちました。 これを使うと、 AWS Management Console にログインしているユーザの権限で各種 AWS リソースへの API を呼び出す事ができます。
細かい使い方はリンク先を読んでいただければと思いますが、 CloudShell に以下のようなコードを配置し実行することで、不要なデータの削除が容易にできました。リソースの後片付け ( IAM ユーザ削除や EC2 削除など ) を実施する必要が無いのがすごく良かったです。また CloudShell 自体の料金が 10 シェル / リージョン まで無料なのも素敵ポイントですね。

const AWS = require('aws-sdk')
const BUCKET_NAME = 'large-capacity-image-bucket'

const s3 = new AWS.S3({signatureVersion: 'v4'});

let listParams = {Bucket: BUCKET_NAME};
s3.listMultipartUploads(listParams).promise()
  .then(res => {
    console.log(res);
    for (let upload of res.Uploads) {
      console.log(upload);
      let abortParams = {
        Bucket: BUCKET_NAME,
        Key : upload.Key,
        UploadId: upload.UploadId,
      };
      s3.abortMultipartUpload(abortParams).promise()
        .then(res => {
          console.log('delete!');
        });
    }
  });

免責

本投稿は、個人の見解であり、所属する組織の公式見解ではありません。
掲載しているプログラムの動作に関しても保障いたしませんので参考程度としてください。