前々回にPush-to-Talkの音声通話アプリを1日で作り、前回はVADの調教とWebSocket回線崩壊の犯人探しをやった。今回はその仕上げ、「terraform applyしたらサービスが立つ」の実現である。
なぜこれをやるのか。フリーランスとして「何を作ったか」を語るより、「これ作った、デプロイするから待ってろ、ほらね」と目の前でURLを渡すほうが強いからだ。デモの即応性は営業力である。
構成: 月30ドルの本番環境
出来上がった構成はこうだ。Route53(既存ドメインのサブドメイン) → ALB(ACM証明書でhttps) → ECS Fargate → EFS。コンテナイメージはECR、OpenAIのAPIキーはSSM SecureString、履歴のSQLiteはEFSに置く。
設計判断で面白いのは3つ。
ARM64を選ぶ。Apple Siliconのdocker buildがそのままFargateに載る。クロスビルドなし、しかも安い。
httpsは飾りではなく必須要件。ブラウザのマイク(getUserMedia)はセキュア文脈でしか動かない。「とりあえずhttpで公開して後で証明書」という選択肢が、音声アプリには存在しないのだ。
ALBのidle_timeoutは400秒。uvicornのWebSocket ping間隔(既定20秒)より必ず長くする。これを逆転させると、ハンズフリー通話の長い沈黙でソケットが切られる。構築前にバックログへ書いておいた注意書きが、そのままTerraformの1行になった。
デプロイして、合成音声をwss://voice.pocraft.net に流し、「こんにちは、聞こえますか」が正確に文字起こしされて応答が返るところまでE2Eで確認。さらに全リソースを破壊→ゼロから再構築→E2E再合格までやって、再現性を証明した。ここまでは順調だった。
事故: User Poolごと消した
「一旦手動でdestroyしてみたい」と、素のterraform destroyを撃った。サービス層だけ消すつもりが、同じstateに同居していたCognito User Poolごと消えた。ユーザー登録が全滅である。
Cognitoにバックアップ・復元機能はない。作り直し一択。幸い設定は全部コードにあったので、User Pool再作成→ユーザー再登録→実行基盤再構築で30分で全快したが、教訓は明確だった。
User Poolはインフラではなく、データである。
ECSやALBは何度壊してもapplyで同じものが生える。しかしUser Poolを壊すと「誰が登録されているか」という状態が消える。性質の違うものを同じstateに置いていたのが敗因だ。対策としてTerraformを2スタックに分離した。認証基盤(infra/auth)と実行基盤(infra/service)でstateを分け、serviceはauthの出力をterraform_remote_stateで参照する。これで実行基盤でdestroyを撃ってもUser Poolには構造的に届かない。ついでにdeletion_protection = "ACTIVE"も付けた。二度と消えない。
ユーザー登録も公式フローに寄せる
再構築ついでに、ユーザー招待をCognito公式のフローに乗せ替えた。Terraformのaws_cognito_userにメールアドレスを書いてapplyすると、Cognitoが一時パスワード入りの招待メールを送り、初回ログインで本パスワードの設定を強制する。管理者は最終パスワードを知らず、AWSコンソールもCLIも触らない。顧客をデモに招待するときも、tfファイルに1行足すだけだ。
ここで小さな罠を踏んだ。招待メールの一時パスワードでログインできない。原因は既定文面だった。
Your username is … and temporary password is XxXxXx.
パスワードの直後に文末のピリオドが付いていて、コピーで巻き込むのである。invite_message_templateでパスワードを独立行に置く文面に変えて解決。この種の罠は、踏んだ人間がテンプレートを直すしかない。
謎解き: InactiveにしたAPIキーで通話できてしまう
仕上げにOpenAIのAPIキーをローテーションした。新キーを発行し、SSMを更新し、タスクを再起動——ここでユーザー(私)が気づく。「古いキーをInactiveにしたのに、まだ通話できるね?」
まず2つの当たり前を確認した。実行中のタスクは起動時に読んだキーをメモリに持ち続けるので、SSMを変えても再起動までは旧キーで動く。そしてOpenAIは新キーを発行しても旧キーを自動失効させない。だが今回はどちらでもなかった。ダッシュボードで明示的にInactiveにした後も通話できていたのだ。
プローブを書いて計測した。結果が面白い。
| エンドポイント | Inactive化した旧キー |
|---|---|
| /v1/models (通常API) | 即時401 |
| /v1/responses (テキスト生成) | 即時401 |
| /v1/realtime/client_secrets (一時キー発行) | 200が返り続ける |
| wss://…/v1/realtime (WS直結) | セッション確立できる |
通常APIでは無効化が即時に効いているのに、Realtime系だけ生きている。さらに観測を続けると、約15分後にWS直結がinvalid_api_keyを返し始め、約20分でclient_secretsも401になった。無効化は最終的に全部に効くが、Realtime系には15〜20分遅れて届く。
なぜか。ドキュメントと観測から推理する。決定的な手がかりは、失効直後のWS直結が「ハンドシェイク(HTTP 101)は通り、接続確立後のerrorイベントで拒否された」ことだ。つまりRealtimeゲートウェイのエッジは認証しておらず、後段のセッションサービスが検証している。Realtimeには一時キー(ek_)という専用の認証機構もあり、通常のHTTP APIとは別の、自前の検証層を持つ独立インフラであることが伺える。音声フレームが毎秒飛び交う長寿命接続のゲートウェイが、キー検証の結果をTTL付きでキャッシュする——中央のキーストアへの問い合わせを間引くための、ごく真っ当な設計だ。その代償が15〜20分の失効ラグである。
ちなみにOpenAIのコミュニティには「失効させたキーが数時間使えた」という報告が2023年から繰り返し上がっていて、公式は初期に「分散サーバー間の同期遅延」に言及している。うちの15〜20分は、むしろ行儀のいい部類らしい。
運用の教訓はこうだ。「失効させた」と「失効した」は別物。401を実測で確認するまでがローテーションである。
まとめ
terraform apply×2(認証基盤・実行基盤) +./deploy.shで音声AIサービスが立つ。月30ドル- User Poolはインフラではなくデータ。stateを分けろ
- 招待メールの一時パスワードは末尾ピリオドを巻き込む。テンプレートで潰せ
- キーの無効化はエンドポイントによって届く速さが違う。失効は実測確認するまで信じるな
次はデプロイのGitHub Actions化と、会話履歴を検索ツール化する「会話メモリRAG」に進む予定である。
リポジトリ(公開しました): github.com/yoshiharu-ishii/realtime_voice
リアルタイム音声AI 開発シリーズ
- Claude Codeと1日で作るリアルタイム音声AI ― $5から始めて、RAG・ペルソナ・Cognito認証・Terraformまで
- 「コードは読まない」開発スタイル ― Mermaid図解とモジュール分割で人間とAIの分業を作る
- AIが空耳する日 — 音声通話アプリの誤検知対策、文字起こし崩壊の犯人探し、そしてコンテナ化
- terraform applyで音声AIサービスが立つまで — User Pool全消し事故と、死なないAPIキーの謎(この記事)
- User Poolはインフラではなくデータである — Terraformスタック分割の2軸
- WebSocket中継 vs WebRTC直結 ― 両方実装して固有名詞で殴り合わせたら、精度の勝敗とバグが2匹出てきた
- 無線機から電話へ ― サーバーVADでハンズフリー通話を実装する