0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravel + FFmpegでHLS動画変換を非同期Queue処理する設計

0
Posted at

Webアプリケーションでユーザーから動画を受け取り、HLS形式へ変換する場合、FFmpegをHTTPリクエスト内で直接実行するのは避けたいところです。

例えば、次のような処理です。

Upload
  ↓
FFmpeg
  ↓
HLS生成
  ↓
Response

数秒で終わる処理なら問題にならないかもしれません。

しかし、1GBを超える動画や長時間動画では、FFmpeg処理に数分以上かかる可能性があります。

そこで今回はLaravel Queueを使って、

Upload
  ↓
DBへJob登録
  ↓
Queueへ投入
  ↓
すぐResponse

--- Background ---

Queue Worker
  ↓
FFmpeg
  ↓
HLS生成
  ↓
ready

という構成を考えてみます。

今回作る構成

全体像は次のようになります。

Client
  |
  | Upload
  v
Laravel Application
  |
  +---- Save Original
  |
  +---- Create DB Record
  |
  +---- Dispatch Job
              |
              v
          Queue Worker
              |
              v
            FFmpeg
              |
              v
          HLS Output
              |
              v
        Update Status

ポイントは、動画変換をWebリクエストから分離することです。

動画Jobの状態を管理する

まず、動画変換の状態をDBで管理します。

例えば以下のような状態を用意します。

pending
processing
ready
failed

migrationの簡単な例です。

Schema::create('video_jobs', function (Blueprint $table) {
    $table->id();

    $table->string('source_path');
    $table->string('output_path')->nullable();

    $table->string('status')
        ->default('pending');

    $table->unsignedTinyInteger('progress')
        ->default(0);

    $table->unsignedInteger('attempts')
        ->default(0);

    $table->text('error_message')
        ->nullable();

    $table->timestamps();
});

これで、

どの動画を処理しているか
現在の状態
進捗
失敗理由

を確認できます。

Upload Controller

次に動画を受け取ります。

簡略化すると次のようになります。

public function store(Request $request)
{
    $request->validate([
        'video' => [
            'required',
            'file',
            'mimetypes:video/mp4,video/quicktime'
        ],
    ]);

    $path = $request
        ->file('video')
        ->store('videos/originals');

    $videoJob = VideoJob::create([
        'source_path' => $path,
        'status' => 'pending',
    ]);

    ProcessVideo::dispatch($videoJob->id);

    return response()->json([
        'id' => $videoJob->id,
        'status' => 'pending',
    ], 202);
}

ここでは動画変換を実行していません。

重要なのは、

ProcessVideo::dispatch($videoJob->id);

だけ実行してHTTP Responseを返すことです。

Queue Jobを作る

LaravelでJobを作ります。

php artisan make:job ProcessVideo

Jobでは動画IDだけ受け取るようにします。

class ProcessVideo implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(
        public int $videoJobId
    ) {
    }

    public function handle(): void
    {
        $video = VideoJob::findOrFail(
            $this->videoJobId
        );

        // Video processing
    }
}

ModelそのものではなくIDを渡して、Worker側で最新レコードを取得する構成にしています。

processingへ変更する

FFmpeg開始前に状態を更新します。

$video->update([
    'status' => 'processing',
    'attempts' => $video->attempts + 1,
    'error_message' => null,
]);

これで管理画面などから、

pending → processing

への変化を確認できます。

FFmpegでHLSへ変換する

非常に単純なFFmpegコマンドなら次のようになります。

ffmpeg \
  -i input.mp4 \
  -c:v libx264 \
  -c:a aac \
  -hls_time 6 \
  -hls_playlist_type vod \
  output.m3u8

出力は例えば、

video-101/
├── output.m3u8
├── output0.ts
├── output1.ts
├── output2.ts
└── ...

のようになります。

ただし、LaravelからShell commandを作る場合、ユーザー入力をそのまま文字列連結するのは避けるべきです。

Symfony Processを利用する

LaravelプロジェクトではSymfony Processを利用できます。

use Symfony\Component\Process\Process;

例えば、

$process = new Process([
    'ffmpeg',
    '-y',
    '-i',
    $inputPath,
    '-c:v',
    'libx264',
    '-c:a',
    'aac',
    '-hls_time',
    '6',
    '-hls_playlist_type',
    'vod',
    $outputPath,
]);

$process->setTimeout(3600);

$process->run();

配列形式で引数を渡すことで、単純なShell文字列連結より安全に扱いやすくなります。

FFmpegの成功を確認する

処理終了後は必ずexit statusを確認します。

if (! $process->isSuccessful()) {
    throw new RuntimeException(
        $process->getErrorOutput()
    );
}

成功したら、

$video->update([
    'status' => 'ready',
    'progress' => 100,
    'output_path' => $relativeOutputPath,
]);

とします。

状態は、

pending
   ↓
processing
   ↓
ready

になります。

エラー時はfailedにする

動画変換は必ず成功するとは限りません。

例えば、

  • 壊れた動画
  • unsupported codec
  • disk不足
  • FFmpeg crash
  • timeout
  • memory不足

などがあります。

そのため例外処理が必要です。

try {

    $process->run();

    if (! $process->isSuccessful()) {
        throw new RuntimeException(
            $process->getErrorOutput()
        );
    }

    $video->update([
        'status' => 'ready',
        'progress' => 100,
    ]);

} catch (Throwable $e) {

    $video->update([
        'status' => 'failed',
        'error_message' => mb_substr(
            $e->getMessage(),
            0,
            5000
        ),
    ]);

    throw $e;
}

