みてね×gihyo.jpスペシャル

1秒動画のつくり「家族アルバム みてね」おける動画エンコードパイプラインとその最適化事例

株式会社MIXIで家族アルバム みてね⁠以下みてね)のData Engineeringグループ所属の松石と申します。

この連載では、これまでみてねのSREグループにおける取り組みを紹介してきました。今回からは同じくみてねにおけるData Engineeringグループの取り組みをご紹介します。

みてねのData Engineeringグループは、⁠みてねのプロダクトとしての魅力を高めるコンテンツや、ユーザの最適な選択・編集を補助するレコメンド機能」の開発をミッションとするグループです。具体的な機能としては、例えばみてねへアップロードされた写真・動画からダイジェスト動画を作って定期的にお届けする「1秒動画」や、フォトブックや写真プリント・DVDなどをご注文いただく際に、どの写真・動画を選択するか補助する各種自動提案、写真・動画をお子さま別に自動分類する「人物ごとのアルバム」などがあり、Data Engineeringグループでは、これら AI/ML を活かしたコンテンツ自動生成・自動提案・自動分類機能のバックエンドを設計・開発・運用しています。

今回の記事では、1秒動画をどのように生成・配信しているのかご紹介します。

1秒動画とは

1秒動画とは、ユーザがみてねへアップロードした写真・動画から良さそうなものをピックアップし、1秒ごとに切り出してダイジェスト動画として自動的に編集したうえで、定期的にお届けする機能です。

この1秒動画には、全家族へ3ヵ月ごとに配信する「四季版⁠⁠、みてねプレミアム加入家族へ毎月配信する「月版⁠⁠、同じくみてねプレミアム加入家族を対象に年に1回配信する「年間版」の3種類が存在し、それぞれ下表のとおり配信条件や素材、動画編集にも差異を設けています。四季版についてはサンプル動画もありますのでご覧ください。

項目 四季版 月版 年間版
動画の中身 直近3ヵ月の振り返り 先月の振り返り 去年1年間の振り返り
配信対象 全家族 みてねプレミアム加入家族 みてねプレミアム加入家族
配信頻度と大量配信の期間 3ヵ月に1回(1月・4月・7月・10月の各15日から1週間程度) 毎月1回(毎月10~12日) 年に1回(毎年1月2日頃から数日間)
含まれる素材数 動画のみ最大20点 動画・写真あわせて最大20点 動画・写真あわせて最大60点
動画編集 動画のみの基本的な編集 エフェクトを含むリッチな編集 1年間の振り返りに最適化した、よりリッチな編集

なお上記の「大量配信」とは、⁠1~3月分の四季版を4月15日から配信開始し、1週間で全家族に配信完了する」などのように、⁠新しい期間の1秒動画をはじめて配信してから、その時点で条件を満たす全家族への配信が完了するまで」の期間を指します。1秒動画の生成・配信の大部分はこの大量配信期間に行っていることから、これを「大量配信」と呼んでいます。

生成⁠配信の流れ

1秒動画の生成・配信は、図1のとおり(1)対象家族抽出、(2)素材選択、(3)動画エンコード、(4)配信、の4段階で実現しています。以下ではその詳細を説明します。

図1 1秒動画の生成・配信の流れ
図

(1)対象家族抽出

1秒動画の生成・配信処理は、基本的にはバッチ処理として毎日実行しています。そのはじめに行うのは、⁠その日、どの家族に、どのバージョン・どの期間の1秒動画を生成・配信するか」を取り出す対象家族抽出です。この処理は四季版と、月版・年間版とで設計が異なります。

四季版の対象家族抽出にはBigQueryを用いています。これは、みてねにご登録いただいている全家族が配信対象であり、必然的に生成・配信すべき1秒動画の件数も多くなるため、家族ごとの配信条件を満たすかの確認(素材数が十分にあるか、など)にかかるDB負荷を、アプリサーバ向けDBからBigQueryにオフロードし、かつ高速に実行するための工夫として導入した設計です。以前はアプリサーバ向けDBへ直接クエリを実行していましたが、BigQueryへの移行により、実行時間の大幅な削減(時間単位から分単位への高速化)を実現しています。

