🌎
Azure App Service + Next.jsでファイルの分割アップロードを実装する
Azure App Service上でNext.jsアプリ作ってたんですが、大容量ファイルのアップロードでApp Serviceがメモリ不足でクラッシュするという事件が発生しました😇
開発環境とアーキテクチャ
まず、今回作ってたシステムの構成はこんな感じ:
ここで重要な制約が...
- VNet統合: App ServiceとBlob StorageはVNet統合されてる
- 外部アクセス制限: ブラウザから直接Blob Storageにアクセスできない
- 必須経路: ファイルアップロードは全部App Service経由じゃないとダメ
要するに、よくある「ブラウザ→Blob Storage直アップロード」パターンが使えないんですよね。全部App Service通さないといけない。
初期実装
最初は深く考えずに、こんな感じで実装:
// この実装、後で大爆発します💣
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
// やばい:ファイル全体をメモリに展開
const buffer = Buffer.from(await file.arrayBuffer())
// Azure Blob Storageにポイ
const blockBlobClient = containerClient.getBlockBlobClient(file.name)
await blockBlobClient.upload(buffer, buffer.length)
return new Response('Success', { status: 200 })
}
// フロントエンド側もシンプル
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
// ファイル全体をドーン!
await fetch('/api/upload', {
method: 'POST',
body: formData
})
}
数十MBのファイルなら問題なく動いてたんで、「よし、完璧!」って思ってました。
そして事件は起きた 🔥
数GBの大容量ファイルをテストでアップロードしようとしたその時...
💥 App Service インスタンス、死亡確認
何が起きたのか
- メモリ不足: 数GBのファイルをメモリに全展開して、App Serviceのメモリが枯渇
- インスタンスクラッシュ: 結果的にプロセスが死んでサービス停止
- 復旧時間: インスタンスの再起動で数分間サービス停止
解決策:チャンク分割で攻略
作戦変更
問題を分析した結果、こんな戦略に変更しました:
- フロントエンド: ファイルを4MBずつに分割して送る
- App Service: チャンクを受け取って、そのままBlob Storageに流す(メモリに貯めない)
- Blob Storage: Azure Block Blobの機能でチャンクを最後に結合
Azure Block Blob
Azure Block Blobには、ブロック単位でファイルをアップロードできる機能があります:
通常のファイルアップロード:
[ファイル全体] → Blob Storage
Block Blobのブロックアップロード:
[ブロック1] → Blob Storage (ステージング状態)
[ブロック2] → Blob Storage (ステージング状態)
[ブロック3] → Blob Storage (ステージング状態)
↓
[結合指示] → 完成したファイル
2段階の処理
-
ステージング:
stageBlock()
でブロックを一時的にアップロード -
コミット:
commitBlockList()
でブロックを結合して最終ファイル完成
// ステージング:ブロックを一時保存
await blockBlobClient.stageBlock(blockId, chunkBuffer, chunkBuffer.length)
// コミット:全ブロックを結合
await blockBlobClient.commitBlockList([blockId1, blockId2, blockId3, ...])
実装してみた
1. Azure Blob Service層
// services/azure-blob-service.ts
import { BlobServiceClient } from "@azure/storage-blob"
interface UploadBlobResponse {
success: boolean
blockId?: string
error?: any
}
function initializeBlob() {
const connectionString = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
if (!connectionString) {
throw new Error("接続文字列がないよ〜")
}
return new BlobServiceClient(connectionString)
}
async function createContainer(containerName: string) {
const blobServiceClient = initializeBlob()
const containerClient = blobServiceClient.getContainerClient(containerName)
await containerClient.createIfNotExists()
return containerClient
}
export async function uploadBlobByChunk(
containerName: string,
blobName: string,
chunk: File,
index: number
): Promise<UploadBlobResponse> {
try {
const containerClient = await createContainer(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(blobName)
// インデックスをBase64エンコードしてブロックID作成
// Azure Block BlobではブロックIDはBase64形式である必要がある
const blockId = Buffer.from(index.toString()).toString('base64')
// 🔥 ここがポイント:4MBのチャンクをそのまま流す
const chunkBuffer = Buffer.from(await chunk.arrayBuffer())
await blockBlobClient.stageBlock(blockId, chunkBuffer, chunkBuffer.length)
return {
success: true,
blockId: blockId
}
} catch (error) {
console.error("チャンクアップロード失敗:", error)
return {
success: false,
error: error
}
}
}
// 全部のチャンクが終わったら結合
export async function commitBlob(
containerName: string,
blobName: string,
blockIds: string[]
) {
const containerClient = await createContainer(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(blobName)
await blockBlobClient.commitBlockList(blockIds)
}
2. API Routes
// app/api/upload/route.ts
import { uploadBlobByChunk } from "@/services/azure-blob-service"
export async function POST(req: Request) {
const formData = await req.formData()
const chunk = formData.get('chunk') as File
const index = formData.get('index') as string
const containerName = formData.get('containerName') as string
const blobName = formData.get('blobName') as string
if (!chunk || !index) {
return new Response('パラメータが足りないよ', { status: 400 })
}
// 🚀 メモリ効率アップ:4MBのチャンクだけ処理
const response = await uploadBlobByChunk(
containerName,
blobName,
chunk,
parseInt(index)
)
return new Response(JSON.stringify(response), { status: 200 })
}
3. フロントエンドのチャンク分割
// utils/chunkUpload.ts
import axios from "axios"
export async function chunkUpload(
file: File,
onProgress: (progress: number) => void
) {
const containerName = "uploads"
const blobName = file.name
const chunkSize = 4 * 1024 * 1024 // 4MB(Azure Blob Storageの最大チャンクサイズ)
const totalChunks = Math.ceil(file.size / chunkSize)
const blockIds: string[] = []
console.log(`ファイル分割開始: ${totalChunks}個のチャンクに分割`)
try {
// 📦 ファイルを4MBずつに分割して順次アップロード
for (let i = 0; i < totalChunks; i++) {
const formData = new FormData()
const start = i * chunkSize
const end = start + chunkSize
const chunk = file.slice(start, end) // この操作はメモリ効率的
formData.append('chunk', chunk)
formData.append('index', i.toString())
formData.append('containerName', containerName)
formData.append('blobName', blobName)
try {
const response = await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const result = response.data
if (result.success && result.blockId) {
blockIds.push(result.blockId)
}
// 進捗更新(これがあるとユーザーが安心する)
onProgress((i + 1) / totalChunks * 100)
console.log(`チャンク ${i + 1}/${totalChunks} 完了`)
} catch (error) {
console.error(`チャンク ${i + 1} でコケた:`, error)
throw new Error(`チャンク ${i + 1} のアップロードに失敗`)
}
}
// 全部終わったら結合処理
console.log('チャンク結合開始...')
await commitUpload(containerName, blobName, blockIds)
console.log('✅ ファイル結合完了!')
} catch (error) {
console.error('アップロード処理でエラー:', error)
throw error
}
}
async function commitUpload(containerName: string, blobName: string, blockIds: string[]) {
await axios.post('/api/commit', {
containerName,
blobName,
blockIds
})
}
4. UI実装(進捗バー付き)
// app/page.tsx
"use client"
import { useState } from "react"
import { chunkUpload } from "@/utils/chunkUpload"
export default function Home() {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setError(null)
setUploading(true)
setProgress(0)
try {
console.log(`アップロード開始: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`)
await chunkUpload(file, (progress: number) => {
setProgress(progress)
})
console.log('🎉 アップロード成功!')
} catch (err) {
console.error('😭 アップロード失敗:', err)
setError('アップロードがコケました...')
} finally {
setUploading(false)
}
}
return (
<div className="p-6 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">大容量ファイルアップロード</h1>
<input
type="file"
onChange={handleFileSelect}
disabled={uploading}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
{uploading && (
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-gray-600 mt-2">
アップロード中... {Math.round(progress)}%
</p>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
</div>
)
}
結果:無事解決 💪
App Serviceは落ちなくなりました。やったね
↓↓↓サンプルコード↓↓↓
Discussion