terraform applyで音声AIサービスが立つまで — User Pool全消し事故と、死なないAPIキーの謎

前々回に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 開発シリーズ

シリーズ目次(全7回のあらすじ)はこちら

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール