API で動画をバッチトランスコード:アーキテクチャと実装、スケーリングの話
1 万本の動画を落とさずに変換する。キュー設計、リトライ戦略、Node.js での実装まで、本番運用で踏んだ設計判断を全部書きます。

動画 1 本を変換するのは簡単です。1 万本を夜通し落とさずに回すのは、まったく別の問題です。メディアライブラリの移行、ユーザーアップロードの正規化、アーカイブのコーデック変換——どれも小規模なら気にならないこと(キュー管理、同時実行制御、リカバリ、コスト)が、バッチになるとすべて顔を出します。
この記事では、本番に耐えるバッチトランスコード(batch transcoding)パイプラインを組むためのアーキテクチャ、実装の細部、運用面の注意点を順に見ていきます。
バッチトランスコードが必要になる場面
「FFmpeg を for ループで回すだけ」では済まない理由があります。よくあるシナリオ:
UGC プラットフォームのバックフィル
最初はユーザーがアップロードしたフォーマットそのままで配信していた。今は ABR 配信のため、全動画を H.264 MP4 の 3 解像度に揃えたい。ソース 5 万本 → 出力 15 万本。詳しくは UGC プラットフォームの動画処理ガイド。
メディアライブラリの移行
オンプレからクラウドへ、あるいは CDN を切り替える。配信仕様に合わせて全アセットを再エンコードする必要がある。
フォーマットの標準化
長く運用していると MKV、AVI、MOV、WMV、FLV と十数種のフォーマットが溜まる。「モバイルで再生できない」というサポートチケットが積み上がってきたら、一括正規化のタイミング。
アーカイブとコンプライアンス
法令や社内ポリシーで、過去コンテンツ全体に特定のコーデック・解像度・ウォーターマークを当てる必要があるケース。
なぜバッチトランスコードは難しいのか
シングルサーバーで ffmpeg を for ループ——これ、20 ファイルなら動きます。2 万ファイルでは動きません。
リソースの取り合い
動画変換は CPU を食います。1080p 1 本で 4〜8 コアを張り付かせる。8 コアのマシンで 10 並列したらスラッシング・OOM kill・タイムアウト由来の品質劣化のフルコース。
キュー管理
どのファイルが pending / in-progress / completed / failed なのか追えないと、重複処理、ジョブ消失、再起動後に復元不能、というつらい状態に必ず行き着きます。
スケールしてからのエラー処理
1 万ファイルでエラー率 1% は失敗 100 件。壊れたソース、コーデック非対応、ネットワークタイムアウト、ディスクフル——失敗パターンごとに対処が違います。
スケーリング上限
シングルマシンのコア数は固定。バックログが増えたら水平スケール(worker 並列、ジョブ分配、結果集約)が必要になります。
アーキテクチャパターン
代表的なアプローチは 3 つ。それぞれトレードオフが違います。
パターン 1:キュー + Worker プール(セルフホスト)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Job DB │───>│ Queue │───>│ Worker 1 │
│ │ │ (Redis / │ │ (FFmpeg) │
│ │ │ RabbitMQ)│ ├──────────────┤
│ │ │ │───>│ Worker 2 │
│ │ │ │ │ (FFmpeg) │
│ │ │ │ ├──────────────┤
│ │ │ │───>│ Worker N │
└──────────┘ └───────────┘ └──────────────┘
良い点: すべて自分で握れる、ジョブ単位の課金がない、オフラインでも動く。
つらい点: サーバー、FFmpeg のバージョン、スケーリング、監視、障害復旧——全部自分の責任。Worker のプロビジョニングだけで結構な工数がかかり、専任の DevOps が必要になります。
パターン 2:イベント駆動(Cloud Functions)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ S3 │───>│ Lambda / │───>│ S3 Output │
│ Upload │ │ Cloud Fn │ │ Bucket │
│ Event │ │ (FFmpeg) │ │ │
└──────────┘ └───────────┘ └──────────────┘
良い点: 需要に応じて自動スケール、アイドルサーバーゼロ、呼び出し単位の課金。
つらい点: 関数のタイムアウト上限(AWS Lambda は 15 分)、コールドスタート(cold start)、FFmpeg バイナリのサポートが限定的、メモリ制限。サムネイル生成なら問題ないが、長尺動画ではほぼ詰む。詳細は Lambda で FFmpeg を動かすときの罠 を参照。
パターン 3:クラウドトランスコード API
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Your │───>│ API │───>│ Webhook │
│ App │ │ Service │ │ Callback │
│ │ │ │ │ │
└──────────┘ └───────────┘ └──────────────┘
良い点: インフラ管理ゼロ、どんなボリュームにもスケール、エラー処理とリトライ(retry)が組み込み済み、FFmpeg のバージョンが安定。
つらい点: ジョブ単位の課金、ネットワーク必須、データが自社インフラの外に出る。
比較
| 観点 | セルフホストキュー | Cloud Functions | Cloud API |
|---|---|---|---|
| 立ち上げ時間 | 数日〜数週間 | 数時間 | 数分 |
| スケーリング | 手動 | 自動(上限あり) | 自動 |
| 動画長の上限 | 無制限 | 約 15 分 | 無制限 |
| メンテナンス | 高 | 中 | なし |
| 100 本/日のコスト | $$(サーバー) | $ | $ |
| 1 万本/日のコスト | $$$(クラスタ) | $$$(呼び出し) | $$ |
| エラー処理 | 自前 | 自前 | 組み込み |
実装:Cloud API でのバッチトランスコード
Node.js でひととおり組んでみます。アーキテクチャはシンプル:ソース動画リストを読む → 同時実行を絞って API に投げる → 進捗を追う → 失敗をハンドリング。
プロジェクト準備
mkdir batch-transcode && cd batch-transcode
npm init -y
npm install p-limit
バッチ処理本体
// batch-transcode.js
import pLimit from "p-limit";
const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;
const CONCURRENCY = 20; // max parallel jobs
const POLL_INTERVAL = 5000; // 5 seconds
const MAX_RETRIES = 3;
// 模拟视频源列表 — 实际场景从数据库或 S3 列表获取
function getVideoList() {
return Array.from({ length: 100 }, (_, i) => ({
id: `video-${String(i + 1).padStart(4, "0")}`,
url: `https://your-bucket.s3.amazonaws.com/raw/video-${i + 1}.mp4`,
}));
}
// 提交单个转码任务
async function submitTask(video) {
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
}),
});
if (!response.ok) {
throw new Error(`Submit failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.task_id;
}
// 轮询任务状态直到完成或失败
async function waitForCompletion(taskId) {
while (true) {
const response = await fetch(`${API_BASE}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const task = await response.json();
if (task.status === "completed") {
return { success: true, output_url: task.output_url, duration: task.duration };
}
if (task.status === "failed") {
throw new Error(`Task failed: ${task.error}`);
}
// 仍在处理中,等待后继续轮询
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
// 带重试的单视频处理
async function processVideo(video, attempt = 1) {
try {
console.log(`[${video.id}] 提交任务 (尝试 ${attempt}/${MAX_RETRIES})`);
const taskId = await submitTask(video);
console.log(`[${video.id}] 任务已提交: ${taskId}`);
const result = await waitForCompletion(taskId);
console.log(`[${video.id}] 完成 — 耗时 ${result.duration}s`);
return { video, status: "completed", ...result };
} catch (error) {
if (attempt < MAX_RETRIES) {
const delay = Math.pow(2, attempt) * 1000; // 指数退避
console.warn(`[${video.id}] 失败,${delay / 1000}s 后重试: ${error.message}`);
await new Promise((resolve) => setTimeout(resolve, delay));
return processVideo(video, attempt + 1);
}
console.error(`[${video.id}] 最终失败: ${error.message}`);
return { video, status: "failed", error: error.message };
}
}
// 主函数 — 带并发限制的批量处理
async function main() {
const videos = getVideoList();
const limit = pLimit(CONCURRENCY);
console.log(`开始批量转码: ${videos.length} 个视频, 并发: ${CONCURRENCY}`);
const startTime = Date.now();
const results = await Promise.all(
videos.map((video) => limit(() => processVideo(video)))
);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const completed = results.filter((r) => r.status === "completed").length;
const failed = results.filter((r) => r.status === "failed").length;
console.log(`\n===== 批量处理完成 =====`);
console.log(`总计: ${videos.length} | 成功: ${completed} | 失败: ${failed}`);
console.log(`总耗时: ${elapsed}s`);
// 输出失败列表以便后续排查
if (failed > 0) {
console.log(`\n失败列表:`);
results
.filter((r) => r.status === "failed")
.forEach((r) => console.log(` ${r.video.id}: ${r.error}`));
}
}
main();
実行:
FFHUB_API_KEY=your_key node batch-transcode.js
設計判断のポイント
Promise.all を全配列に当てるのではなく p-limit を使う理由は? 1 万件の API リクエストを同時発火するとどんなエンドポイントも壊れます。p-limit は同時実行を CONCURRENCY までに抑えるクライアント側のレートリミッタとして機能します。
指数バックオフを使う理由は? 一過性の失敗(ネットワークの瞬断、レートリミット)はだいたい時間が解決します。即リトライはむしろ負荷をかけるだけ。2s → 4s → 8s と引いていけば、システムにも回復の時間が与えられます。
ここで Webhook ではなくポーリングなのは? バッチスクリプトとしてはポーリングのほうが書きやすいから。Web サーバーを伴う本番システムでは Webhook のほうが効率的——次節で扱います。
Webhook ベースのアーキテクチャ(本番運用)
本番で何千ものタスクをポーリングするのは帯域の無駄です。Webhook に切り替えましょう。
// Express webhook endpoint
app.post("/webhooks/transcode-complete", async (req, res) => {
const { task_id, status, output_url, error } = req.body;
// 更新数据库中的任务状态
await db.query(
`UPDATE transcode_jobs
SET status = $1, output_url = $2, error = $3, completed_at = NOW()
WHERE task_id = $4`,
[status, output_url, error, task_id]
);
// 如果失败且未超过重试次数,重新入队
if (status === "failed") {
const job = await db.query(
"SELECT * FROM transcode_jobs WHERE task_id = $1",
[task_id]
);
if (job.retry_count < MAX_RETRIES) {
await enqueueRetry(job);
}
}
res.sendStatus(200);
});
タスク投入時に Webhook URL を渡します:
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 output.mp4`,
webhook_url: "https://your-app.com/webhooks/transcode-complete",
}),
});
エラーハンドリング戦略
バッチ規模ではエラーは必ず出ます。分類して処理を分けましょう:
| エラー種別 | 例 | 戦略 |
|---|---|---|
| 一過性 | ネットワークタイムアウト、503 | バックオフ付きでリトライ |
| ソースファイル | 壊れたファイル、コーデック非対応 | ログを残してスキップ、要レビュー扱い |
| 設定不備 | FFmpeg 引数が無効 | コマンド修正してバッチ再投入 |
| レートリミット | 429 Too Many Requests | 並列度を下げ、ディレイを足す |
| 恒久的 | ファイル未存在 (404) | ログのみ、リトライしない |
デッドレターキュー(dead letter queue)
MAX_RETRIES を超えた失敗をログに流して終わり、にしないこと。デッドレターキューに突っ込んで、後で人間が見られるようにします:
async function handlePermanentFailure(video, error) {
await db.query(
`INSERT INTO dead_letter_queue (video_id, source_url, error, failed_at)
VALUES ($1, $2, $3, NOW())`,
[video.id, video.url, error]
);
}
進捗トラッキング
大規模バッチでは可視化が必須。最低限の進捗トラッカー:
class BatchProgress {
constructor(total) {
this.total = total;
this.completed = 0;
this.failed = 0;
this.startTime = Date.now();
}
update(status) {
if (status === "completed") this.completed++;
if (status === "failed") this.failed++;
const done = this.completed + this.failed;
const percent = ((done / this.total) * 100).toFixed(1);
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(0);
const rate = (done / (elapsed || 1)).toFixed(1);
const eta = ((this.total - done) / (rate || 1)).toFixed(0);
process.stdout.write(
`\r[${percent}%] ${done}/${this.total} | ` +
`OK: ${this.completed} FAIL: ${this.failed} | ` +
`${rate}/s | ETA: ${eta}s `
);
}
}
スケール時のコスト分析
1 万本(平均 5 分、1080p H.264 出力)を一度きりで変換する想定で、現実的なコスト比較:
| 方式 | 計算リソース | エンジニア工数 | 総時間 | 総コスト |
|---|---|---|---|---|
| シングルサーバー(16 コア) | 約 $200/月 | 2〜3 日のセットアップ | 約 7 日 | $400+ |
| Kubernetes クラスタ(40 コア) | 約 $500/月 | 1〜2 週のセットアップ | 約 2 日 | $1,500+ |
| AWS Elastic Transcoder | 約 $150 | 1〜2 日 | 約 4 時間 | $300+ |
| Cloud FFmpeg API | 約 $100 | 2〜3 時間 | 約 2 時間 | $150+ |
セルフホストは生の計算コストでは安く見えますが、エンジニア工数がコスト全体を支配します。バックエンドエンジニアの時給は $80〜150。2 日分のセットアップとデバッグだけで、1 万本バッチの API 利用料を軽く超えます。
定常ワークロードでは話が変わります。月 10 万本処理するならセルフホストクラスタの初期費用を償却できる。それ未満なら、トータルコストで API 方式がほぼ勝ちます。SaaS チーム向けの build-vs-buy 分析は SaaS の動画処理ガイド で詳しく扱っています。
最適化のヒント
1. ファイルサイズで並べ替える
小さいファイルから処理する。早めに成功が出るうえ、巨大ファイルにコミットする前に FFmpeg コマンドが正しいかフィードバックが得られます。
videos.sort((a, b) => a.fileSize - b.fileSize);
2. 並列度をチューニング
10〜20 並列から始め、API のレスポンスタイムを観察。レイテンシが悪化したりレートリミットがかかるなら下げる。安定しているなら少しずつ上げる。
3. 適切なプリセットを使う
ファイルサイズより速度が大事なバッチでは -preset fast か -preset veryfast。medium と fast の差はせいぜい 5〜10%、エンコード速度は倍になります。
4. 処理済みファイルはスキップ
処理済みログ(DB テーブルなど)を持ち、毎回ジョブ投入前にチェックして二重請求を防ぐ:
async function shouldProcess(video) {
const existing = await db.query(
"SELECT 1 FROM completed_jobs WHERE video_id = $1",
[video.id]
);
return existing.rows.length === 0;
}
まとめ
バッチトランスコードは、アーキテクチャを正しく選べば既に解決済みの問題です。多くのチームの判断軸は「ボリューム」と「頻度」:
- 1 万本以下のワンショット:クラウドトランスコード API。本記事のコードがそのまま動きます。
- 月 10 万本未満の定常運用:API + Webhook + DB によるトラッキング。
- 月 10 万本以上の定常運用:ハイブリッドを検討。ベース負荷はセルフホスト worker、ピークだけ API に流す。
ここで示した Node.js 実装は、よくあるケースは十分カバーします。ソースを差し替え、FFmpeg コマンドを調整し、並列度を決めて回せば動きます。
インフラそのものをスキップしたいなら FFHub.io がスケーリング、リトライ、FFmpeg のバージョン管理を引き受けます——コマンドを送って結果を受け取るだけ。
関連記事
- UGC プラットフォームの動画処理 - ユーザー投稿動画を大規模に扱うパイプライン全般
- SaaS の動画処理 - 各成長フェーズでの build vs buy のフレームワークとコスト分析
- FFmpeg 動画圧縮のベストプラクティス - バッチで品質とサイズのバランスを取るための FFmpeg コマンド最適化