数GB単位の動画やログファイルをユーザーにアップロードさせる機能を実装する際、単純なフォーム送信やサーバー経由のストリーム転送を行っていませんか?そのアプローチは、サーバーのメモリ枯渇(OOM)や帯域幅の圧迫、そして504 Gateway Timeoutの温床となります。
AWS S3 Presigned URL(署名付きURL)とMultipart Uploadの組み合わせは、この問題を解決する「業界標準」のアーキテクチャです。サーバーを介さずにクライアントからS3へ直接、かつ分割してファイルを転送することで、スケーラビリティと信頼性を劇的に向上させます。
なぜ「サーバー経由」ではダメなのか?
アナロジー:引っ越し業者のトラックと倉庫の鍵
従来の「サーバー経由アップロード」は、あなたが引っ越しの荷物(ファイル)を一度「仲介業者(サーバー)」の小さな事務所に運び込み、業者がそれを改めて「巨大倉庫(S3)」へ運ぶようなものです。荷物が巨大だと、仲介業者の事務所はパンクし、他の客(リクエスト)をさばけなくなります。
Presigned URLは、倉庫の管理人が発行する「一時的な合鍵」です。これをあなた(クライアント)に渡すことで、あなたは仲介業者を通さず、直接倉庫の指定場所に荷物を搬入できます。
さらにMultipart Uploadは、巨大な家具を分解して運ぶ手法です。もし途中で一つの部品を落としても、その部品だけ運び直せば良く、最初からやり直す必要がありません。
技術的なメリットは以下の通りです:
- サーバー負荷ゼロ: ファイルデータがアプリケーションサーバーを通過しないため、Node.jsのイベントループやメモリを消費しません。
- コスト削減: EC2やLoad Balancerのデータ転送量を大幅に削減できます。
- 耐障害性: ネットワークが不安定でも、失敗したパート(Chunk)のみを再送するだけで済みます。
実装:AWS SDK v3によるアーキテクチャ
ここでは、Node.js(Backend)と一般的なFrontend(React等)を想定した実装フローを解説します。AWS SDKは最新のv3を使用します。
Step 0: 必要なパッケージのインストール
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Step 1: Backend - アップロードの初期化とURL生成
クライアントから「ファイル名」と「パート数」を受け取り、S3に対してマルチパートアップロードを開始します。その後、各パートごとの署名付きURLを生成して返します。
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({ region: "ap-northeast-1" });
const BUCKET_NAME = "your-bucket-name";
// 1. アップロード初期化 & URL生成API
export const initiateUpload = async (fileName, totalParts) => {
// A. マルチパートアップロードの開始を宣言
const createCommand = new CreateMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: fileName,
// 必要に応じてContentTypeなどを設定
});
const { UploadId } = await s3Client.send(createCommand);
// B. 各パート用のPresigned URLを生成
const partPromises = [];
for (let i = 0; i < totalParts; i++) {
const partNumber = i + 1;
const command = new UploadPartCommand({
Bucket: BUCKET_NAME,
Key: fileName,
UploadId,
PartNumber: partNumber,
});
// URLの有効期限は短めに(例: 15分)
partPromises.push(
getSignedUrl(s3Client, command, { expiresIn: 900 }).then((url) => ({
partNumber,
url,
}))
);
}
const parts = await Promise.all(partPromises);
return { uploadId: UploadId, parts };
};
Step 2: Frontend - 並列アップロードの実装
受け取ったURLに対してPUTリクエストを送ります。重要なのは、S3から返されるETagヘッダーを取得することです。
CORS設定の注意点: S3のバケット設定(CORS)で、ExposeHeadersにETagを含めないと、ブラウザ側のJavaScriptからETagを読み取れず、アップロードを完了できません。
// Frontend: ファイルを分割してアップロード
const uploadFile = async (file, uploadData) => {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB (S3の最小制限)
const uploadPromises = uploadData.parts.map(async (part) => {
const start = (part.partNumber - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const response = await fetch(part.url, {
method: "PUT",
body: chunk,
});
if (!response.ok) throw new Error("Upload failed");
// ETagはダブルクォートで囲まれている場合があるため除去処理が必要な場合も
const eTag = response.headers.get("ETag");
return { PartNumber: part.partNumber, ETag: eTag };
});
// 並列実行(本番ではp-limit等で同時接続数を制御することを推奨)
const partsInfo = await Promise.all(uploadPromises);
// Backendへ完了通知を送る
await completeUpload(uploadData.uploadId, partsInfo);
};
Step 3: Backend - アップロードの完了
全てのパートが揃ったら、S3に結合を指示します。
// 2. アップロード完了API
export const completeUpload = async (fileName, uploadId, parts) => {
// partsは { PartNumber, ETag } の配列である必要があり、PartNumber順にソート推奨
const sortedParts = parts.sort((a, b) => a.PartNumber - b.PartNumber);
const command = new CompleteMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: fileName,
UploadId: uploadId,
MultipartUpload: {
Parts: sortedParts,
},
});
await s3Client.send(command);
return { status: "success", location: `s3://${BUCKET_NAME}/${fileName}` };
};
コストトラップに注意: マルチパートアップロードが途中で中断された場合、アップロードされた「不完全なパート」はS3に残存し、ストレージ料金が発生し続けます。必ずS3のライフサイクルルールを設定し、「不完全なマルチパートアップロードを7日後に削除する」設定を有効にしてください。
Frequently Asked Questions
Q. ファイルサイズの制限はありますか?
A. はい。S3の仕様上、マルチパートアップロードの各パート(最後のパートを除く)は最低5MB以上である必要があります。ファイルサイズが5MB未満の場合は、マルチパートではなく通常のPutObjectCommand用の署名付きURLを使用する分岐処理を入れるのが一般的です。
Q. 署名付きURLのセキュリティリスクは?
A. 署名付きURLは、発行時に指定した操作(この場合はPUT)とリソースに対してのみ有効です。有効期限(expiresIn)を必要最小限(例: 15分〜1時間)に設定することでリスクを最小化できます。また、サーバー側で認証済みのユーザーに対してのみURLを発行することが前提です。
Q. アップロード中にネットワークが切れた場合は?
A. クライアント側で再試行ロジックを実装してください。失敗したパートだけを再度アップロードすれば良いため、ネットワーク帯域の無駄がありません。既存のUploadIdを使い回すことで、アップロードを再開(Resume)するUXも構築可能です。
Post a Comment