PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

署名付きURLを使用したAmazon S3へのマルチパートアップロードを実装してみた

初めまして、弊社のプロダクト「KRONOS DRIVE」開発メンバーの大野です。

2024年に突入しましたが、年末年始はどのように過ごされましたでしょうか? 私はというと、カリフォルニアのディズニーランドで年越しをしてきました。初めての年越しディズニーでとても楽しかったのですが、夜寒すぎたのと疲労でニューイヤーのイベントが終わったらすぐにホテルへ戻りました笑

さて、以前「KRONOS DRIVE」の機能開発において、AWS SDKをフロント側で使用せず、APIで発行した署名付きURLでブラウザからS3へ直接アップロードする機能を実装したため、その方法を紹介させていただきます。

Amazon S3とは?

ご存知の方も多いと思いますが、念のためS3についても簡単に説明します。 Amazon S3の公式ページ*1によると、以下のように説明されています。

Amazon S3 は、業界最高水準のスケーラビリティ、データ可用性、セキュリティ、およびパフォーマンスを提供するオブジェクトストレージサービスです。

またS3とは、「Simple Storage Service」のことを略した呼び方になります。

AWS SDKとは?

こちらもAWSの公式ページ*2によると、SDKについて以下のように定義されています。

Software Development Kit (SDK) は、開発者向けのプラットフォーム固有の構築ツールのセットです。特定のプラットフォーム、オペレーティングシステム、またはプログラミング言語で実行されるコードを作成するには、デバッガー、コンパイラー、ライブラリなどのコンポーネントが必要です。SDK は、ソフトウェアの開発と実行に必要なすべてを 1 か所にまとめます。さらに、ドキュメント、チュートリアル、ガイドなどのリソースや、アプリケーション開発を高速化するための API やフレームワークも含まれています。

AWS SDKを使用したブラウザからのアップロード方法

AWS SDKを使用してファイルをアップロードする仕組みを簡単にまとめた図がこちらになります。

AWS SDKを使用したアップロードの仕組み

①② 一時的な認証情報取得

AWSへ直接アップロードをするために必要な認証情報をバックエンドから取得します。

③ 一時的な認証情報を使用してS3へアップロード

AWS SDK for JavaScriptを使用してブラウザからアップロードをする場合には、マルチパートアップロード*3にも対応しているManagedUploadなどを使用することができます。

const AWS = require('aws-sdk');

const upload = new AWS.S3.ManagedUpload({
  params: {
    Bucket: 'バケット名',
    Key: 'アップロードのキー',
    Body: 'ファイル'
  }
});

④ アップロード結果

アップロード結果が返却されます。

署名付きURL(Presigned URL)とは?

Amazon S3は基本的には権限を持ったアカウント以外からの操作はできませんが、署名付きURLを発行して使用することで、指定した行動を指定した範囲内でなら可能になります。 AWS Security Token Service を使用しての発行なら最大36時間、IAMユーザーなら最大7日間の有効期間を持ったURLを発行できます。

詳しくはAWSのユーザーガイドをご覧いただければと思います。

docs.aws.amazon.com

署名付きURLを使用したブラウザからのアップロード方法

通常アップロードの場合

サイズが大きくないファイルであれば、単純に以下の処理を1回するだけで1ファイルのアップロードが可能です。

署名付きURLはバックエンドで発行します。

署名付きURLを使用した通常アップロードの仕組み

マルチパートアップロードの場合

AWS SDK、REST API、 AWS CLI を使用していてファイルサイズが5GB以下であれば通常アップロードが可能です。しかしそれより大きなサイズの場合は、オブジェクトをいくつかのパートに分けてアップロードすることが必要になります。 マルチパートアップロードであれば、最大5TB のサイズの単一の大容量オブジェクトをアップロードできます。

また下図③〜⑥の処理は、パートの個数分繰り返されます。

署名付きURLを使用したマルチパートアップロードの仕組み

AWS SDKと署名付きURL使用時のメリット・デメリットの比較

2種類のブラウザからのアップロード方法を確認したところで、それぞれのメリットとデメリットを比較してみましょう。

メリット・デメリットの比較表

方法 メリット デメリット
ブラウザ上でAWS SDKを使用
  • 実装が簡単
  • アップロードが最適化される
  • temporary credential を払い出す API をアプリケーション(バックエンド)に実装する必要がある
  • フロント側のバンドルサイズが大きくなってしまう
  • AWS特化となるため汎用的にしづらい
署名付きURLを使用
  • フロント側のバンドルサイズを抑えられる
  • AWS以外のサービスにも汎用的に使える可能性がある
  • SDKと比較して実装に手間がかかる

