🌎

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 インスタンス、死亡確認

何が起きたのか

  1. メモリ不足: 数GBのファイルをメモリに全展開して、App Serviceのメモリが枯渇
  2. インスタンスクラッシュ: 結果的にプロセスが死んでサービス停止
  3. 復旧時間: インスタンスの再起動で数分間サービス停止

解決策:チャンク分割で攻略

作戦変更

問題を分析した結果、こんな戦略に変更しました:

  1. フロントエンド: ファイルを4MBずつに分割して送る
  2. App Service: チャンクを受け取って、そのままBlob Storageに流す(メモリに貯めない)
  3. 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段階の処理

  1. ステージング: stageBlock() でブロックを一時的にアップロード
  2. コミット: 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は落ちなくなりました。やったね
↓↓↓サンプルコード↓↓↓
https://github.com/morimori-moribayashi/FileUploadSample

参考リンク

ネイバーズ東京

Discussion