ワンクリックで複数のプログラムをスケジュール — AWS Lambdaの15分という壁を打ち破る
FASTチャンネルのオペレーターは1ヶ月分の番組編成をスケジュールし、Deployを一度クリックします。そのワンクリックの裏側では、何百ものプログラムが何千ものMediaLiveスケジュールアクションとなり、それらはすべてLambdaの15分という実行時間の上限内で、順番通りにAWSに送られる必要があります。途中で失敗すると、ライブチャンネルに穴が開いた状態になってしまいます。ここでは、大規模なプレイリストのワンクリックデプロイをいかに信頼性の高いものにしたか、そしてなぜ次のステップが「より大きなLambda」ではなく「異なるランタイム」であるのかを説明します。
概要
| 側面 | 詳細 |
|---|---|
| ランタイム | AWS Lambda (単一の呼び出し)、615秒のバックエンドタイムアウト |
| 出力ターゲット | MediaLive BatchUpdateScheduleCommand |
| バッチ処理 | リクエストあたり最大200個のMediaLiveアクション — 実際には約25プログラム |
| プログラムごとのアクション | 約7~8個(入力切り替え + レンダリングごとのウォーターマーク4個 + SCTE-35 2個)、CMブレイクがある場合はさらに多く |
| フォールバック | バッチ拒否時のプログラムごとの再試行 |
| 実測値 | 約44秒で195プログラムをデプロイ(ローカル測定) |
| 目標値 | 90秒以内に360プログラム |
| スケールアウトパス | 1ヶ月以上のプレイリストに対するStep Functionsチャンキング — 計画中、未リリース |
課題
プレイリストのデプロイは書き込みではなく、オーケストレーションです。オペレーターがスケジュールしたすべてのプログラムについて、MediaLiveは入力の切り替え時期、各レンダリングでウォーターマークをオンにする時期、SCTE-35キューポイントを挿入する時期、そしてCMを挿入する時期を正確に知る必要があります。構造的なプレッシャーは次のとおりです。
- アクション数は足し算ではなく掛け算です。 各プログラムは、CMブレイクの前に約7~8個のアクションを生成します — 入力切り替え1つ、ウォーターマークのアクティベーション4つ(StaticImageOutputActivateがOutput-Pixel座標を使用するため、レンダリングごとに1つ)、そしてSCTE-35マーカー2つです。CMブレイクごとにさらに3つのアクションが追加されます。360プログラムの1ヶ月は、ワイヤー上で約2,800アクションに相当します。
- MediaLiveはチャンネルごとの順序を強制します。 スケジュールアクションは時間軸で固定され、互いに参照し合っています。APIが競合として拒否するため、同じチャンネルへの書き込みを並行処理することはできません。
- AWS Lambdaには15分という厳格な上限があります。 これはソフトリミットでも、設定可能な項目でもありません。オーケストレーターはその時間内に完了しなければならず、さもなければチャンネルは半端にデプロイされた状態になります。
- 部分的な失敗は運用上容認できません。 360プログラム中174番目のプログラムが失敗して実行が中断された場合、オペレーターは何がデプロイされ、何がされなかったのかを差分として確認できません。チャンネルは隙間だらけの状態で公開され、視聴者はコンテンツを期待していた場所にスレートを見ることになります。
- 最初のバージョンでは、プログラムごとに1つのBatchUpdateScheduleを送信し、プログラム間に200msのスリープを挟んでいました。 これはプログラムあたり約2.5秒のウォールタイムです。360プログラムでは、MediaLiveが実作業を始める前にすでにLambdaの上限を超えてしまいます。
したがって、仕事は「より速く書き込む」ことではありません。MediaLiveが要求する順序保証を失うことなく、「書き込み回数を減らし」、「部分的な失敗から回復し」、そして「1回の呼び出し内で完了する」ことです。
既存のアプローチが失敗する理由
明らかな回避策はすべて、チューニングの問題ではなく、構造的な理由で失敗します。
- 「Lambdaのタイムアウトを増やせばいい。」それはできません。15分はAWSがLambdaの実行に課している厳格な上限であり、コンソールで設定できる項目ではありません。たとえできたとしても、カタログが増えるにつれてプログラムごとのコストも増大します — より多くのウォールタイムを購入しても、次の限界に到達するのを遅らせるだけです。
- 「ECS FargateやEC2に移行する。」これを検討しましたが、却下しました。長時間稼働するコンテナは、私たちがランタイムを所有することを意味します。ヘルスチェック、オートスケーリング、コールドスタートとウォームプールのトレードオフ、IAMスコープ設定、そしてバースト的に実行されるサービスのオンコール輪番などです。Lambdaは、本質的にバースト性の高いワークロードに対して、呼び出しごとの分離とアイドル状態でのゼロコストを提供します。単一のボトルネックを解決するためにそれらを諦める準備はできていませんでした。
- 「MediaLiveの書き込みを並列化する。」MediaLiveはチャンネルごとにスケジュール更新をシリアル化します。同じチャンネルに対する並行したBatchUpdateSchedule呼び出しは、アクションタイムライン上で競合し、拒否されます。唯一正当な並列処理は、バッチ内であり、バッチ間ではありません。
- 「クラッシュさせて、オペレーターに再試行させる。」これは最悪の選択肢です。プログラムNでデプロイが中断された場合、チャンネルはUIからでは誰も説明できない状態になります。オペレーターは差分を確認できず、ブラックボックスと穴の開いたライブチャンネルを受け取ることになります。システムは、すべてをデプロイするか、オペレーターが対応できるプログラムごとのステータス付きで部分的な結果をデプロイするかのどちらかでなければなりません。
私たちに残された唯一のレバーは、作業自体の形でした。すなわち、より少なく、より大きな書き込みを逐次実行し、バッチが拒否された場合にのみ行レベルの粒度にフォールバックする仕組みです。
私たちのソリューション
ループが始まる前にLambdaからコストを排除し、ループ内でMediaLiveの書き込みを結合し、バッチが失敗した場合にのみプログラムごとの送信にフォールバックします。この3つのアイデアをこの順序で実行しました。そして、現在実際にデプロイしているカタログサイズであれば15分の上限で問題ないと意図的に判断しました。プレイリストが1回の呼び出しで処理しきれなくなった場合、答えは「より大きなLambda」ではなく、「異なるランタイム」です。