メリット&デメリットを比較した結果、元々署名付きURL発行やマルチパートアップロード用のAPIがバックエンドに存在していて追加開発の範囲が少ないことと、今後のプロダクトの方向性を考慮した結果、今回は署名付きURLを使用した直接アップロードを採用することにしました。

実装の内容

それでは、署名付きURLを使ったブラウザからの直接アップロードの実装内容を紹介していきます。 なおAWS SDKはv2を、CORS設定は完了していることを前提に実装しています。

構成

署名付きURLとAPIを使用したアップロードの構成

バックエンド側ではAWS SDKを使用したAPIを用意し、フロントエンド側では、それぞれのAPIを叩けるような処理を用意します。 APIは「アップロード開始」「署名付きURL発行」「アップロード完了」の3つが必要になります。

バックエンド側(API)の実装

バックエンドのAPIに関しては、AWS SDKのS3クラスを使用して実装をします。

アップロード開始

マルチパートアップロードに必要なアップロードIDの発行をおこなうAPIになります。 S3クラスのcreateMultipartUploadを使用します。返却される内容はKeyUploadIdになります。

サンプルコード(クリックで展開)

beginMultipartUpload(name, path) {
  const options = {
    region: 'リージョン',
    signatureVersion: '署名のバージョン',
    credentials: '認証情報'
  };
  const s3 = AWS.S3(options);

  // 引数のname、pathからS3のキーを導く
  const params = {
    Bucket: 'アップロード先バケット名',
    Key: 'アップロードオブジェクトのキー'
  };

  return s3.createMultipartUpload(params).promise();
}

署名付きURL発行

パートアップロード用署名付きURLの発行をおこなうAPIになります。 S3クラスのgetSignedUrlPromiseを使用します。返却される内容は、署名付きURL(文字列)になります。

サンプルコード(クリックで展開)

createMultipartUploadUrl() {
  const options = {
    region: 'リージョン',
    signatureVersion: '署名のバージョン',
    credentials: '認証情報'
  };
  const s3 = AWS.S3(options);

  const operation = 'uploadPart';
  const params = {
    Bucket: 'アップロード先バケット名',
    Key: 'アップロードオブジェクトのキー',
    UploadId: 'createMultipartUploadで返却されたアップロードID',
    PartNumber: 'パートNo.'
  };

  return s3.getSignedUrlPromise(operation, params);
}

アップロード完了

パートの結合を実行し、S3オブジェクトの情報を返却します。この処理が完了すると、ファイルが確認できるようになります。 S3クラスのcompleteMultipartUploadを使用します。返却される内容はKeyETag*4VersionIdになります。

サンプルコード(クリックで展開)

completeMultipartUpload() {
  const options = {
    region: 'リージョン',
    signatureVersion: '署名のバージョン',
    credentials: '認証情報'
  };
  const s3 = AWS.S3(options);

  const params = {
    Bucket: 'アップロード先バケット名',
    Key: 'アップロードオブジェクトのキー',
    UploadId: 'createMultipartUploadで返却されたアップロードID',
    MultipartUpload: {
      Parts: [ /* 全パートのパートNo.とEタグの配列 */ ]
    }
  };
  
  return s3.completeMultipartUpload(params).promise();
}

フロントエンド側(HTML)の実装

今回は、ファイルを選択できる<input>とアップロードをする<button>の2つだけ用意します。

サンプルコード(クリックで展開)

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>アップロードサンプル</title>
</head>

<body>
  <input id="load_file" type="file">
  <button id="upload_button" onclick="upload()">アップロード</button>

  <script type="text/javascript" src="{JavaScriptファイル}"></script>
</body>

</html>

フロントエンド側(JavaScript)の実装

各APIにリクエストする処理と一連の処理を実行する関数を用意します。

共通

ひとまず分割のサイズを100MiBと定めます。 パートのサイズは5MiBから5GiBとなっています。(最後のアップロードパートには最小サイズの制限はありません。)

const CHUNK_SIZE = 100 * 1024 * 1024; // 100MiB

マルチパートアップロードの開始

この処理では、マルチパートアップロード時、アップロード完了時に必要なupload_idがAPIより返却されます。

サンプルコード(クリックで展開)

// マルチパートアップロードの開始リクエスト
async function beginMultipartUpload(objectName, objectPath) {
  const path = 'マルチパートアップロード開始APIパス';
  const headers = {
    'Content-Type': 'application/json'
  };
  const body = JSON.stringify({
    name: objectName,
    path: objectPath
  });

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

  return multipartUpload;
}

署名付きURL発行&パートアップロード

この処理では、署名付きURL発行のAPIリクエスト、ファイルのパート読み込み、その署名付きURLへパートのアップロードをおこないます。パートのアップロードが完了するとETagが返却されます。