※対象家族抽出の詳細についてはこちら:BigQuery で1秒動画の配信対象家族を爆速で抽出する / How to create 1sec movie schedules with bigquery - Speaker Deck

これに対し月版・年間版では、家族ごとの生成・配信スケジュールをあらかじめDBへ保存しておき、対象家族抽出はこのテーブルを参照する設計としています。

これは、月版・年間版の「今月(または今年)みてねプレミアムに加入していると、来月(来年)に今月(今年)分の月版(年間版)1秒動画を受け取れる」という仕様を実現するための設計であり、みてねプレミアムへの加入または1ヵ月ごとの契約更新時に、⁠この家族に対し、この月(年)の月版(年間版)1秒動画を生成する」という生成・配信スケジュールをDBに保存しているためです。

なお月版・年間版において、実際に該当家族がみてねプレミアム加入以外の配信条件(素材数など)を満たすかの確認は、後述の(2)素材選択の際に行っています。

(2)素材選択

素材選択の処理では、(1)で抽出した各家族および1秒動画のバージョンと期間に対し、各家族がもつ動画・写真の中から、実際の1秒動画に含める素材の組み合わせを選択します。

この素材選択処理には、独自の素材選択AIを利用しています。これは、顔検出や人物検出などのMLモデルを使った動画・写真解析パイプラインによる事前の解析結果と、公開範囲や撮影日時・お気に入り・コメントといった動画・写真のメタデータとを組み合わせて利用するものです。また素材選択AIはルールベースの手法を用いていますが、その理由としてはプロダクトオーナーやデザイナーなどの意見を反映し、挙動を説明可能にしたい点や、より統計的な手法を用いる際に必要となる教師データを用意することの難しさが挙げられます。

※素材選択処理の詳細についてはこちら:いい感じの素材選択ロジック / How to select videos for 1sec Movie - Speaker Deck

(3)動画エンコード

素材選択の完了後には、動画・写真をエンコードし、1秒動画の動画ファイル本体を生成します。(2)素材選択までの処理は、みてねのアプリサーバにおいて実行していますが、この動画エンコードには専用のマイクロサービス⁠transcoder⁠を用意して実行しています。transcoderと動画エンコード処理の詳細については後述します。

(4)配信

transcoderで生成した1秒動画の動画ファイルは、iOS/Androidアプリへとプッシュ通知を送り、ユーザへお届けします。

transcoderにおける1秒動画生成の詳細

ここまで1秒動画の生成・配信処理の全体像をご説明しました。ここからは特にtranscoderにおける動画エンコード、1秒動画の動画ファイル生成について詳しく紹介します。

みてねのバックグラウンド処理においては、大規模かつ広範囲にSidekiqを利用しています。Sidekiqは、Rubyで実装された非同期のジョブキューシステムであり、データストアとしてRedisを利用します。

transcoderは、Sidekiq Proの一機能であるSidekiq Batchを利用し、FFmpegの処理群からなる1秒動画エンコードの処理をパイプラインとして定義・実行するものです。

以下では説明のため、1秒動画の生成処理を簡略化したイメージを用いてご説明します(実際の1秒動画の処理はより複雑です⁠⁠。

※transcoderについては過去の登壇資料も併せてご参照ください:Scalable Microservice for Media Transcoding / Transcoder - Speaker Deck

1秒動画の生成パイプライン

1秒動画ファイルの生成は、以下のようなFFmpegの処理FFmpeg filterの組み合わせとして実現しています:

  • 動画の切り出しtrim
  • 写真の動画化overlayなど)
  • エフェクトの付与zoompanなど)
  • ウォーターマークの付与overlay
  • クロスフェードでの動画連結fade

transcoderではこれらのFFmpeg filterの処理を、それぞれ1つのSidekiq Workerとして実装しています。また各Workerの依存関係や直列・並列の実行順序(図2)を、Sidekiq Batchによるパイプラインとして定義しています。

