← All posts

API で動画をバッチトランスコード:アーキテクチャと実装、スケーリングの話

1 万本の動画を落とさずに変換する。キュー設計、リトライ戦略、Node.js での実装まで、本番運用で踏んだ設計判断を全部書きます。

FFHub·2026-05-11
API で動画をバッチトランスコード:アーキテクチャと実装、スケーリングの話

動画 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 FunctionsCloud 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約 $1501〜2 日約 4 時間$300+
Cloud FFmpeg API約 $1002〜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 veryfastmediumfast の差はせいぜい 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 のバージョン管理を引き受けます——コマンドを送って結果を受け取るだけ。

関連記事

API で動画をバッチトランスコード:アーキテクチャと実装、スケーリングの話 | FFHub