🍣
大容量ファイルアップロードの実装方法:PresignedURLを使った効率的なアプローチ
はじめに
CSVファイルアップロード機能を実装していた時のことです。10MBの制限を設けていたら、同僚エンジニアから指摘を受けました。
「容量の大きいファイルもあるから、PresignedURL形式にして」
正直、PresignedURLがよく分かっていませんでした。そもそも「なんでサーバーを経由しないの?」という疑問もありました。今回、徹底的に調査して実装したので、その学びをまとめます。
なぜファイルアップロードが難しいのか
現在のgRPC実装を見てみましょう:
// 現在の実装(gRPC bytes方式)
const checkCSV = async () => {
const arrayBuffer = await file.arrayBuffer(); // ファイル全体をメモリに
const csvData = new Uint8Array(arrayBuffer);
const request = {
csvData, // ファイル全体をリクエストに含める
brandId: brandId,
};
await hogeService.checkHogeCSV(request);
};
この実装の問題:
- メモリ爆発: 100MBのファイル × 10人 = 1GBのメモリ消費
- サイズ制限: gRPCのデフォルト上限は4MB
- タイムアウト: 大きなファイルは転送時間がかかる
「なんでサーバーを経由しないの?」という最初の疑問
私が最初に考えていた「普通」の方法
// 直感的に思いつく実装
app.post('/upload', async (req, res) => {
const file = req.file; // ファイルを受け取って
// サーバーからS3へアップロード
await s3.putObject({
Bucket: 'my-bucket',
Key: file.originalname,
Body: file.buffer, // メモリに全部載せる😱
});
res.json({ success: true });
});
これが「当たり前」だと思っていました。だって:
- 認証はサーバーでチェック ✓
- データ処理はサーバーで実行 ✓
- ファイル保存も...サーバー経由?🤔
PresignedURL方式を知った時の衝撃
「え、サーバーをスキップ?セキュリティは大丈夫?」
でも調べてみると、これが現代の標準的な方法だと分かりました。
なぜサーバー経由は非効率なのか
各アップロード方式の詳細比較
1. 現在のgRPC bytes方式
// Protocol Buffers定義
message CheckHogeCSVRequest {
bytes csv_data = 1; // バイナリデータ
uint64 brand_id = 2;
}
評価項目 | 内容 |
---|---|
メモリ使用 | ❌ ファイル全体をメモリに保持 |
最大サイズ | ❌ 実用的には10-20MB程度 |
実装の複雑さ | ⭕ 比較的シンプル |
進捗表示 | ❌ 不可 |
2. 従来のmultipart/form-data方式
const formData = new FormData();
formData.append('file', file);
await fetch('/api/upload', { method: 'POST', body: formData });
評価項目 | 内容 |
---|---|
メモリ使用 | ❌ サーバー側でファイル処理 |
最大サイズ | ⭕ 設定次第でGB単位も可 |
実装の複雑さ | ⭕ 最もシンプル |
進捗表示 | 🔺 可能だが実装が必要 |
3. PresignedURL方式
// 1. URLを取得
const { uploadUrl } = await getPresignedUrl();
// 2. S3に直接アップロード
await axios.put(uploadUrl, file, {
onUploadProgress: (e) => setProgress(e.loaded / e.total * 100)
});
評価項目 | 内容 |
---|---|
メモリ使用 | ✅ サーバーメモリ使用なし |
最大サイズ | ✅ 5GBまで(マルチパートで5TB) |
実装の複雑さ | 🔺 2段階の処理が必要 |
進捗表示 | ✅ ネイティブサポート |
PresignedURLの仕組み(図解)
実装例
バックエンド実装(Node.js + TypeScript)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
class FileUploadService {
private s3Client: S3Client;
constructor() {
this.s3Client = new S3Client({ region: 'ap-northeast-1' });
}
async generatePresignedUrl(userId: string, filename: string, fileSize: number) {
// 1. セキュリティチェック
if (fileSize > 500 * 1024 * 1024) {
throw new Error('ファイルサイズは500MB以下にしてください');
}
// 2. ユニークなキーを生成(上書き防止)
const timestamp = Date.now();
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
const fileKey = `hoges/${userId}/${timestamp}-${sanitizedFilename}`;
// 3. アップロード用コマンドを作成
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: fileKey,
ContentType: 'text/csv',
// メタデータを付与
Metadata: {
userId,
originalName: filename,
uploadedAt: new Date().toISOString(),
},
});
// 4. 署名付きURLを生成(15分間有効)
const uploadUrl = await getSignedUrl(this.s3Client, command, {
expiresIn: 900,
});
return { uploadUrl, fileKey };
}
}
// Express エンドポイント
app.post('/api/presigned-url', authenticate, async (req, res) => {
try {
const { filename, fileSize } = req.body;
const service = new FileUploadService();
const result = await service.generatePresignedUrl(
req.user.id,
filename,
fileSize
);
res.json(result);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
フロントエンド実装(React + TypeScript)
import { useState } from 'react';
import axios from 'axios';
// カスタムフック
const useFileUpload = () => {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File) => {
setUploading(true);
setError(null);
try {
// 1. PresignedURLを取得
const { data } = await axios.post('/api/presigned-url', {
filename: file.name,
fileSize: file.size,
});
// 2. S3に直接アップロード(進捗付き)
await axios.put(data.uploadUrl, file, {
headers: {
'Content-Type': 'text/csv',
},
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total!
);
setProgress(percent);
},
});
// 3. バックエンドに完了通知
await axios.post('/api/upload-complete', {
fileKey: data.fileKey,
});
return { success: true, fileKey: data.fileKey };
} catch (err) {
const message = err instanceof Error ? err.message : 'アップロードに失敗しました';
setError(message);
return { success: false, error: message };
} finally {
setUploading(false);
}
};
return { uploadFile, uploading, progress, error };
};
// コンポーネント
export const HogeFileUploader = () => {
const { uploadFile, uploading, progress, error } = useFileUpload();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleSubmit = async () => {
if (!selectedFile) return;
const result = await uploadFile(selectedFile);
if (result.success) {
alert('アップロード完了!');
setSelectedFile(null);
}
};
return (
<div>
<input
type="file"
accept=".csv"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={uploading}
/>
{selectedFile && (
<div>
<p>ファイル: {selectedFile.name}</p>
<p>サイズ: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
)}
{uploading && (
<div>
<div style={{ width: '100%', backgroundColor: '#e0e0e0' }}>
<div
style={{
width: `${progress}%`,
height: '20px',
backgroundColor: '#4caf50',
transition: 'width 0.3s',
}}
/>
</div>
<p>{progress}% アップロード済み</p>
</div>
)}
{error && <p style={{ color: 'red' }}>{error}</p>}
<button
onClick={handleSubmit}
disabled={!selectedFile || uploading}
>
{uploading ? 'アップロード中...' : 'アップロード開始'}
</button>
</div>
);
};
セキュリティのベストプラクティス
// ⚠️ よくある間違い
const badExample = {
Bucket: 'my-bucket',
Key: req.body.filename, // 危険!パストラバーサル攻撃の可能性
};
// ✅ セキュアな実装
const secureExample = {
Bucket: 'my-bucket',
Key: `users/${userId}/${timestamp}-${sanitize(filename)}`,
ContentType: 'text/csv', // 固定して悪意のあるファイルを防ぐ
ServerSideEncryption: 'AES256', // 暗号化
Metadata: {
'uploaded-by': userId,
'scanned': 'pending', // ウイルススキャン状態
},
};
CORS設定(S3バケット)
{
"CORSRules": [{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://yourdomain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}]
}
いつPresignedURLを使うべきか
✅ PresignedURLが最適なケース
- ファイルサイズが10MB以上
- 動画・画像などのメディアファイル
- 同時アップロード数が多い
- 進捗表示が必要
- サーバーリソースが限られている
❌ 従来方式で十分なケース
- 小さなJSONデータ(< 1MB)
- リアルタイムな処理が必要
- ファイル内容の即時検証が必要
まとめ
最初は「なんでサーバーを経由しないの?」と疑問でしたが、調査して実装してみると、PresignedURLの利点が明確になりました:
- スケーラビリティ: サーバーリソースを消費しない
- パフォーマンス: 大容量ファイルも高速転送
- UX向上: 進捗表示でユーザー体験改善
- コスト削減: サーバーの負荷軽減
特にコーディネートデータのような大容量CSVファイルを扱う場合、PresignedURL方式は必須です。
実装のポイント
- 有効期限は短めに(5-15分)
- ファイル名は必ずサニタイズ
- エラー時のリトライ処理を実装
- CORS設定を忘れずに
「サーバーが全てを処理する」という固定観念を捨て、クラウドサービスを活用することで、より効率的なシステムが構築できることを学びました。
Discussion