図2 FFmpeg filterの組み合わせによる1秒動画エンコードパイプライン
図

このパイプライン設計により、各Workerを並列実行することで生成処理を高速化(パイプライン全体のレイテンシを削減)でき、また処理の失敗時にはパイプライン全体をリトライせず、失敗した特定のWorkerやジョブだけをリトライできる、といったメリットがあります。

1秒動画生成パイプラインのSidekiq Batchによる実装

続いてSidekiq Batchによる具体的なパイプライン実装について、下記のコード例を用いて説明します。

OneSecondMoviePipelineがパイプライン全体の定義であり、ヘルパメソッドdefine_sidekiq_methodを用いてtrimeffectadd_watermarkcrossfadeの順に各ステップの処理をパイプラインに登録しています。

これに対し、各ステップの実装はTrimWorkerのように、通常のSidekiq Workerとして実装します。

このパイプライン全体としては、はじめにOneSecondMoviePipeline#performで実行するtrim処理からステップが始まり、各ステップに登録されたジョブがすべて完了するごとに、次のステップへと進む、といった具合に処理が行われます。

# パイプライン全体の実装

class OneSecondMoviePipeline
  include Sidekiq::Worker

  def perform
    status = nil
    options = {}
    trim(status, options) # パイプラインの先頭はtrimの処理
  end

  # パイプライン定義のためのhelper
  def self.define_sidekiq_method(method:, worker_class:, on_success_method:, on_success_options:)
    define_method(method) do |status, options|
      parameters = worker_class.create_parameters(options: options)

      # @see https://github.com/mperham/sidekiq/issues/3522
      parent_batch = ::Sidekiq::Batch.new(status.try(:parent_bid) || bid)
      parent_batch.jobs do
        options = on_success_options.call(parameters)
        step = ::Sidekiq::Batch.new
        step.callback_queue = 'one_second_movie_callback'
        step.on(:success, "#{self.class}##{on_success_method}", options)
        step.on(:complete, "#{self.class}#on_complete", options)
        step.description = "#{self.class}##{__method__}"

        # 各Workerのenqueueメソッドでperform_asyncすることで、このstepにおけるジョブを登録する
        step.jobs { worker_class.enqueue(parameters: parameters, queue: 'one_second_movie') }
      end
    end
  end

  # パイプラインの定義。trim, effect, add_watermark, crossfadeの順で処理する
  define_sidekiq_method(
    method: 'trim',
    worker_class: TrimWorker,
    on_success_method: 'effect',
    on_success_options: proc { |parameters| { outputs: parameters.map { |p| p.output.to_h } } },
  )
  define_sidekiq_method(
    method: 'effect',
    worker_class: EffectWorker,
    on_success_method: 'add_watermark',
    on_success_options: proc { |parameters| { outputs: parameters.map { |p| p.output.to_h } } },
  )
  define_sidekiq_method(
    method: 'add_watermark',
    worker_class: AddWatermarkWorker,
    on_success_method: 'crossfade',
    on_success_options: proc { |parameters| { outputs: parameters.map { |p| p.output.to_h } } },
  )
  define_sidekiq_method(
    method: 'crossfade',
    worker_class: CrossfadeWorker,
    on_success_method: 'on_success',
    on_success_options: proc { |_| {} },
  )

  def on_complete(status, options)
    # パイプライン完了後の処理として成果物として得られた動画ファイルの保存・返却を行う(省略)
  end
end
# 各ステップの実装

class TrimWorker
  include Sidekiq::Worker

  def perform(parameters)
    # FFmpegで実際にtrim処理を実行する(省略)
  end

  def self.create_parameters(options:)
    # trim処理に必要なパラメータを組み立てる(省略)
  end

  def self.enqueue(parameters:, queue:)
    # trim処理を行うSidekiqジョブを登録する
    parameters.each do |parameter|
      TrimWorker.set(queue: queue).perform_async(parameter.map(&:to_h))
    end
  end
