Remotion Lambda × GitHub Actions — 動画自動生成のCI/CDパイプライン構築
Remotion Lambda × GitHub Actions — 動画自動生成のCI/CDパイプライン構築
Remotion LambdaをCI/CDなしで本番運用するのは、バックアップなしでデータベースを動かすようなものです。コード変更でレンダリングパイプラインが壊れたとき、手作業での復旧は想像以上に時間がかかります。大量の失敗ジョブ、配信遅延、深夜のデバッグ——これを防ぐのがCI/CDパイプラインです。
適切に設計されたパイプラインでは、コードがmainブランチにマージされると自動的にLambda関数とサイトバンドルがデプロイされ、StripeのWebhookやフォーム送信といる外部イベントに応じてレンダリングが開始され、失敗したジョブは自動的にリトライされます。
本記事ではその全体像を構築します。GitHub Actionsを使ったデプロイワークフロー・CI環境でのAWS認証情報の管理・renderMediaOnLambda と getRenderProgress の使い方・Webhook連動レンダリングパターン・framesPerLambda によるコスト最適化・本番ジョブの監視まで、実際のコードとともに解説します。
アーキテクチャの全体像
実装に入る前に、システム全体の構造を把握します。
┌────────────────────────────────────────────────────────────────┐
│ GitHubリポジトリ │
│ ├── src/ ← Remotionコンポジション │
│ ├── .github/workflows/ ← CI/CDパイプライン │
│ └── scripts/ ← デプロイ・レンダースクリプト │
└────────────────────────────────────────────────────────────────┘
│ mainへのpush
▼
┌────────────────────────────────────────────────────────────────┐
│ GitHub Actions │
│ 1. deploy.yml → Lambda関数 + サイトバンドルをデプロイ │
│ 2. render.yml → スケジュール・手動トリガーでレンダー │
└────────────────────────────────────────────────────────────────┘
│ deployFunction() + deploySite()
▼
┌────────────────────────────────────────────────────────────────┐
│ AWS │
│ ├── Lambda関数 ← Remotionレンダーワーカー │
│ ├── S3バケット ← サイトバンドル + 出力ファイル │
│ └── CloudWatch ← ログと監視 │
└────────────────────────────────────────────────────────────────┘
│ renderMediaOnLambda()
▼
┌────────────────────────────────────────────────────────────────┐
│ 外部トリガー │
│ ├── Stripe Webhook → 購入完了 → パーソナライズ動画 │
│ ├── Cronスケジュール → 夜間バッチレンダー │
│ └── REST API → オンデマンドレンダーエンドポイント │
└────────────────────────────────────────────────────────────────┘
ステップ1:プロジェクト構造
CI/CDに対応しやすい構成を最初から用意します。
remotion-project/
├── src/
│ ├── index.ts # Remotionルート(コンポジション登録)
│ ├── compositions/
│ │ ├── WelcomeVideo.tsx
│ │ └── ReportVideo.tsx
│ └── components/ # 共通Reactコンポーネント
├── scripts/
│ ├── deploy.ts # Lambda関数 + サイトバンドルのデプロイ
│ └── render.ts # プログラマティックレンダースクリプト
├── .github/
│ └── workflows/
│ ├── deploy.yml # mainへのpush時に実行
│ └── render.yml # スケジュール・手動ディスパッチ
├── package.json
└── tsconfig.json
ステップ2:GitHub ActionsへのAWS認証情報の設定
AWS認証情報はGitHub Actionsのシークレットとして管理します。リポジトリの Settings → Secrets and variables → Actions → New repository secret から以下を登録します。
| シークレット名 | 値 |
|---|---|
AWS_ACCESS_KEY_ID | IAMユーザーのアクセスキーID |
AWS_SECRET_ACCESS_KEY | IAMユーザーのシークレットアクセスキー |
AWS_REGION | 例:ap-northeast-1 |
REMOTION_FUNCTION_NAME | デプロイ済みLambda関数名 |
REMOTION_SERVE_URL | deploySite() が返したS3サーブURL |
IAMユーザーには npx remotion lambda policies user が生成するポリシーを付与します。認証情報をワークフローファイルに直接書くことは絶対に避けてください。シークレットはログ上でマスクされますが、ファイルにハードコードするとリポジトリごと流出するリスクがあります。
長期間有効なアクセスキーの代わりに、GitHub ActionsのOIDCフェデレーションを使ってIAMロールを一時引き受けする方法もあります。静的な認証情報を管理せずに済むため、セキュリティ上より優れた選択肢です。どちらの方式もRemotion Lambdaのセットアップとは互換性があります。
ステップ3:デプロイワークフロー(コード変更時に自動実行)
src/ 以下のファイルが main ブランチに変更されるたびに、Lambda関数とサイトバンドルを自動デプロイします。
# .github/workflows/deploy.yml
name: Deploy Remotion Lambda
on:
push:
branches:
- main
paths:
- "src/**"
- "package.json"
- "package-lock.json"
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node.jsのセットアップ
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: 依存パッケージのインストール
run: npm ci
- name: AWS認証情報の設定
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Lambda関数のデプロイ
run: npx remotion lambda functions deploy --yes
- name: サイトバンドルのデプロイ
run: |
npx remotion lambda sites create src/index.ts \
--site-name=my-video \
--yes \
| tee /tmp/deploy-output.txt
- name: サーブURLの抽出と保存
run: |
SERVE_URL=$(grep -oP 'https://[^\s]+index\.html' /tmp/deploy-output.txt | head -1)
echo "Serve URL: $SERVE_URL"
# 本番ではSSM Parameter Storeに書き込む
# aws ssm put-parameter --name /remotion/serve-url --value "$SERVE_URL" --overwrite
なぜコード変更のたびにLambda関数をデプロイするのか
RemotionのLambda関数名にはRemotionのバージョンが含まれます(例:remotion-render-4-0-272-mem2048mb-disk2048mb-120sec)。Remotionをアップグレードすると関数名が変わり、古い関数名でのレンダーは失敗します。functions deploy は冪等です——同一パラメータの関数がすでに存在する場合は素早く完了するため、毎回実行しても無駄なコストは発生しません。
--yes フラグはインタラクティブな確認プロンプトをスキップします。CI環境では確認待ちでジョブがハングするのを防ぐために必須です。
ステップ4:プログラマティックデプロイスクリプト
CLIではなくNode.js APIを使うと、サーブURLをSSMに書き込んだりSlack通知を送ったりといった処理を柔軟に追加できます。
// scripts/deploy.ts
import { deployFunction, deploySite, getOrCreateBucket } from "@remotion/lambda";
import path from "path";
const REGION = process.env.AWS_REGION ?? "ap-northeast-1";
const SITE_NAME = "my-video";
async function deploy(): Promise<void> {
console.log("Lambda関数をデプロイ中...");
const { functionName } = await deployFunction({
region: REGION,
timeoutInSeconds: 120,
memorySizeInMb: 2048,
createCloudWatchLogGroup: true,
});
console.log("デプロイ完了:", functionName);
console.log("S3バケットを確認・作成中...");
const { bucketName } = await getOrCreateBucket({ region: REGION });
console.log("サイトバンドルをデプロイ中...");
const { serveUrl } = await deploySite({
bucketName,
entryPoint: path.resolve("./src/index.ts"),
region: REGION,
siteName: SITE_NAME,
});
console.log("サイトデプロイ完了:", serveUrl);
// サーブURLを永続化(例:SSM Parameter Store)
// await ssmClient.send(new PutParameterCommand({
// Name: "/remotion/serve-url",
// Value: serveUrl,
// Overwrite: true,
// Type: "String",
// }));
}
deploy().catch((err) => {
console.error(err);
process.exit(1);
});
ステップ5:renderMediaOnLambda APIの使い方
インフラが整ったら、renderMediaOnLambda を呼んでレンダリングを開始します。この関数は非同期かつノンブロッキングで、即座に renderId を返します。
import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda";
interface RenderParams {
compositionId: string;
inputProps: Record<string, unknown>;
outName?: string;
}
async function triggerRender(params: RenderParams): Promise<string> {
const { renderId, bucketName } = await renderMediaOnLambda({
region: process.env.AWS_REGION!,
functionName: process.env.REMOTION_FUNCTION_NAME!,
serveUrl: process.env.REMOTION_SERVE_URL!,
compositionId: params.compositionId,
inputProps: params.inputProps,
codec: "h264",
imageFormat: "jpeg",
maxRetries: 1,
privacy: "public",
outName: params.outName ?? `render-${Date.now()}.mp4`,
framesPerLambda: 20, // 後述のコスト最適化セクション参照
});
console.log(`レンダー開始 — renderId: ${renderId}, bucket: ${bucketName}`);
return renderId;
}
主要なパラメータを整理します。
| パラメータ | 型 | 説明 |
|---|---|---|
region | string | Lambda関数と同じAWSリージョン |
functionName | string | deployFunction() が返した関数名(完全一致) |
serveUrl | string | deploySite() が返したS3サーブURL |
compositionId | string | ルートファイルに登録したコンポジションの id |
inputProps | object | コンポジションに渡すシリアライズ可能なデータ |
codec | string | "h264" / "h265" / "vp8" / "vp9" / "gif" / "prores" |
imageFormat | string | "jpeg"(高速)または "png"(透過サポート) |
maxRetries | number | チャンクが失敗した際のリトライ回数 |
privacy | string | "public"(公開URL)または "private"(S3キーのみ) |
framesPerLambda | number | 並列度の制御(後述) |
ステップ6:getRenderProgressで進捗を確認する
renderMediaOnLambda はレンダーを開始するだけです。完了を確認するには getRenderProgress をポーリングします。
import { getRenderProgress } from "@remotion/lambda";
async function waitForRender(
renderId: string,
bucketName: string,
functionName: string,
region: string,
pollIntervalMs = 2000
): Promise<string> {
while (true) {
const progress = await getRenderProgress({
renderId,
bucketName,
functionName,
region,
});
if (progress.done) {
console.log("レンダー完了:", progress.outputFile);
console.log("ファイルサイズ:", progress.outputSizeInBytes, "bytes");
return progress.outputFile!;
}
if (progress.fatalErrorEncountered) {
const errors = progress.errors.map((e) => e.message).join("; ");
throw new Error(`レンダー失敗: ${errors}`);
}
const pct = Math.round(progress.overallProgress * 100);
console.log(`進捗: ${pct}%`);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
getRenderProgress のレスポンスに含まれる主なフィールドです。
| フィールド | 型 | 説明 |
|---|---|---|
done | boolean | true でレンダー完了 |
overallProgress | number | 0〜1の全体進捗 |
fatalErrorEncountered | boolean | true でレンダー失敗 |
errors | array | 失敗時のエラーオブジェクト |
outputFile | string | null | 完了時の出力ファイルURL |
outputSizeInBytes | number | null | 完了時のファイルサイズ |
renderedFrames | number | レンダー済みフレーム数 |
encodedFrames | number | エンコード済みフレーム数 |
costs | object | このレンダーの推定AWSコスト内訳 |
ステップ7:Webhookトリガーのレンダリング — Stripe購入の例
本番でもっとも一般的なパターンは、ビジネスイベントに応じてパーソナライズ動画を生成することです。Stripe購入完了をトリガーに、顧客向けのウェルカム動画を自動生成してメールで届ける例を示します。
import express from "express";
import Stripe from "stripe";
import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Stripeの署名検証にはrawボディが必要
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook署名検証失敗:", err);
return res.status(400).send("Webhook Error");
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const customerName = session.customer_details?.name ?? "お客様";
const customerEmail = session.customer_details?.email ?? "";
const productName = session.metadata?.product_name ?? "ご購入商品";
// レンダーは非同期で実行 — Webhookレスポンスをブロックしない
renderPersonalizedVideo({
customerName,
customerEmail,
productName,
sessionId: session.id,
}).catch(console.error);
}
// Stripeは数秒以内に200レスポンスを期待する
res.json({ received: true });
});
async function renderPersonalizedVideo(data: {
customerName: string;
customerEmail: string;
productName: string;
sessionId: string;
}): Promise<void> {
const { renderId, bucketName } = await renderMediaOnLambda({
region: process.env.AWS_REGION!,
functionName: process.env.REMOTION_FUNCTION_NAME!,
serveUrl: process.env.REMOTION_SERVE_URL!,
compositionId: "WelcomeVideo",
inputProps: {
customerName: data.customerName,
productName: data.productName,
accentColor: "#10b981",
},
codec: "h264",
imageFormat: "jpeg",
maxRetries: 2,
privacy: "public",
outName: `welcome-${data.sessionId}.mp4`,
framesPerLambda: 20,
});
// レンダー完了まで待機
const outputUrl = await waitForRender(
renderId,
bucketName,
process.env.REMOTION_FUNCTION_NAME!,
process.env.AWS_REGION!
);
// 動画URLをメールで送信
await sendWelcomeEmail({
to: data.customerEmail,
videoUrl: outputUrl,
customerName: data.customerName,
});
console.log(`ウェルカム動画を送信: ${data.customerEmail} → ${outputUrl}`);
}
Webhookレスポンスは必ずレンダー開始前に返してください。Stripeはレスポンスを数秒以内に期待します。レンダーはその後非同期に進行します。
ステップ8:GitHub Actionsのレンダーワークフロー
スケジュールバッチや手動ディスパッチでレンダーをトリガーするワークフロー例です。
# .github/workflows/render.yml
name: 動画レンダー(スケジュール・手動)
on:
workflow_dispatch:
inputs:
composition_id:
description: "コンポジションID"
required: true
default: "WelcomeVideo"
customer_name:
description: "顧客名"
required: false
schedule:
# 毎朝3時(JST)= UTC 18:00前日
- cron: "0 18 * * *"
jobs:
render:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node.jsのセットアップ
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: 依存パッケージのインストール
run: npm ci
- name: AWS認証情報の設定
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: 動画レンダー実行
env:
REMOTION_FUNCTION_NAME: ${{ secrets.REMOTION_FUNCTION_NAME }}
REMOTION_SERVE_URL: ${{ secrets.REMOTION_SERVE_URL }}
AWS_REGION: ${{ secrets.AWS_REGION }}
COMPOSITION_ID: ${{ github.event.inputs.composition_id || 'NightlyReport' }}
run: npx ts-node scripts/render.ts
ステップ9:framesPerLambdaによるコスト最適化
framesPerLambda はレンダー速度とコストのバランスを決める最重要パラメータです。1つのLambda関数が担当するフレーム数を指定します。
framesPerLambdaを下げる → より多くのLambda関数が並列起動 → ウォールクロック時間が短くなる
framesPerLambdaを上げる → Lambda起動回数が減る → Lambdaのオーバーヘッドが下がる
300フレーム(30fpsで10秒)の動画での比較例:
| framesPerLambda | Lambda起動数 | 並列度 |
|---|---|---|
| 10 | 30 | 最大 |
| 20 | 15 | 標準 |
| 50 | 6 | 最小 |
Remotionはデフォルトでコンポジション長に基づいて自動計算します。最小値は4フレームです。
実運用での使い分け:
ユーザーが結果を待っている(購入後の動画など)ならLambdaが増えても速度優先で低い値(10〜20)を選びます。夜間バッチで大量生成する場合はLambda起動回数を減らして管理しやすくするために高め(40〜80)にします。
レンダー前にコストを見積もる estimatePrice() も活用できます。
import { estimatePrice } from "@remotion/lambda";
const estimate = estimatePrice({
region: "ap-northeast-1",
memorySizeInMb: 2048,
durationInMilliseconds: 3000,
concurrency: 15,
renderingTimeInMilliseconds: 2000,
});
console.log("推定コスト:", estimate.priceInDollars.toFixed(6), "USD");
ステップ10:本番レンダージョブの監視
CloudWatchログ
deployFunction() で createCloudWatchLogGroup: true を指定すると、各Lambda呼び出しがCloudWatchにログを書き込みます。失敗したレンダーのトレース・チャンクごとの処理時間プロファイリング・エラーレートへのアラートに活用します。
データベースでのジョブ管理
大量のレンダーが並行して走る本番環境では、レンダージョブのテーブルを持つことを推奨します。
// レンダー開始時
await db.renderJobs.create({
id: renderId,
compositionId,
inputProps: JSON.stringify(inputProps),
status: "pending",
startedAt: new Date(),
bucketName,
});
// ポーリング中
await db.renderJobs.update(renderId, {
status: "rendering",
progress: Math.round(progress.overallProgress * 100),
});
// 完了時
await db.renderJobs.update(renderId, {
status: "complete",
outputUrl,
completedAt: new Date(),
costEstimate: progress.costs?.estimatedCost,
});
失敗時のアラート
progress.fatalErrorEncountered が true になったときにSlackやPagerDutyへ通知を送ります。主な原因は次の3つです。
- アセットURLのタイムアウト:CDNまたは事前キャッシュされたURLを使う
- Lambdaのメモリ不足:複雑なコンポジションでは
memorySizeInMbを増やす - バージョン不整合:デプロイ済みLambda関数とCI環境の
@remotion/lambdaのバージョンが違う
if (progress.fatalErrorEncountered) {
await sendSlackAlert({
channel: "#render-alerts",
message: `レンダー失敗: ${renderId}\nエラー: ${progress.errors.map(e => e.message).join(", ")}`,
});
throw new Error("レンダー失敗");
}
FAQ
Q: サーブURLはどこに保存するのが最善ですか?
AWS SSM Parameter Storeへの保存を推奨します。デプロイワークフローで aws ssm put-parameter を使って書き込み、レンダーワークフローで aws ssm get-parameter で読み込みます。こうすると2つのワークフローを疎結合に保ちながら、常に最新のURLを参照できます。
Q: Lambdaチャンクが失敗した場合はどうなりますか?
maxRetries の設定に従ってリトライされます。すべてのリトライが失敗すると progress.fatalErrorEncountered が true になり、progress.errors にエラー内容が格納されます。チャンクによってはノンファタルなエラーが起きても残りのチャンクが処理を続ける場合もあります。
Q: デプロイとレンダーを1つのジョブにまとめても問題ありませんか? 動作はしますが分けることを推奨します。デプロイはコード変更時に実行され、レンダーはビジネスイベント時に実行されます。1つにまとめると「コードをpushするたびにレンダーが走る」という意図しない挙動につながります。
Q: AWS Lambdaのコンカレンシー制限に引っかかることはありますか? AWSアカウントのリージョンごとのデフォルトLambdaコンカレンシー上限は1000実行です。framesPerLambda=10で1000フレームの動画をレンダーすると100並列が発生します。同時に複数のレンダーを走らせる場合はコンカレンシー上限をAWSサポートに引き上げてもらうことを検討してください。
Q: このパイプラインをRemotionのCloud Runに移植できますか?
できます。@remotion/cloudrun パッケージが renderMediaOnCloudRun() と getRenderProgress() を提供しており、APIの形は類似しています。Lambda固有のセットアップ(IAMポリシー・関数デプロイ)をCloud Run対応のものに置き換えるだけで、GitHub Actionsのワークフロー構造はそのまま使えます。
Q: Lambdaで生成できる動画の最大の長さは?
Lambda関数の最大実行タイムアウトは15分です。Remotionはチャンク分割でこれを回避するため、動画の長さ自体に理論上の上限はありません。60分の動画はチャンク数が増えるだけです。ただし動画が長くなるほどコストとS3ストレージが増加します。個々のLambda呼び出しを120秒以内に収めるよう framesPerLambda を調整することをRemotionチームは推奨しています。
Q: inputPropsに顧客の個人情報を入れても安全ですか?
inputProps はレンダー実行中にLambdaインフラによって一時的にS3に保存されます。個人情報や秘匿情報を直接入れることは推奨しません。代わりに顧客IDや短期有効の署名付きURLを渡し、コンポジション内で必要な場合のみ解決するパターンを採用してください。名前や商品名程度のパーソナライズデータであれば通常は問題ありません。
RenderCompのテンプレートでパイプラインを高速化
本記事で解説したインフラとパイプラインのパターンは、どのRemotionコンポジションにも適用できます。ただし、コンポジションの品質——Reactコンポーネントの描画効率・propインターフェースの明確さ・アニメーションのフレーム効率——はLambdaの実行時間とコストに直結します。
RenderComp はLambdaパイプラインに最適化されたRemotionテンプレートライブラリです。軽量なバンドルサイズ・クリーンなpropsインターフェース・JPEG描画フレンドリーなデザインで構成されており、本記事のワークフローに直接組み込んで使えます。各テンプレートはTypeScriptの型定義付きで提供されています。
テンプレート一覧は rendercomp.com でご確認ください。