サンプルコード(クリックで展開)

// presigned URLの発行リクエスト
async function createPresignedUploadUrl(params) {
  const path = 'マルチパートアップロード用署名付きURL発行APIパス';
  const headers = {
    'Content-Type': 'application/json'
  };
  const res = await fetch(path, {
    method: 'POST',
    headers,
    body: JSON.stringify(params)
  });
  const data = await res.json();

  return data.data.presigned_url;
}

// パートデータのアップロード
async function putPartData(presignedUrl, partData, partNumber) {
  const headers = {
    'Content-Type': 'application/octet-stream'
  };
  const res = await fetch(presignedUrl, {
    method: 'PUT',
    headers,
    body: partData
  });
  const etag = res.headers.get('ETag');

  return {
    ETag: etag.replaceAll('"', ''),
    PartNumber: 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);
    };

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

    reader.readAsArrayBuffer(slice);
  });

  return partData;
}

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

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

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

  return Promise.resolve(part);
}

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

この処理では、S3にアップロードしたパートをまとめて1つのファイルに戻すために必要な情報であるPartNumberETagがセットになったパートのリストを渡します。

サンプルコード(クリックで展開)

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

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

  const res = await fetch(path, {
    method: 'POST',
    headers,
    body
  });
  const data = await res.json();

  return data;
}

アップロード(アップロードボタンクリックで実行)

実際の実装では、パート数やパートサイズを調整したり、履歴を登録したりと他の処理も実行していますが、今回はあくまで署名付きURLを使用したアップロードについて焦点を当てているため省略します。

ポイントとしては、以下の2点になります。

① 非同期処理の同時実行数を制限

同時実行数について指定をせずに大容量のファイルをアップロードすると、リクエストだけ増えてしまいエラーとなったりブラウザが落ちたりする可能性があるため、今回は最大同時実行数を1ファイルにつき5つとなるよう処理に組み込んでいます。

② パートアップロード結果リストのソート

非同期で実行したマルチパートアップロード処理の結果リストをソートし、パートNo.で昇順になるようにしています。

サンプルコード(クリックで展開)

const uploadData = アップロードファイルのデータ;

async function upload() {
  try {
    const multipartUpload = await beginMultipartUpload(uploadData.name, uploadData.webkit_relative_path);
    const uploadId = multipartUpload.data.upload_id;
    const objectKey = multipartUpload.data.key;
  
    // パーツ数
    const count = Math.ceil(uploadData.file_size / CHUNK_SIZE);
  
    // 最大同時実行数
    const CONCURRENCY = 5;
  
    const promises = [];
    const multipartMap = [];
    let index = 0;

    // 同時実行数を指定し、無限に同時リクエストされないようにする
    for (let i = 0; i < CONCURRENCY ; i++) {
      promises.push(new Promise(async (resolve) => {
        while (index < count) {
          const multipartInfo = await createPresignedUrlAndPutPartData(uploadId, objectKey, uploadData.data, index++);
          multipartMap.push(multipartInfo);
        }
        resolve();
      }));
    };
  
    await Promise.all(promises);
  
    // 完了時に渡すパートNoとEタグのリストは、パートNoで昇順である必要があるためここでソート
    multipartMap.sort((a, b) => a.PartNumber - b.PartNumber);
    
    const uploadInfo = {
      upload_id: uploadId,
      multipart_map: multipartMap,
      object_key: objectKey
    }
  
    await completeMultipartUpload(uploadInfo.upload_id, uploadInfo.multipart_map, uploadInfo.object_key);
  } catch (error) {
    console.log('アップロードに失敗しました: ', error.message);
  }
}

まとめ

今回は、ブラウザからアップロードする方法について比較し、実装内容の紹介をしてみました。 どちらの方法を使用してもそれぞれのメリット・デメリットが存在するかと思いますので、より条件に合った方法を検討する際の材料になれば幸いです。

参考

docs.aws.amazon.com

docs.aws.amazon.com

docs.aws.amazon.com

*1:参照ページリンク:Amazon S3

*2:参照ページリンク:SDK とは何ですか? - SDK の説明 - AWS

*3:アップロードをするファイルが大きい場合、1つのリクエストで送信すると時間がかかってしまいます。 しかしマルチパートアップロードをすることで、ファイルを小さいパートに分割してアップロードし、全てのアップロードが完了した後に結合させることができ、1つのリクエストでするよりも早くアップロードができます。

*4:S3オブジェクトのエンティティタグでファイル内容のmd5ハッシュ値。ただしマルチパートアップロードの場合は、ファイル内容のmd5ハッシュ値とは異なるETagとなります。