end

# EffectWorker, AddWatermarkWorkerなども同様に実装する(省略)

transcoderのインフラ

上記Sidekiq Batchによるパイプラインが実際に動くtranscoderの動作環境としては、みてねではAmazon EKSを利用しており、実際の処理はAmazon EC2インスタンスが処理します。

この詳細は、当連載における過去の記事4年間のEKS移行の取り組みを振り返って | gihyo.jpでご紹介しています。

インフラとSidekiq Batchパイプラインの最適化事例

ここまででtranscoderの設計と実装を簡単にご紹介しました。ここからはtranscoderにおけるインフラとSidekiq Batchパイプラインの最適化事例についてご紹介します。

従来のtranscoderのインフラ⁠Sidekiq Batchパイプライン設定とその課題

従来のtranscoderでは、1秒動画生成処理に関するあらゆるWorkerを1つのキューone_second_movieキュー)にまとめ、1つのEKS Deploymentの中で区別なく、複数Podに並列化して実行する、という構成を取っていました。

# 各Workerにおけるqueueの指定例

class OneSecondMoviePipeline
  include Sidekiq::Worker
  sidekiq_options queue: 'one_second_movie' # 他のTrimWorker, EffectWorkerなども同様
end
# EKS環境の1秒動画生成用DeploymentにおけるSidekiqプロセスの実行例

# このSidekiqプロセスでは[one_second_movie_callback, one_second_movie]キューの優先順位でジョブを実行する
# one_second_movie_callbackキューでは、Sidekiq Batchによるコールバックの処理が実行され、これは最優先で実行する必要があるためキューを分けている
# see also: https://github.com/sidekiq/sidekiq/wiki/Advanced-Options
sidekiq -q one_second_movie_callback -q one_second_movie

これにより、各ステップにおける複数のジョブを並列実行することで、パイプライン全体としての高速化が可能である一方、以下の問題が発生していました。

  • ①一度(1日)の1秒動画生成バッチ処理全体が終わりに近づくまで、1家族分も最終的な成果物(1秒動画ファイル)が完成しない→これにより、障害などで生成バッチの処理が遅れた場合、バッチの締め切り時間(配信時間)に、大部分のジョブ(家族)の処理が間に合わなくなるリスクがある。

  • ②各ステップにはCPUやメモリの使用状況に差があり、インフラ設定(コンテナのCPU・メモリ割り当て量)の最適化が難しい→これにより、CPUやメモリのリソースを余裕を持って割り当てる必要が生じ、インフラコストが無駄になる。

