Twitchは無効なキーでも「OK」と言って、黙って捨てる — 連携の全落とし穴と41秒復帰
無人で動く AI アバターのライブ配信システムを作ると、開発の重心が普通のアプリとは変わってきます。誰も見ていない時間帯に落ちるので、誰も直せない。だから「うまく動く」ことより「壊れても分かる/勝手に戻る」ことのほうが品質を決めます。
この記事は、そのシステムを Twitch 上で通しで動かすまでに踏んだ落とし穴を、3 つの層に分けてまとめたものです。
- 認証の層: EventSub とメタ情報操作のための OAuth。人間の手作業が挟む時間制約と、トークンが不変ではないという罠
- 送出の層: ffmpeg で映像を送れているのに、チャンネルがライブにならない。Twitch が無効なキーを黙って受理する問題
- 耐障害の層: 配信中にサーバが死ぬ経路は 3 つあり、そのうち 1 つは映像が出たまま無言になる
いずれも「送信側から見ると全部正常」という共通のパターンを持っていて、そこが一番厄介でした。順番に書いていきます。
そもそもどこから OAuth が必要になるか
最初に整理しておくと、チャットを読むだけなら匿名の IRC 接続で足り、アプリ登録すら不要です。ここは何の手続きもなくできました。
OAuth が必要になったのは、次の 2 つをやりたくなったときです。
- EventSub でビッツ(投げ銭)・サブスク・レイドを受け取り、アバターに反応させる
- Helix API で配信タイトルを番組ごとに自動設定する
この 2 つで初めて認可が要ります。全体の流れはこうです。
1. 開発者コンソールでアプリを登録する(Confidential タイプ)
2. client_id / client_secret を得る
3. 認可 URL をブラウザで開き、チャンネルの持ち主として認可する
4. リダイレクト先の URL から認可コード(code)を取り出す
5. code を token エンドポイントに投げて、access token と refresh token に交換する
6. refresh token をサーバに保存する
対話が必要なのは 3 と 4 だけで、あとは自動化できます。以下、通してみて詰まったところです。
アプリの種類とリダイレクト URI
アプリ登録時に種別を選びます。サーバ側で client_secret を保持して token 交換をするなら Confidential です。ここを間違えると、後段の token 交換で secret が使えず作り直しになります。
リダイレクト URI は認可を通すためだけのものなので、http://localhost:3000 のような手元のアドレスで構いません。実際に何かを受け取るサーバを立てる必要すらなく、リダイレクト後のアドレスバーから code をコピーすれば十分です。
スコープは 1 回の認可でまとめて取る
スコープが足りないと、認可からやり直しになります。必要だったのは 3 つでした。
| スコープ | 用途 |
|---|---|
bits:read |
ビッツ(投げ銭)のイベント購読 |
channel:read:subscriptions |
サブスクのイベント購読 |
channel:manage:broadcast |
配信タイトルなどのメタ情報の設定 |
あとからスコープを足すには、また人間にブラウザを開いてもらう必要があります。やりたいことを先に洗い出して、必要なスコープを 1 回の認可でまとめて取る。
force_verify を付けて、認可するアカウントを取り違えない
認可 URL には force_verify=true を付けました。
これを付けないと、ブラウザにログイン済みのアカウントで確認画面なしに認可が通ってしまうことがあります。配信用のキャラクターアカウントと個人アカウントを両方持っている場合、意図しないアカウントで認可される事故が起きます。force_verify=true なら毎回確認画面が出るので、どのアカウントで認可しようとしているかが目で見えます。
認可コードの寿命は数分しかない
ここが実際に一番ヒヤッとした点です。
**認可コード(code)は数分で失効します。**ブラウザで認可 → URL から code をコピー → 手元に渡す → token に交換、という流れで人間の手作業が挟まると、受け取った時点でもう死んでいることがあります。
対策は単純で、交換処理を先に準備しておいて、code が届いた瞬間に叩くことです。交換用のコマンドを組み立てた状態で待機し、code が来たら即実行、という段取りにしたら 1 回で通りました。
資格情報を得たら「誰として認可されたか」を確認する
token を得たら、検証エンドポイント(/oauth2/validate)を叩いて中身を確認します。
curl -H "Authorization: OAuth <access_token>" https://id.twitch.tv/oauth2/validate
返ってくる login(ユーザー名)とスコープの一覧を目で見ます。期待したチャンネルの持ち主として認可されているか、スコープが全部入っているか。
これをやらずに先へ進むと、EventSub の購読が通らない理由が「スコープ不足なのか」「アカウント違いなのか」「実装ミスなのか」で切り分けられなくなります。資格情報を得た直後に、その資格情報が何者かを確認する。
運用の罠: refresh token は入れ替わることがある
これは実装が終わってから気づいた点で、いちばん共有する価値があるところです。
Twitch はトークンを更新するとき、refresh token 自体も新しいものに差し替えることがあります。
素直に実装すると、こうなります。
- 起動時に、保存された refresh token を読む
- それでアクセストークンを更新する
- 新しい refresh token が返ってくる
- それをプロセスのメモリに持つ
- プロセスが再起動する → 1 に戻り、保存された古い refresh token を読む
古いほうがまだ有効なら動きますが、無効化されていれば認証が通らなくなります。しかも再起動するまで症状が出ないので、原因が分かりにくい種類の障害になります。
正しくは、更新のたびに新しい refresh token を永続化することです。同種の話は他のサービスにもあるので、「refresh token は不変」という前提を置いていないかを確認するといいと思います。
秘密情報は「どこに残るか」を先に考える
最後に運用の話を少しだけ。client_secret や refresh token をチャットやチケットに貼るのは避けたいところです。ファイル経由で受け渡し、使い終わったら消す手順にしました。
touch ~/.twitch-cred && chmod 600 ~/.twitch-cred
# 値を書き込む
# 使用後
shred -u ~/.twitch-cred
以前、別の検証でストリームキーがログに出てしまい、キーをリセットする羽目になったことがあります。秘密情報は「どこに残るか」を先に考える。
「ffmpeg は動いている」は「配信できている」ではない
認可が通り、次は映像を実際に Twitch へ送る段階です。ここで、送信側の情報が全部「正常」なのにチャンネルがライブにならない、という状態に突き当たりました。
ffmpegは起動して、フレームを送り続けている- 標準エラー出力にエラーは出ていない
- RTMP の接続は維持されている(切断も再接続も起きていない)
- それでもチャンネルはライブにならない
どこを疑えばいいのか分からない。結論は 2 つありました。
**1. Twitch の RTMP インジェストは、無効なストリームキーでも接続を受理します。**受け取ったデータは黙って捨てられます。送信側からは成功と区別がつきません。
**2. 送信しているストリームが「壊れている」場合も、Twitch はライブと判定しません。**このときダッシュボードの配信インスペクターにすら何も出ません。
接続の維持は、キーが有効な証拠にならない
RTMP でよくある思い込みが、「接続が確立して維持されているならキーは通っている」です。通っていません。実際に見えていた状態と Twitch 側のズレはこうでした。
| 見ているもの | 状態 |
|---|---|
| ffmpeg のログ | 正常。フレームを送出中 |
| TCP 接続 | 確立・維持 |
| Twitch のチャンネル | オフラインのまま |
このときの原因はアカウント側の設定(2 要素認証まわりとストリームキーの状態)でした。設定を直したら、まったく同じコマンドでライブになりました。送信側は何も変えていません。
ここから得た運用則はシンプルです。
配信の成否は、送信側のログではなく受信側の状態で判定する。
「壊れたストリーム」もライブにならない
もう 1 つ引っかかったのがこちらです。当時、CPU 描画で映像を作っていて、録画してみると 90 秒動かして 6 秒分しか記録されないという別の不具合を抱えていました(原因は音声トラックの枯渇です)。
このタイムラインが脱落したストリームを Twitch に送ると、**接続は成立するのにライブ判定されません。**配信インスペクターにも情報が出ませんでした。Twitch 側から見ると、タイムスタンプが実時間に対して極端に遅れているストリームが届いているので、正常な配信として扱いようがない、ということだと思います。
これも送信側からは「送れている」ように見えます。ffmpeg が動いていることは、有効なストリームを送れていることを意味しません。
外形監視をどう作ったか
そこで、Twitch 側の状態を外から取る仕組みを検証手順に組み込みました。チャンネルの配信時間を返す公開エンドポイントを使っています。
curl -s "https://decapi.me/twitch/uptime/<チャンネル名>"
# ライブ中: "49 seconds"
# オフライン: "<チャンネル名> is offline"
これで「実際にライブになっているか」が機械的に判定できるようになりました。配信テストの合否条件を、送信側のログからこの出力に移しています。
ただし罠があります。**この種のサードパーティ API はレスポンスをキャッシュします。**配信を開始した直後に 1 回だけ叩くと、まだ offline が返ってきます。これで一度「配信できていない」と誤判定して、原因を探しに行くという無駄をやりました。
# 単発チェックはダメ。ポーリングする
for i in $(seq 1 20); do
curl -s "https://decapi.me/twitch/uptime/<チャンネル名>"
echo
sleep 15
done
終了確認も同じで、配信を止めた直後は offline になりません。開始も終了も、状態が落ち着くまでポーリングするのが正解でした。
「送れた」と「届いた」は別の検査項目
この件は Twitch に限った話ではありません。同じ構造はいろいろな場所にあります。
- メール送信: SMTP が 250 を返しても、受信箱に入ったとは限らない
- Webhook: 200 が返っても、相手の処理が成功したとは限らない
- メトリクス送信: エージェントが送信していても、ダッシュボードに出ているとは限らない
いずれも「送信側の成功」と「受信側の状態」の間にギャップがあります。**片側だけを見て運用していると、静かに壊れた状態が続きます。**この一件以降、配信系の検証チェックリストには必ず「受信側の外形確認」を 1 行入れることにしました。沈黙は成功の同義語ではありません。
落ちる経路は 3 つあり、1 つは無言のまま生き続ける
配信が通ったら、次は無人運用に耐える番です。落ちたときにどうなるかが品質のほぼ全部になります。落ちる経路を整理したら 3 つあり、それぞれ検知の仕方も復帰の仕方も違い、そして 3 つ目がいちばん危険でした。
| # | 壊れ方 | 症状 | 復帰 |
|---|---|---|---|
| 1 | GPU ホストが不安定 | 数十秒〜数分おきにレンダラーが復帰を繰り返す | 別ホストで取り直す |
| 2 | Pod ごと消える | 映像が完全に途切れる | 別ホストで取り直す |
| 3 | 対話サーバだけ死ぬ | 映像は流れているが、アバターが無言のまま | 3 段の連鎖で復帰 |
経路 1・2: 個体を直さず、引き直す
1 と 2 はまとめて対処できました。壊れた個体を直そうとせず、捨てて取り直す方針です。
- レンダラー側: 一定時間の窓の中で復帰が閾値を超えたら、自分でプロセスを終了する
- スケジューラ側: Pod の状態を監視し、終了・消滅を検知したら別ホストで作り直す(上限あり)
「自分から死ぬ」のは直感に反しますが、上位に異常を伝える最も確実な手段でした。検証は、配信中に手で Pod を削除しました。32 秒で検知され、別ホストで配信が再開しました。
経路 3: いちばん危険な「無音のまま生き続ける」
問題はこちらでした。対話サーバ(発話を生成する側)だけが再起動したとき、レンダラーは何も知りません。映像は流れ続けます。ページも生きています。アバターは画面に映っていて、瞬きもしています。ただ、一切しゃべらなくなります。
視聴者から見ると「配信は続いているのに、AI が黙り込んだ」状態です。監視の観点では最悪で、
- 配信はライブのまま(プラットフォームは正常と判断)
- レンダラーは正常稼働(プロセスも ffmpeg も生きている)
- 映像も音声トラックも流れている(無音だが)
**どのヘルスチェックにも引っかかりません。**先に書いた外形監視でさえ、これは検知できません。ライブ判定は立っているからです。
**原因は、途中の層が死を伝えなかったことでした。**構成は多段になっていました。
対話サーバ ←WebSocket→ 音声パイプライン ←WebRTC→ ページ
対話サーバへの WebSocket が切れたとき、間の音声パイプラインがそれを**下流に伝えていませんでした。**WebRTC の接続は生きたままなので、ページから見ると「繋がっているが何も来ない」状態になります。**接続の切断が、経路の途中で吸収されて消えていた。**これが真因でした。
対処: 死を 3 段で伝播させる
上流の死を、確実に下流まで届くようにしました。
- 音声パイプラインが、対話サーバへの WebSocket の死を検知したら、自分の WebRTC 接続を能動的に切る
- ページが、WebRTC の切断を検知したら、一定時間待ってページを再読み込みする
- 再読み込み後の初回接続が失敗しても、間隔を空けてリトライする(対話サーバの再起動が終わるまで待つ)
3 番目が地味に重要でした。これが無いと、「再読み込みしたタイミングがサーバの再起動中だった」場合に、接続に失敗したまま止まります。復帰処理が復帰のタイミングに依存するという、狭いけれど確実に踏むタイミングの穴です。
計測: 実際に再起動して測る
対話サーバをローリング再起動して、復帰までを計測しました。
サーバ再起動
→ WebSocket の死を検知
→ WebRTC を自死させる
→ ページ再読み込み
→ 再接続(サーバがまだ起動中なら数回リトライ)
→ スナップショットから状態を復元
→ 発話再開
合計 41 秒
状態の復元まで含めて 41 秒で、挨拶をやり直すこともなく続きから再開しました。
耐障害設計として持ち帰ったこと
**1. 「無音のまま生き続ける」を最悪の状態として設計する。**プロセスが死ぬのは検知できるので実は安全です。危険なのは、生きているように見えて機能していない状態です。今回でいえば、映像が出ているのにしゃべらない配信。各層に「自分が機能していないと判断したら、正直に死ぬ」条件を入れるほうが、結果的に全体は安定しました。可用性のために生き延びようとする層が、全体の可用性を下げていたわけです。
**2. 切断は必ず下流まで伝える。**多段構成では、途中の層が上流の死を吸収してしまうことがあります。各接続について「上流が死んだとき、この層はどう振る舞うか」を明示的に決めておく必要がありました。既定の振る舞いは、たいてい「何もしない」です。
3. 復帰処理は、復帰の最中でも動くように書く。「サーバが再起動したので再接続する」というコードは、サーバがまだ再起動中であるという当たり前の状況を想定しないと動きません。リトライは復帰処理の一部です。
**4. 障害注入なしの耐障害コードは、動かないと思ったほうがいい。**3 経路とも、実際に壊して確かめました。手で Pod を消す、サーバを再起動する。どれも数分の作業です。書いただけの復帰コードは、たいてい一箇所で止まります。3 つ目の「初回接続のリトライ」は、実際に壊してみて初めて必要性が分かりました。
まとめ
3 つの層を貫いていたのは、**「送信側から見える情報は、受信側の状態を保証しない」**という一点でした。
認証の層
- チャットを読むだけなら匿名 IRC で足りる。EventSub とメタ情報の設定で初めて OAuth が要る
- アプリは Confidential、リダイレクト URI は localhost でよい。スコープは 1 回の認可でまとめて取る
force_verify=trueで意図しないアカウントでの認可を防ぐ- **認可コードは数分で失効する。**交換処理を準備してから認可する
- token を得たら
/oauth2/validateで誰として認可されたかを確認する - **refresh token は更新のたびに入れ替わることがある。**永続化しないと、再起動して初めて壊れる
送出の層
- Twitch は無効なストリームキーでも RTMP 接続を受理し、データを黙って捨てる
- タイムラインが壊れたストリームも同様にライブ判定されず、インスペクターにも出ない
- 配信の成否は送信側のログではなく、受信側の状態を外から取って判定する
- 外形監視のエンドポイントはキャッシュされるので、単発チェックではなくポーリングする(開始も終了も)
耐障害の層
- 無人配信が落ちる経路は 3 つ: 不安定なホスト / Pod の消滅 / 上位サービスだけの死
- 1・2 は「個体を直さず引き直す」。実測 32 秒で検知・再開
- 3 は映像が出たまま無言になるため、どのヘルスチェックにも出ない。原因は切断が経路の途中で吸収されて下流に伝わっていなかったこと。死の伝播で実測 41 秒で完全復帰
OAuth の手順そのものも RTMP の仕様も各サービスのドキュメントに書いてあります。見落としやすいのは、人間の手作業が挟まる部分の時間制約、トークンやストリームが不変ではないこと、そして**「生きているように見えて機能していない」状態**でした。「落ちないシステム」を目指すより、「落ちたことが分かって、勝手に戻るシステム」を目指すほうが、無人運用では圧倒的に現実的でした。