ログも残しておくとデバッグしやすくなります。

Laravel QueueのRetry

動画変換では一時的な失敗も考えられます。

Job側で、

public int $tries = 3;

を設定できます。

さらにbackoffも設定できます。

public function backoff(): array
{
    return [
        60,
        300,
        900,
    ];
}

つまり、

1回目失敗
   ↓
60秒

2回目失敗
   ↓
300秒

3回目

のようなRetry戦略を作れます。

ただし、壊れた動画のような永久エラーを何度も再処理しても意味がありません。

エラー種類によってRetry可能か判断する仕組みがあるとより良いです。

Queue Workerを分離する

WebサーバーとFFmpeg Workerを同じプロセスとして考えないことも重要です。

例えば、

Server
├── nginx
├── PHP-FPM
└── Queue Worker
       └── FFmpeg

でも動作します。

しかし動画処理が増えてきたら、

Web Server
├── nginx
└── PHP-FPM

Worker Server
├── Queue Worker 1
├── Queue Worker 2
└── FFmpeg

のように分離できます。

これによりFFmpegがCPUを大量消費してもWebリクエストへの影響を減らしやすくなります。

Worker数を増やしすぎない

ここは非常に重要です。

CPUが8 coreだからといって、

8 workers
=
8 FFmpeg processes

が最適とは限りません。

FFmpeg自体が複数CPU threadを利用する可能性があります。

例えば、

Worker 1 → FFmpeg → CPU
Worker 2 → FFmpeg → CPU
Worker 3 → FFmpeg → CPU
Worker 4 → FFmpeg → CPU

と同時実行するとCPU使用率が急上昇します。

最初は少ないconcurrencyから始め、CPU、load average、変換時間を計測して調整するほうが安全です。

FFprobeで動画を事前チェックする

FFmpegを実行する前にFFprobeで動画情報を取得することもできます。

ffprobe \
  -v quiet \
  -print_format json \
  -show_format \
  -show_streams \
  input.mp4

JSON形式で、

{
  "streams": [],
  "format": {}
}

が返ります。

ここから、

duration
codec
width
height
bitrate
audio

などを確認できます。

解像度によって処理を変える

例えば入力動画が720pしかない場合、

1080p
720p
480p

を作る必要はありません。

720p動画を1080pへupscaleしても、元の画質以上の情報が増えるわけではありません。

例えば、

Input 2160p
→ 1080p
→ 720p
→ 480p

Input 1080p
→ 1080p
→ 720p
→ 480p

Input 720p
→ 720p
→ 480p

というルールを作れます。

Adaptive Bitrate HLS

本格的な動画配信では複数画質を作ることがあります。

構成例:

video-101/
├── master.m3u8
│
├── 1080p/
│   ├── index.m3u8
│   └── segments...
│
├── 720p/
│   ├── index.m3u8
│   └── segments...
│
└── 480p/
    ├── index.m3u8
    └── segments...

master.m3u8から各画質を参照します。

ネットワーク状態に応じてPlayerが適切なbitrateを選択できるようになります。

元動画をどうするか

HLS生成後にoriginal MP4を残すかどうかも設計上のポイントです。

残す場合:

Original
+
HLS

メリットは、将来別設定で再encodeできることです。

デメリットはstorage使用量が増えることです。

削除する場合:

Original
   ↓
Encode
   ↓
HLS
   ↓
Delete Original

storageは節約できますが、再変換用の高品質sourceがなくなります。

サービス要件に合わせて決める必要があります。

一時ファイルを必ずCleanupする

動画処理ではtemporary filesが大量に発生します。

例えば、

/tmp/video-101/

を作った場合、成功・失敗に関係なく削除できるようにします。

PHPならfinallyを利用できます。

try {

    // encode

} catch (Throwable $e) {

    throw $e;

} finally {

    $this->cleanupTemporaryFiles();
}

失敗したJobがtemporary diskを埋め尽くす問題を防ぎやすくなります。

Monitoring

本番環境では最低限以下を監視したいところです。

queue_wait_time
encoding_time
failed_jobs
CPU usage
memory usage
disk usage
output_size

例えばQueue待ち時間が、

30 sec
↓
5 min
↓
30 min

と増えているなら、Worker capacityが不足している可能性があります。

一方、CPUが常に100%なら、単純にWorkerを追加するのは逆効果かもしれません。

最終的な構成

ここまでをまとめると、

                 Client
                    |
                    v
             Laravel Upload
                    |
          +---------+---------+
          |                   |
          v                   v
     Original File        Database
                              |
                              v
                         Queue Job
                              |
                              v
                       Worker Server
                              |
                              v
                           FFprobe
                              |
                              v
                            FFmpeg
                              |
                              v
                         HLS Output
                              |
                              v
                         ready status

という構成になります。

まとめ

Laravelで動画変換を実装するときに重要なのは、FFmpegのコマンドそのものだけではありません。

むしろ、

  • HTTP Requestと動画変換を分離する
  • Queueで非同期処理する
  • Job statusをDBで管理する
  • Retryを設計する
  • FFmpeg失敗を検知する
  • Worker concurrencyを制御する
  • temporary filesをcleanupする
  • CPUやQueueをmonitoringする

といった周辺設計のほうが、本番運用では重要になります。

最初は、

Upload
↓
Queue
↓
FFmpeg
↓
HLS

というシンプルな構成から始め、動画数やトラフィックが増えた段階でWorker分離やAdaptive Bitrate Streamingを追加していくのが扱いやすいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?