これらの問題が発生する理由は以下のとおりです。

  • Sidekiq Batchは、1ステップのWorker(たとえばTrimWorkerのジョブがすべて完了した時点で、はじめて次のステップTrimWorkerに対するEffectWorkerのジョブをenqueueperform_asyncする、という仕様です。
  • ここで前述のように各ステップのWorkerを1つのキューにまとめると、すべてのジョブがenqueueされた順に実行されます。
  • この条件において、バッチ処理的に多数の1秒動画(多数の家族の1秒動画)をいちどに生成開始することを考えます。このときtranscoder全体の処理の流れは、⁠全家族に対するステップA → 全家族に対するステップB → …… → 全家族に対するステップN」といった具合に、⁠パイプライン定義のステップ順に従い、全家族の処理をまとめて、1つ目のステップから順に実行する」という挙動になります(上記問題の①)⁠下図の黒矢印⁠⁠。
  • また、各ステップのFFmpeg処理は、それぞれが必要とするCPUやメモリに大きな差があります。たとえば動画から特定の秒数を切り出すTrimWorkerでは、オリジナルの動画ファイル(最長10分)をダウンロードするためI/O待ちの時間が長く、相対的にCPU使用率やメモリ使用量が減ります。これに対し、素材となる動画・画像をすべてクロスフェードでつなぎ合わせるCrossfadeWorkerでは、多数の動画・画像ファイルを処理するために相対的に多くのメモリが必要となります。このような計算リソースへの要求量の差により、⁠transcoderの動くインフラ(EC2インスタンス)を、1秒動画生成パイプライン全体を通して、できるだけCPUもメモリも効率良く安定的に動かし続ける」ことが難しくなります(上記問題の②⁠⁠。
図3 1秒動画生成パイプラインの改善
図

改善策⁠パイプライン先頭のWorkerのみ優先順位を下げる

この問題への改善策として、1秒動画生成パイプラインのうち先頭にあるOneSecondMoviePipelineの優先度を下げ、⁠他のWorkerのジョブが無い時に限り、新たな1秒動画生成ジョブ(新たな家族に対するパイプライン)を開始する」という修正を行いました:

# 各Workerにおけるqueueの指定例

class OneSecondMoviePipeline
  include Sidekiq::Worker
  sidekiq_options queue: 'one_second_movie_low' # 他のTrimWorker, EffectWorkerなどはone_second_movieキューのまま
end
# EKS環境の1秒動画生成用DeploymentにおけるSidekiqプロセスの実行例

# このSidekiqプロセスでは[one_second_movie_callback, one_second_movie, one_second_movie_low]キューの優先順位でジョブを実行する
sidekiq -q one_second_movie_callback -q one_second_movie -q one_second_movie_low

これにより、transcoder全体の処理の流れは、⁠家族1の各ステップ → 家族2の各ステップ → …… → 家族Mの各ステップ」といった具合に、⁠1家族分の1秒動画生成パイプラインを開始すると、そのパイプラインの処理中は次の家族へ手を付けず、パイプライン完了後にはじめて次の家族のパイプラインを開始する」という挙動になります(図3の赤矢印⁠⁠。

より厳密には、上記の挙動をtranscoderにおける各Sidekiqプロセスが行うため、⁠transcoder全体として空きプロセスができるまで、既存のパイプライン(transcoder全体として処理中の家族)のジョブを実行し続け、空きプロセスが生まれた時点ではじめて次の家族のパイプラインを開始する」という挙動になります。

ここでSidekiqプロセスの動く(EKS管理下の)EC2インスタンスの負荷を考えると、前述のとおり修正前は「transcoder全体がパイプラインのどのステップを処理しているかにより、CPU・メモリ負荷の傾向が大きく変動する」という課題がありました。これに対し修正後は、transcoder全体としては、各時点でパイプラインのさまざまなステップを処理していることになり、CPU・メモリ負荷がより均一化され、安定的に計算リソースを活用し続けることが可能になります。

この改善策により、前述の2つの問題点はほぼ完全に解決され、transcoderからは成果物である1秒動画ファイルがコンスタントに出力され、かつ計算リソースを安定的かつ効率的に利用できるようになりました。

※transcoderにおける処理の効率化や負荷テストといった観点では、過去に以下のようなテーマでもご紹介しており、あわせてご参照ください:

FFmpegのfilter graphを用いて、複数のfilter処理を1つのFFmpegコマンドにまとめる方法をご紹介しています。

毎年1月2日ごろから大量配信を行っている年間版1秒動画に関し、生成負荷安定化のために行った施策と負荷テストについてご紹介しています。

まとめ

この記事では、みてねData Engineeringグループにおける取り組みの1つとして、1秒動画の生成・配信処理に関する概要と、とくにSidekiq Batchを使った動画エンコードパイプラインの詳細、およびその改善についてご紹介しました。

今回ご紹介したようなパイプラインあるいはワークフローの実装にSidekiq Batchを用いる場合、この記事で見たように、ある程度のboilerplate実装を要求される点には注意が必要です。

ただしみてねのように、すでにSidekiqを運用している場合などには、その他のSidekiq Pro機能、たとえばReliabilityなどとセットで利用でき、かつ個別のWorkerは通常のSidekiq Worker同様に実装できるため、選択肢の1つとして検討に値するかもしれません。この記事がどなたかのお役に立てれば幸いです。

おすすめ記事

記事・ニュース一覧