アーキテクチャ
- NestJSバックエンド (schedule.service.ts) — デプロイを事前準備します。すべてのユニークなビデオに対して1回のMongoラウンドトリップ、すべてのアドマーカーに対して1回のラウンドトリップを行い、その後、ユニークなビデオURLに対してffprobeを並行して実行し、その結果をエンリッチメントループのためにキャッシュします。
- Lambdaオーケストレーター (fastChannel-lambda-fun/index.js) — バッチ処理ループ、バッチごとのスロットル、プログラムごとのフォールバック、および孤立したアクションのスイープを担当します。
- MediaLive BatchUpdateScheduleCommand — 唯一の書き込みインターフェースです。入力切り替え、ウォーターマーク、SCTE-35、CM挿入など、すべてのアクションがこれを通して流れます。
- MongoDB — プログラム、ビデオ、アドマーカーの真のソースです。インナーループ内では決してクエリされません。
- scheduleResults マップ — オペレーターがスタックトレースではなく差分を受け取れるように、バックエンドに返されるプログラムごとのscheduled / errorステータス。
主要なエンジニアリング決定事項
1. ループが始まる前に遅い処理を事前準備する。元のコードでは、エンリッチメントループ内でスケジュールごとのMongoルックアップとスケジュールごとのffprobe呼び出しを行っていました — 典型的なN+1問題を2回支払っていました。現在のバックエンドは、すべてのユニークなビデオとアドマーカーをそれぞれ1回のラウンドトリップで一括取得し、その後、ユニークなURLに対してffprobeを並行して実行し、その結果を解決済みキャッシュに格納します。インナーループはキャッシュヒットになります。ffprobeのレイテンシは、ループ内で逐次ではなく、ユニークなURLごとに1回だけ支払われます。
2. MediaLiveの書き込みを約25個のバッチに結合する。オーケストレーター内では、200アクションがキューに登録されるか、最後のプログラムに達するまで、アクションが単一のBatchUpdateScheduleCommandに蓄積されます。各プログラムは約7~8個のアクションを生成するため、バッチは自然に約25プログラムごとに収まります。200アクションの上限は、MediaLiveのリクエストごとのペイロード制限を十分に下回り、サイズによるバッチ拒否の可能性を減らすために慎重に選択されました — ネットワークコストを償却するのに十分な大きさであり、プログラムごとのフォールバック(次の決定事項)が必要な場合に安価にするのに十分な小ささです。1回のネットワークラウンドトリップが25回分を置き換えます。以前はすべてのプログラムの間にあった200msのスロットルが、今ではすべてのバッチの間にあります。
3. 速度のためのバッチ処理、正確性のためのフォールバック。バッチ処理は、1つの不良プログラムが同じバッチ内の他の24個を汚染しない場合にのみ安全です。submitProgramBatchが例外をスローすると、catchブロックはretryBatchAsIndividualsを呼び出します。これは、失敗したバッチ内の各プログラムを独自のBatchUpdateScheduleCommandとして再送信し、プログラムごとにstatus: 'scheduled'またはstatus: 'error'を記録し、試行間に200msスリープし、部分的な成功ごとにアクションタイムラインを再固定します。高速パスはバッチ処理されます。リカバリパスは粒度を細かくします。どちらの方法でも、オペレーターはプログラムごとの差分を受け取ります。
4. 孤立したアクションは途中で防止せず、最後にまとめて処理する。sweepIncompleteProgramGroupsはデプロイの最後に一度実行され、クリーンな終端状態に到達しなかったすべてのアクショングループを削除します。ループ中にチャンネルの内部整合性を維持しようとは意図的にしませんでした — それは、15分の予算内に収まるロールバックパスが必要になることを意味するからです。クリーンアップは単一のスイープであり、トランザクションではありません。
5. Lambdaを大きくするのではなく、カタログが大きくなったら置き換える。今日デプロイしているすべてのものについて、単一の呼び出しパスは予算内に十分に収まっています。真のスケールアウトはStep Functionsチャンキングです — プレイリストを分割し、チャンクを並列ステートマシンとして実行し、再結合します。これは1ヶ月以上および6ヶ月のプレイリストに対するパスです。これは設計済みであり、デプロイされていません。すでに稼働していると偽るよりも、それを次のステップと呼ぶ方が有用です。
結果
- 内部テストでは、195プログラムのデプロイが約44秒で完了しました — 数分かかっていた、プログラムを1つずつ処理するベースラインから短縮されました。
- 360プログラム(約1ヶ月)のプレイリストの目標値は90秒以下であり、615秒のバックエンドタイムアウトと15分のLambdaの上限内に余裕を持って収まっています。
- バッチ内の1つの不良プログラムが他の24個の処理を中断させることはなくなりました。オペレーターは、すべてのデプロイからプログラムごとのステータスマップを受け取ります。
- リクエストの遅い部分 — Mongoルックアップとffprobe — は、スケジュールエントリごとにではなく、ユニークなリソースごとに1回だけ支払われます。
- 15分の上限は、実際に配信しているカタログサイズにとって制約要因ではなくなりました。再び制約要因となった場合、答えはより大きなLambdaではなく、Step Functionsチャンキングです。
テクノロジースタック: AWS Lambda · AWS MediaLive · NestJS · MongoDB · Node 18 · ffprobe · TypeScript · AWS SDK v3

