Claude Codeと24/7 YouTubeライブ配信SaaSを作った記録 — 止まらない配信基盤のフルスタック実装
はじめに
「LoopCast」という24/7/365 YouTubeライブ配信SaaSを、Claude Codeとペアプロしながら構築しました。ユーザーが動画をアップロードすると、それが永遠にループ配信される——Lofi Girlのような配信を誰でも持てるサービスです。
このサービスの核心は**「止まらないこと」**です。配信が止まれば視聴者が離れ、YouTubeのアルゴリズムも不利になる。それだけでなく、配信が止まるとブロードキャストIDが変わり、URLが変わり、視聴者のブックマークも積み上げた再生回数もゼロになります。
本記事はそのPhase 0〜T1-9(配信ノード構築からコントロールプレーン本番配備まで)の実装記録です。Claude Codeのセッションログをもとに書いており、どこでハマり、何を見落とし、どう解決したかを中心に整理しました。
個人の感想として: Claude Codeは「指示に従う」だけでなく、SPECの穴を見つけて報告したり、自分の間違いを申告したりする場面が多くありました。特にセキュリティ上の問題を「報告の最後」ではなく「最初」に置き直すよう促された場面が印象的でした。
やったこと
システム構成
[ConoHa 4GB] Control Plane (CP)
- Next.js 15 App Router
- PostgreSQL 16 + Drizzle ORM
- /api/agent/sync エンドポイント
- nginx (リバースプロキシ)
[ConoHa 1GB] Streaming Node (loopcast-node)
- ffmpeg (concat demuxer, -c copy)
- stunnel (TLS終端)
- loopcast-agent (Node.js, 149KB単一ファイル)
- systemd template units
Phase 0: 配信基盤の検証
T0-2: 検収スクリプト(verify.sh)
正規化済み動画の品質を機械的に検証するスクリプトを作りました。受け入れ基準の核心は**「通すことより弾くことが仕事」**という考え方です。
# 壊しパターン3種のテスト
# broken1: GOP 10秒(keyint不正)→ 項目5のみFAIL
# broken2: VFR(CFR違反)→ 項目7のみFAIL
# broken3: A/V不一致(映像30秒/音声27秒)→ 項目6のみFAIL
ポイントは「狙った1項目だけが落ちること」の検証です。他の項目が巻き添えで落ちると、その項目単独の検出力を証明できません。
loudnorm が100ms音声を伸ばす問題
SPEC §2-1のまま実装すると、normalize.sh自身がSPEC §2-2の検収項目6(A/V差100ms以内)を違反するという発見がありました。
# 問題のある構成
-af "loudnorm=I=-14:TP=-1.5:LRA=11"
# → A/V差 100.0ms ❌
# 修正版
-af "loudnorm=I=-14:TP=-1.5:LRA=11,aresample=async=1:first_pts=0"
# → A/V差 0.0ms ✅
loudnormのフィルタ遅延で音声フレーム数は同じなのにコンテナdurationだけが伸びる現象です。
T0-4: ループ境界の検証
33分で0.008秒しかA/V差が変化しないこと、境界フレームのMD5ハッシュが120秒地点と2040秒地点で完全一致することを確認しました。これがPhase 0で最も重要な証拠でした。
# ローカル収録で境界フレームを抽出してハッシュ比較
ffmpeg -i recorded.flv -vf select='eq(n\,3597)' -frames:v 1 frame_before.png
ffmpeg -i recorded.flv -vf select='eq(n\,3598)' -frames:v 1 frame_after.png
md5sum frame_*.png
# 26e8a086eeb37ec3... frame_before.png # 120秒地点
# 26e8a086eeb37ec3... frame_after.png # 2040秒地点 → 完全一致
T0-5: TLSオーバーヘッドの実測と stunnel 導入
帯域の実測で予想外の結果が出ました。
| 経路 | 実測膨張率 | 8本推計 |
|---|---|---|
| 平文RTMP | 1.0069倍 | — |
| ffmpeg直接RTMPS | 1.3527倍 | 52.5 Mbps ❌ |
| stunnel経由RTMPS | 1.0399倍 | 39.0 Mbps ✅ |
原因はffmpegがRTMPのチャンク(デフォルト128バイト)ごとに小さなTLSレコードを生成していること。stunnel経由にすることで大きなレコードにまとめられ、TLSの固定オーバーヘッドの影響が激減します。
# stunnel が必要な理由をコメントに残す(将来勝手に外されないように)
# ffmpeg が TLS の書き込みをバッファリングするよう修正されたら不要。
# ×1.05 程度になったら外してよい。
# 逆に理由を確認せずに外すと帯域が33%増えて Fair Use を踏む。
重要な設計決定として、BindsTo=ではなくRequires=を使いました。BindsTo=だとstunnelが死んだときsystemdが停止ジョブを発行し、停止ジョブで止まったユニットはRestart=alwaysの対象外になります。
T1-6: エージェントの設計
「止めない」と「正しく止める」の両立
エージェントのreconcileの核心はDesiredSource判別可能ユニオンです。
type DesiredSource =
| { kind: 'fresh'; desired: DesiredStream[] } // 空配列は「全部止めろ」
| { kind: 'last-known'; desired: DesiredStream[]; staleSec: number }
| { kind: 'unknown' } // 何もしない
CPが200+空desiredを返す場合と5xxを返す場合で挙動を分けています。
// ✅ CPが内部エラー時は 5xx を返す
// ❌ 200 + [] は返さない(「本当に止めてよい」場合だけ空配列)
テストで固定した不変条件
「sync失敗を止めると解釈しない」はコメントではなくテストで固定しました。
// CP が 5xx を返し続けても止まらない
it('5xx が続いても配信を止めない', async () => {
const source: DesiredSource = { kind: 'unknown' }
await reconcile(source, running, systemd)
expect(systemd.stop).not.toHaveBeenCalled() // ← ここが核心
})
// 対比: 200 + 空配列は止まる
it('fresh + 空 desired は全部止める', async () => {
const source: DesiredSource = { kind: 'fresh', desired: [] }
await reconcile(source, running, systemd)
expect(systemd.stop).toHaveBeenCalled()
})
稼働時間の冪等性
差分加算方式は「CPは処理したがレスポンスが失われ、エージェントが再送」で二重計上します。
// ❌ 差分方式(再送で二重計上)
usageDaily += report.deltaMs
// ✅ 累積値方式(再送しても結果が変わらない)
const delta = report.cumulativeStreamedMs >= prevCumulative
? report.cumulativeStreamedMs - prevCumulative
: report.cumulativeStreamedMs // 巻き戻り=状態喪失
usageDaily += delta
ハマったポイント
ハマり1: activating状態の誤認
systemctl --state=activeでユニット一覧を取ると、自動再起動中(activating)の約5秒間はリストに出てきません。reconcileのtickがその窓に入ると「起動していない」と誤認して二重起動を引き起こします。
# 修正前: activeのみ
systemctl list-units 'loopcast-stream@*' --state=active
# 修正後: activatingも含める
systemctl list-units 'loopcast-stream@*' --state=active,activating
修正前は13回中9回誤認していました。「10秒で復旧する」を確認するだけでは絶対に見つからない種類の欠陥です。
ハマり2: ffmpeg の stderr パイプが読み手なしで死ぬ
systemdがroot:root 644でログファイルを作り、loopcastユーザーで動くffmpegが追記できない→stderrパイプの読み手がいない→-loglevel warningで平常時は無音だが、警告を1行出した瞬間にSIGPIPE(rc=141)でffmpegが死ぬ。
# 壊れていたことの確認
./test-stderr-pipe.sh
# 修正前: rc=141 ✅(SIGPIPE再現)
# 修正後: rc=0 ✅(正常終了)
ハマり3: .gitignoreの*.tsがTypeScriptを全部除外
MPEG-TSの動画ファイルを除外するつもりで*.tsと書いたところ、TypeScriptのソース26ファイルが一度もコミットされていない状態になっていました。
# ❌ こう書いてはいけない
*.ts
# ✅ 正しくはこう
*.m2ts
# コメント: `*.ts` と書くと TypeScript のソース全部が除外される
# (実際に 26 ファイルが未コミットだった)
偶然にも.gitignoreのバグのせいで、テストに実キーを書いていても.gitに入っておらず、漏洩が防がれていたという皮肉な状況でした。
ハマり4: verify-node.shの[P]/[B]分類
provisionした設定を検証するスクリプトで、「イメージの既定値をテストしているだけの緑」を生み出す問題がありました。
素のVPSに verify-node.sh を流したとき:
- Automatic-Reboot=false が ✅ → 実はコメント行にマッチしていた
- swap 2GB が ✅ → ConoHaイメージの既定
- ed25519鍵1本が ✅ → VPS作成時にConoHaが配った
これを[P](provision固有の成果)と[B](イメージ由来の前提)に分類し、**受け入れ基準を「素のVPSで[P]が1つでも通ったら検証器が壊れている」**と定義することで解決しました。
またgrepで設定ファイルを読む検証はコメント行も拾うため、apt-config dump/sshd -T/sysctl -nなどでプログラム自身に実効値を聞く形に変えました。
ハマり5: sweep-secrets.shの5連続失敗
シークレットスキャンのスクリプトで同じクラスの失敗が5回続きました。
-
grep -rがシンボリックリンクを追わない -
IFS='|'が正規表現の|でも分割する(SSH鍵のパターンが切り詰め) - macOSに
timeoutがなく127が成功扱いされた -
findはNFD、pwdはNFCでパスを返す(日本語パスの比較が常に偽) -
set -e下で(( n == 0 ))が非0を返して関数を抜ける
全て「シェルが書いてあることと違う挙動をした」で、既定の失敗モードが沈黙という共通原因でした。
解決策として**カナリアテスト(到達性テスト)**を導入しました。
# パターンのテストではなく「そのファイルまで歩いているか」をテスト
for loc in \
"$HOME/canary_home.key" \
"$HOME/Downloads/canary_dl.key" \
"$HOME/Desktop/level1/level2/level3/level4/level5/canary_deep.key" \
"$TMPDIR/canary_tmp.key" \
"$SYMLINK_TARGET/canary_symlink.key"
do
echo "-----BEGIN RSA PRIVATE KEY-----" > "$loc"
# sweep後に全部検出されることを確認
done
学び
1. 「取った」と「戻せる」は別物
この教訓が3回出てきました。
- DBバックアップ → 復元テスト(14テーブル・FK・インデックスが完全一致)
- bareリポジトリ →
cloneで中身が出るかの確認(HEADがmasterを指してcloneで0件) - GitHubプッシュ →
pnpm install && build && testまで通すかの確認
毎回「一段深く聞く」ことで、実際に取り戻せることを証明しました。
2. コメントは実装を守らない
守るのはfail-closeの分岐とテストだけ。「絶対に間違えない」と書きたくなったら、それは代わりに間違えたときに止まる仕組みを書くべき箇所です。
T0-2の検収、T0-5のTLS検証、T0-8のpublic_htmlへの保存、今回のコメント行マッチ——全部同じ形でした。
3. 否定形の不変条件はペアで検証する
「止まらないこと」を確認するだけでは不十分です。「正しく止まること」が壊れていると、解約した顧客の配信が止まらないという形で表に出ます。
// 「止まらないこと」と「正しく止まること」を対で検証
it('CPが止まっても配信は継続する', ...)
it('desired=stopped なら実際に止まる', ...)
4. [P]/[B]の分類は検証器一般に使える
provision後の設定検証に限らず、「素の状態で通る項目を合格に混ぜると、前提をテストしているだけの緑になる」という考え方は広く応用できます。CI/CDのテストで「環境依存の前提」と「コードが保証する成果」を分けることで、テストが何を証明しているかが明確になります。
5. 設定ファイルを読まない
検証には「プログラム自身に聞く」方式が確実です。
# ❌ 設定ファイルを grep(コメント行・上書き・複数ファイルを見落とす)
grep 'Automatic-Reboot.*false' /etc/apt/apt.conf.d/*
# ✅ 実効値を確認
apt-config dump | grep Automatic-Reboot
sshd -T | grep permitrootlogin
sysctl -n vm.swappiness
おわりに
Claude Codeを使って開発した最も印象的な点は、**「指示していない発見を報告する」**場面の多さです。
-
/proc/cmdlineからストリームキーが読めることを発見→hidepid=2をSPEC仕様に格上げ - loudnormのフィルタ遅延がSPECのバグを生むことを発見
-
ffmpeg 7.0.2静的ビルドのクラッシュ→TLSオーバーヘッドの原因特定という迂回路 -
/tmp/lcgに無保護の秘密鍵が残っている発見
そして毎回「報告の最後ではなく最初に重大な問題を置く」ことを徹底していました。
コードとして残ったのはpackages/sharedに埋め込んだ実測定数です。
export const EXPANSION_RATIO = {
rtmpPlain: 1.0069,
rtmpsFfmpegDirect: 1.3527, // ffmpeg直接だと8本が運用点を超える
rtmpsViaStunnel: 1.0399, // stunnel経由なら収まる
} as const;
「ffmpeg直接だと8本が運用点を超える=stunnelが必要な理由」をテストで表現したことで、半年後に誰かがstunnelを外そうとしたときに、テストが落ちて理由を教えてくれます。コメントより強い。