
Claude Cowork のハンズオン環境をAWS上に構築してみた
以前、Claude Code ハンズオン環境をClaude Codeで構築してみた という記事で、ECS Fargate 上の code-server を使ったブラウザ完結型のハンズオン環境を紹介しました。
今回は Claude Cowork のハンズオンです。同じ発想でいけるだろうと思って着手したのですが、Cowork はデスクトップアプリなので前回の資産がほぼ使えず、まったく別の構成を一から組むことになりました。
この記事では、そこで直面した課題ごとに、検討したアプローチと採用した理由を紹介します。
なお、環境の構築自体も Claude Code と Cowork を使って進めました。前回同様、「Claude のハンズオン環境を Claude で作る」という構図です。
ハンズオン環境の全体構成
完成した環境は、2つの独立した Terraform スタック で1セットになっています。
| スタック | 役割 |
|---|---|
| ゲートウェイ側 | LLMゲートウェイ(ALB + EC2上の LiteLLM + RDS PostgreSQL)。受講者ごとの仮想キーで予算・レート制御。上流は Amazon Bedrock |
| 受講者環境側 | 受講者環境(Windows EC2 + Apache Guacamole + ALB/WAF + 配布用ポータル群) |
受講者がブラウザを開いてから Cowork が使えるようになるまでの流れは次のとおりです。
| ステップ | 内容 |
|---|---|
| 0. 接続情報の取得 | 受講者がセルフ照会ポータルで自分のログインURL・ユーザー名・パスワードを受け取る(メール配布も併用可) |
| 1. ユーザーアクセス | 受講者がブラウザから HTTPS で1つのURLにアクセス |
| 2. WAF | 地理的制限・レート制限・IPレピュテーション・Known Bad Inputs 等で前段をふるいにかける |
| 3. ALB + Cognito | ALB の authenticate-cognito アクションで OIDC 認証 |
| 4. ヘッダー認証 | nginx(njs) が署名付き JWT からユーザー名を取り出し X-Guac-User として Guacamole へ |
| 5. 固定接続 | Guacamole が受講者専用の Windows EC2 へ RDP 接続(1ユーザー = 1台に固定) |
| 6. Cowork 起動 | Windows 上の Claude Desktop が、起動時にレジストリへ注入された設定で LiteLLM ゲートウェイに接続 |
| 7. 推論・MCP | LiteLLM が仮想キーごとに予算・レートを見ながら上流モデルと Web検索 MCP へ中継 |
環境構築フロー(受講者アクセス前に実行)はこうなります。
| ステップ | 内容 |
|---|---|
| A. 仮想キー発行 | LiteLLM に受講者数ぶんの仮想キー(alias は student-NNN)を発行し CSV 出力 |
| B. 機密の投入 | Terraform が CSV とSlack認証情報を SSM Parameter Store(SecureString)へ投入 |
| C. インスタンス起動 | Windows EC2 が起動時に S3 からセットアップスクリプトを取得して実行。IMDSv2 で自分のタグを読み、自分の分の機密だけを取得してレジストリへ書き込む |
| D. 接続の自動生成 | Terraform が受講者情報を S3 に置き、ゲートウェイが起動時にそれを読んで全受講者の固定接続を一括作成 |
| E. 配布の準備 | 接続情報のスロット在庫と参加者の許可リストをポータル(API Gateway + Lambda + DynamoDB)へ投入 |
この構成に至るまでに、大きく7つの課題がありました。
課題1:Cowork はデスクトップアプリである
前回の Claude Code ハンズオンでは、code-server(ブラウザ版 VS Code)のコンテナを受講者ぶん起動する構成にしていました。Cowork ではこれが使えません。 Cowork は Claude Desktop 上の機能であり、ブラウザで動くものではないからです。
さらに面倒なことに、Cowork はファイル操作を隔離するためにローカルの仮想マシン(サンドボックス)を起動します。つまり「デスクトップOSが必要」なだけでなく「そのOS上で入れ子の仮想化が動く必要がある」という条件が付きます。
検討したアプローチ
A案:受講者の手元PCにインストールしてもらう
いちばん素直な案です。しかし受講者は非エンジニアが中心で、OSもバージョンもバラバラ。セミナー冒頭の30分がインストール大会になり、しかも「動かない人」が必ず出ます。企業のPCだと管理者権限がなくインストールできないケースもあります。不採用にしました。
B案:コンテナ上の Linux デスクトップ + VNC
コンテナは軽く、前回の Fargate 構成に近い形で組めます。しかしそもそも Claude Desktop に Linux 版はなく、この時点で前提が成立しません。仮に載せられたとしても、Cowork のサンドボックスは入れ子の仮想化を要求するため、Fargate 上のコンテナでは動きません。
C案:Windows EC2 を受講者ぶん立て、Guacamole でブラウザから配る(採用)
受講者1人につき Windows EC2 を1台。受講者はブラウザから Apache Guacamole 経由で RDP 接続します。受講者側に必要なのはブラウザだけ、という前提を守れます。
採用理由と、そこで判明した制約
決め手は「受講者側に何もインストールさせない」を満たしつつ、Nested Virtualization を確実に有効化できる唯一の選択肢だったことです。
ただし調べていくと制約が出てきました。EC2 の Nested Virtualization は全インスタンスタイプで使えるわけではなく、C8i / M8i / R8i の3ファミリーに限定されます(かつ 192 vCPU 超は不可)。Terraform では cpu_options.nested_virtualization で有効化しますが、この属性は AWS プロバイダ v6 以降でしか解釈されません。
そのため、この構成は「インスタンスファミリーを自由に選べない」という前提を最初から抱えることになりました。コスト最適化のために Graviton に寄せる、といった逃げ道が最初から無いわけです。
課題2:1つのURLで、受講者を自分専用のマシンに届ける
受講者ごとにEC2を分けると、前回と同じ問題が出ます。「あなたは3番のURLです」という案内は混乱のもとなので、全員が同一URLにアクセスし、認証後に自分のマシンへ自動で届く必要があります。
Guacamole 自体は「ユーザーと接続の紐付け」を PostgreSQL に持てるので、機能面での不足はありません。問題はその紐付けをどうやって毎回自動で作るかでした。ハンズオン環境は使い捨てで、開催のたびに terraform apply で作り直します。そのたびに管理画面で人数ぶんの接続を手作業で作るのは現実的ではありません。
採用したアプローチ:Terraform が受講者情報を書き、ゲートウェイが起動時に読む
Terraform が受講者情報(IP・パスワード)をまとめて書き出し、ゲートウェイ側の systemd ユニットが起動時にそれを読んで、全受講者ぶんの固定接続を一括作成する方式にしました。
さらに、インスタンス置換で IP やパスワードが変わったときのために、Parameter Store の変更を EventBridge で拾い、SSM RunCommand でゲートウェイのプロビジョニングを再実行する仕組みを入れています。ポーリングも cron もありません。コンテナは再起動しないので、進行中の RDP セッションは切れません。
なお systemd timer によるポーリング案も検討しましたが、常時プロセスを動かし続けずに済むという理由でイベント駆動を選んでいます。
途中で作り直した点:ペイロードを S3 へ逃がす
当初、受講者情報は SSM Parameter Store の値としてまるごと持たせていました。これは人数が増えると壊れます。
SSM パラメータの値の上限は Advanced ティアでも 8KB が天井です。受講者情報は1名あたり約156バイトなので、約51名で apply が失敗する計算になります(実測で50名 7,911B、120名 18,831B)。Intelligent-Tiering は4KB超で自動的に Advanced を選ぶだけで、その先の逃げ道はありません。
そこで、実データは S3 に置き、SSM パラメータには「所在とハッシュ」だけを入れる構成に変更しました。ポイントは SSM 側にハッシュを残していることです。これによりペイロードが変わればパラメータも変わるため、EventBridge のトリガ機構をまったく作り替えずに上限だけを回避できました。
ゲートウェイの IAM は該当の1オブジェクトにしか s3:GetObject を許可していません。同じバケットに置いてある教材やスクリプトには触れられないようにしています。
課題3:Guacamole の認証と、その前提となるネットワーク境界
オンライン開催だと受講者のIPが特定できないので、IP制限だけで守ることはできません。認証が必要です。
ここは一度作ったものを丸ごと捨てています。
第1案:Guacamole 内蔵の OIDC 拡張 + Cognito(廃案)
Guacamole には OIDC 認証の拡張があるので、まずこれに Cognito をつなぎました。実装して apply して、動きませんでした。
原因は仕様の非互換です。Guacamole の OIDC 拡張は response_type=id_token をハードコードしており、Cognito の認可エンドポイントは code と token しか受け付けません。 リクエストは invalid_request で弾かれます。しかもトークンが Guacamole に届く前に失敗するため、Guacamole 側のログには何も残りません。これが切り分けを長引かせました。
後日あらためて調べたところ、この response_type を外部から上書きするプロパティは最新版にも存在せず、対応を試みた Pull Request はいずれも未マージ(うち1件はクローズ済み)でした。ここで「内蔵OIDCで解決する道はない」と確定しました。
ついでに気づいたのは、仮に動いたとしても目的を達成していないということです。この案では Guacamole アプリケーション自体がインターネットに露出したままで、攻撃面がまったく縮小しません。
第2案:ALB の Cognito 認証 + ヘッダー認証(採用)
採用したのは、認証を Guacamole の外側に出す構成です。
- ALB の
authenticate-cognitoアクションで OIDC 認証を済ませる - ALB が付与する署名付き JWT から、nginx(njs) がユーザー名を取り出す
- それを
X-Guac-Userヘッダーとして Guacamole に渡す(Guacamole 側はヘッダー認証拡張で受ける)
こうすると Guacamole の手前で未認証トラフィックが落ちるため、攻撃面が実際に縮小します。受講者から見ても、Cognito で1回ログインすればそのままデスクトップが出てくるので、二段ログインになりません。
ここで、ユーザー名に何を使うかで一度迷いました。ALB が付ける x-amzn-oidc-identity は Cognito の sub(UUID)です。これをそのまま Guacamole のユーザー名にすると実装は簡単ですが、固定マシンの割り当て(student-001 → 特定のEC2)を UUID ベースで持ち直すことになり、認可データの持ち方が変わってしまいます。そのため JWT を njs でデコードし、student-001 形式のユーザー名を維持する方を選びました。
この構成の信頼モデルについて(重要)
ヘッダー認証を採る以上、ヘッダーの詐称をどう防ぐかを設計として決めておく必要があります。ここは2段構えになっています。
- ALB が、受信した
x-amzn-oidc-*ヘッダーを破棄して自分で署名付きのものを再付与する - nginx が
X-Guac-Userを常に上書きする(受講者が偽装ヘッダーを送っても届かない)
そのうえで正直に書いておくと、この構成の nginx 側は JWT のペイロードをデコードしているだけで、署名を暗号的に検証してはいません。 つまり認証の担保を、暗号ではなくネットワーク到達性(ALB を迂回する経路が存在しないこと)に寄せた設計判断になっています。ゲートウェイをプライベートサブネットに置き、ALB のセキュリティグループからのみ到達を許しているのはそのためです。
この判断が許されるのは、この環境がイベントごとに作って終了後に destroy する使い捨て環境であることと、次に述べるネットワーク境界が成立していることが前提だからです。同じ作りを常設運用に持っていくなら、ALB の公開鍵で JWT の署名(ES256)を検証し、あわせて exp / iss / 署名者を確認する処理を nginx 側に入れる必要があります。この格上げ手順は README に信頼モデルとして書き残してあります。
ネットワーク側の壁:プライベートサブネット + NAT Gateway
上の信頼モデルは「ALB を迂回する経路が存在しない」ことに寄りかかっています。では実際にどう塞いでいるか、という話です。
受講者の Windows も Guacamole ゲートウェイも、プライベートサブネットに置いています。 パブリックIPもEIPも持たせません。インターネットからの到達経路そのものが存在せず、唯一の入口は ALB だけです。加えて、ゲートウェイの HTTP は ALB のセキュリティグループからのみ許可しています。
つまり「ALB 経由での改ざん」は ALB のヘッダー上書きが防ぎ、「ALB を迂回した直接接続」はサブネットと SG の2枚で塞いでいる、という形です。
実はここは以前、弱点でした。 当初はゲートウェイをパブリックサブネットに置いてパブリックIPを付けていたため、直接到達を止めているのが SG 一点のみという状態だったのです。SG の設定を1つ緩めるだけで露出する構成の上に、署名検証を省いた認証を載せていたことになります。プライベートサブネット化は、この一点依存を解消するための変更でした。
外向き通信は NAT Gateway 経由になります。ハンズオンの実行中に、LLMゲートウェイ・Slack MCP と OAuth 認可・プラグインマーケットプレイス・Claude Desktop の認証や更新といった外部通信が発生するため、NAT は省けません。なお S3 だけは Gateway エンドポイント(無料)を置いて NAT を迂回させています。
NAT 周りで気を使ったのは、既存のルートテーブルを奪わないことです。0.0.0.0/0 → NAT のルートを1本追加するだけで、ルートテーブル自体は作らず、関連付けにも触りません。共有VPCに間借りしている以上、destroy の影響はこのルートが消えるだけに閉じてほしいからです。
もう1つ、Terraform が推論できない依存があります。プライベートサブネットのインスタンスは、NAT ルートが無い状態で起動すると user_data の外部ダウンロードが全部失敗します。 これは依存関係として表現されないので、受講者機とゲートウェイの module に depends_on を明示しています。
そして、このプライベートサブネット化にはもう1つの効き目がありました。受講者機の外向き通信がすべて NAT を通るということは、プロキシから見た受講者側の送信元IPが EIP 1つに固定されるということです。これが次の課題で効いてきます。
課題4:推論バックエンドと、受講者ごとのコスト管理
前回の Claude Code ハンズオンでは、コンテナに IAM ロールを付けて Amazon Bedrock を呼ぶ構成にしていました。受講者はAPIキーを一切用意しなくてよく、非常にきれいな形です。今回も同じにしようとしました。
期待していたこと、実際に書いてあったこと
要件の初期案は「EC2 に IAM ロールを付与して、Cowork 側の3rd Party認証操作を可能な限り省略する」でした。しかしドキュメントを読むと、Claude Desktop が Bedrock 向けに用意している認証方式は次の4通りだけでした。
- Bearer Token
- アプリ内の AWS SSO
- Named Profile
- カスタム認証ヘルパースクリプト
「EC2 に IAM ロールを付けるだけで、追加設定なしに Bedrock を使わせる」という動作は、いずれの方式にも存在しません。 ここで前回の資産をそのまま流用する道は消えました。
さらに、当時の Bedrock 経由の Cowork はリサーチプレビュー扱いで、Web検索ツールをはじめいくつかの機能が使えないことも分かりました。顧客が同席する本番当日にプレビュー機能へ依存するのはリスクが高い、という判断もありました。
採用したアプローチ:LiteLLM プロキシを自前で立てる
最終的に、LLM ゲートウェイ(LiteLLM)を自分たちで立て、受講者ごとに仮想キーを発行する構成にしました。Claude Desktop 側はレジストリで「ゲートウェイ方式・Bearerトークン・固定キー」を指定するだけです。
この方式を選んだ理由は、認証を通せることそのものよりも運用上のメリットが大きかったからです。
- 受講者ごとに予算の上限を設定できる。1人が暴走しても他の受講者に影響しない
- 当日、キー単位で消費額をリアルタイムに見られる。誰がどれだけ使ったかが分かる
- 問題のあるキーだけを即座に失効できる
- Web検索 MCP も同じゲートウェイに集約できる(後述)
- 受講者は AWS の認証情報にも仮想キーにも一切触らない(キーは起動時にレジストリへ注入される)
なお、避けたのは「Claude Desktop から Bedrock へ直結する構成」であって、Bedrock そのものではありません。 ゲートウェイの上流は今も Bedrock です。受講者機からは LiteLLM しか見えない、という形に変わっただけです。
プロキシに到達できる相手を絞る(2層のホワイトリスト)
自前でプロキシを立てるということは、インターネットに面した LLM の入口を1つ作るということです。仮想キーが漏れれば誰でも叩けますし、LiteLLM には管理APIも管理UIもあります。ここは2層のホワイトリストで塞ぎました。
1層目:セキュリティグループ(そもそも到達できるか)
ALB の 443/80 に入れるのは、次を合成したものだけです。
| 送信元 | 供給元 |
|---|---|
| 受講者機の出口 | 受講者環境側の NAT Gateway の EIP をタグで自動発見 |
| 運営端末 | 固定の CIDR(イベントごとに見直す) |
| Terraform 実行端末 | apply 時に実行元のグローバルIPを自動取得 |
課題3でプライベートサブネット化した結果、受講者側の送信元がEIP 1つに固定されたので、それを SG で許可するだけで「このワークショップのインスタンスと運営端末以外はプロキシに到達できない」状態が作れます。
ここで1つ工夫が要りました。この NAT 用 EIP は、受講者環境を作り直すたびに新しく払い出されます。 使い捨て環境なので、開催のたびに IP が変わるわけです。IP を tfvars に書く方式にすると当日ごとに手で書き換える作業が発生するので、EIP をタグで引く data source にして、受講者環境を apply した後にゲートウェイ側を apply し直すだけで新しい IP が SG に入るようにしました。
(余談ですが、この「後から apply し直す」を1回飛ばしたことがあります。症状は 403 ではなくタイムアウトでした。SG で弾かれるので TLS ハンドシェイクにすら入りません。今はこの2つの出力値を突き合わせるコマンドをチェックリストの先頭に置いています。)
2層目:ALB のリスナールール(どのパスを使えるか)
| 対象 | アクセス範囲 |
|---|---|
| 運営端末のIP | 全パス(管理UI・管理API・キー発行など) |
| SG を通れる全IP | Cowork の動作に必要な最小セットのみ |
| 上記以外 | 403 Forbidden |
デフォルト 403 のホワイトリストにしているのがポイントです。管理APIを列挙して塞ぐ方式だと、LiteLLM のバージョンアップでエンドポイントが増えたときに穴が開きます。ホワイトリストなら自動的に遮断されますし、使わない OpenAI 互換エンドポイント等も露出しません。
この2層目は、受講者機が乗っ取られた場合の内側の壁でもあります。SG を通れる送信元(=受講者機)からでも、管理APIには届きません。
DB を RDS に分離した理由
当初、LiteLLM の PostgreSQL は EC2 のローカルボリューム上にありました。これには嫌な制約が付いてきます。user_data に影響する変数を1文字変えるだけで EC2 が作り直され、発行済みの仮想キーが全消失するのです。
この制約があると「キーを発行する前に LiteLLM 側の設定を完全に確定させる」という順序を守らなければならず、当日の直前調整がほぼ不可能になります。そこで DB を RDS に分離しました。今は EC2 を作り直しても仮想キーは残ります。
課題5:Web検索をどうやって使わせるか
Cowork には Web 検索の組み込みツールがありますが、上流が Bedrock の構成ではこれが使えません。そのため組み込みの WebSearch は明示的に無効化し、代わりに外付けの検索 MCP サーバーを用意する必要がありました。ハンズオンの題材として調べ物をさせたいので、検索が無いのは致命的です。
当初:スクレイピング方式の OSS MCP サーバー
最初はスクレイピング方式の OSS MCP サーバーを LiteLLM の EC2 に同居させていました。APIキーが要らず、コストもかかりません。
問題は2つありました。1つは安定性で、スクレイピング方式は検索エンジン側の変更で無言で壊れます。もう1つはスケールで、受講者が増えると同一の送信元 IP からのアクセスが集中するため、「別ホストに分離して IP を分散させるべきではないか」という検討課題を抱え続けていました。
採用:Brave Search API の公式 MCP サーバー
結果として、Brave Search API の公式 MCP サーバーに移行しました。おもしろかったのは、この移行によって「分離すべきか」という課題そのものが消えたことです。
分離を検討していた動機は4つ(CPU負荷の波及、障害の巻き込み、個別スケール、送信元IPの分散)ありましたが、公式APIを叩くだけの構成に変わったことで3つが消滅しました。
残った「送信元IPの分散」も、調べたら不要でした。Brave のレート制限は IP 単位ではなくサブスクリプション単位なので、ホストを分けてもキーを分けても上限は上がりません。むしろキーを分散させると、1本が枯渇したときに受講者の N分の1 だけが静かに失敗するという、いちばん気づきにくい壊れ方をします。分けないほうが安全、という結論になりました。
構成上のポイントは、Brave の API キーが受講者側に一切渡らないことです。キーは tfvars → SSM SecureString → 起動時にインスタンスロールで取得 → MCPサーバーのコンテナ、という経路で LiteLLM の EC2 内に閉じており、上流の認証は不要な設定にしています。受講者が提示するのは LiteLLM の仮想キーだけで、そこで予算とレート制限が効きます。
Brave のコストをどう抑えるか
Brave Search API は1リクエスト $0.005 の従量課金で、月間の上限がありません。ここに歯止めが要ります。
LiteLLM で何が使えるかを調べたところ、こうなっていました。
| 探した機能 | 有無 |
|---|---|
| MCP だけの予算(金額で絞る) | 無い |
| MCP の累計呼び出し回数の上限 | 無い |
MCP の毎分の呼び出し回数(mcp_rpm_limit) |
ある |
つまり MCP だけを狙って絞ることはできるが、それは「回数」であって「金額」ではない、というのが出発点でした。MCP のコストはキーの max_budget に LLM の消費と合算されるので、「検索だけに $X」という設計は取れません。
そこで、性質の違う2つを併用する形にしました。
- 予算(総額の天井):設定ファイルに MCP のコスト情報を書くと、検索1回ごとに仮想キーの消費額に加算され、予算超過で 429 が返るようになります。逆に言うと、この設定が無いと MCP のコストは0として扱われ、予算による歯止めが丸ごと消えます。これは実機で確認しました。ただし LLM と合算なので、検索だけを止めることはできません
- 毎分の流量(即時):キー発行時にチームを作り、チーム全体と個別キーの両方に MCP の毎分リクエスト上限を付けています。こちらは MCP 呼び出しにだけ効き、通常の推論には影響しません。ただし毎分の上限なので、総額は制限しません
**「最終的に必ず止まる代わりに反映が遅い予算」と、「即時に効く代わりに総額は見ていない流量制限」**の組み合わせです。どちらか片方では穴が残ります。
なお、チーム側にも予算を設定できるのですが、これも LLM 込みの合算です。検索の歯止めのつもりで小さい値を入れると推論が先に枯渇して全員止まるので、設定していません。
そして、予算チェックには落とし穴があります。計上が非同期なので、$0.01 の予算に対して実測で $0.015 まで通りました。 厳密な上限ではなく「おおむねそこで止まる」程度のものとして扱う必要があります。
課題6:100名規模へのスケール
PoC は3台から始めました。そこから100名規模を視野に入れる過程で、いくつか作りを変えています。
受講者機の AZ 分散(可用性ではなくキャパシティ目的)
受講者用の Windows EC2 を複数の AZ にラウンドロビンで配置するようにしました。
ただしこれは可用性対策ではありません。ゲートウェイも NAT も単一 AZ にあるので、その AZ が落ちれば受講者機がどこにいても使えません。狙いは純粋に、100名規模で同一の第8世代インスタンスタイプを単一AZに一斉要求したときの InsufficientInstanceCapacity を避けることです。
自動フォールバックは実装していません。配置は index で決まるため、容量が無いAZは再applyしても同じく失敗するからです。復旧は「該当受講者だけを別サブネットに逃がす」オーバーライド変数で行います。ここで安易に AZ の並び順を変えると全受講者の割り当てがずれて全台が置換されるので、その罠も明記しています。
guacd の分離(webapp は1台のまま)
Guacamole は RDP 処理と画像エンコードを担う guacd と、Web UI を出す webapp(Tomcat)に分かれます。CPU を食うのは guacd だけで、webapp は中継しかしていません。
そこで guacd だけを AZ ごとに別ノードへ分離し、webapp は1台のまま維持する構成にしました。webapp を1台に保つのが要点です。 Guacamole が PostgreSQL で共有しているのは「認可」だけで、認証トークンとトンネルUUID→guacd接続のマッピングは各JVMのメモリにしかありません。webapp を複数台にすると ALB のスティッキーセッションと共有DBが必須になりますが、guacd だけ分ければどちらも不要です。
guacd の割り当ては接続単位で指定できるので、NLB も要りません。受講者と同じ AZ の guacd を割り当てるのは、障害ドメインを揃えるためです。index の剰余で割り当てると、落ちた guacd が他AZの受講者も担当していて被害が広がります。
サイジングは実機ログで一度見直しています。guacd は接続ごとにコア数ぶんのエンコードスレッドを起こすことが分かったため、「1台×8vCPU(40接続)」よりも「2台×4vCPU(20接続/台)」を第一候補にしています。総vCPUは同じでも、後者のほうがスレッド競合が軽く、1台落ちたときの影響も小さく済みます。
なお t 系インスタンスは変数の検証で拒否しています。CPUクレジットもネットワーク帯域クレジットも、枯渇すると全セッションが一斉に劣化するためです。
課題7:100名分の接続情報を、当日どうやって配るか
意外と最後まで残ったのがこれです。受講者に渡すもの(ログインURL・ユーザー名・パスワード)は、1人ずつ違います。 100名ぶんとなると、配布そのものが運用課題になります。
しかもオンライン開催だと、当日になって「メールが届いていない」「迷惑メールに入っていた」という人が必ず出ます。開始直後の10分をそこで溶かすわけにはいきません。
検討したアプローチ
まずメール一斉送信(SES)を実装しました。これはこれで動きますが、届かなかった人を個別にフォローする手間が残ります。そこで併用できる手段として、受講者が自分で照会できる Web ポータルを用意しました。当日は「照会URL と合言葉」をチャットや口頭で伝えるだけで済みます。
問題は「誰にどのスロット(student-001 など)を割り当てるか」でした。ここで3案を比較しています。
| 案 | 内容 | 採否 |
|---|---|---|
| X. 事前固定 | メールアドレスとスロットの対応を事前に決め打ち | 不採用。申込のキャンセル・追加のたびに作り直しになる |
| Y. 純粋に動的 | 先着順で空きスロットを払い出す | 不採用。参加者でない任意のメールでも枠を取れてしまう |
| Z. 許可リスト + 動的(採用) | 事前に用意するのは許可メール一覧だけ。初回照会時に空きスロットを動的に割り当て、以降は同じものを返す | 採用 |
Z案なら、運営が事前に用意するのは「参加者のメール一覧 CSV」だけです。誰がどのマシンを使うかは決めなくてよく、当日の欠席があっても穴が空くだけで済みます。
設計上のポイント
冪等であること。 同じメールアドレスで何度照会しても同じ接続情報が返ります。受講者が誤ってブラウザを閉じても、もう一度開けば同じ情報にたどり着けます。
割り当てだけは Terraform に持たせない。 スロット在庫と許可リストは Terraform が投入しますが、「誰にどれを割り当てたか」は Lambda が書き込むデータとして分離しています。これを Terraform 管理にしてしまうと、当日の再 apply で割り当てが巻き戻るからです。当日は何が起きるか分からないので、apply でドリフトしない場所に置いておく必要がありました。
閉じ方は3重。 許可リスト(参加者以外を排除)、全員共通のイベントコード(URLだけ知っていても取れない)、時間ウィンドウ(イベント時間外は Lambda が拒否)。加えて、メールの登録有無やコードの当否は一律の汎用メッセージで伏せています。「このメールは登録されていません」と返すと、それ自体が参加者リストの照会窓口になってしまうためです。
管理者画面は二段で守る。 運営用に「どのスロットに誰が割り当たっているか」を見る画面も付けましたが、ここには全受講者のメールと氏名が並びます。WAF による IP 制限(運営の固定IPからのみ到達可能)に加えて、受講者用とは別の管理者コードを要求する構成にしました。パスワードは表示しません。
資料も同じ導線から配る。 ハンズオン資料は受講者機のデスクトップに同期していますが、手元PCにも欲しいという声があったので、照会結果の画面から S3 の署名付きURLでダウンロードできるようにしました。ここはapply 時に URL を作り置きせず、画面を開いた瞬間に発行しています。作り置きすると失効管理ができないためです。Lambda の権限も資料のプレフィックスに限定していて、同じバケットに同居しているセットアップスクリプトやペイロードには手が届きません。
なお、API Gateway は HTTP API ではなく REST API を選んでいます。理由は単純で、WAF の REGIONAL 関連付けが HTTP API では使えないからです。
まとめ
Claude Cowork のハンズオン環境は、「デスクトップアプリを、ブラウザだけで、受講者ごとに分離して、コストを制御しながら配る」という要件を満たすために、以下の組み合わせに落ち着きました。
- Windows EC2(Nested Virtualization 有効)+ Guacamole:Cowork のサンドボックスが動く環境をブラウザから配る
- ALB の Cognito 認証 + ヘッダー認証:1URLでのアクセス集約と、Guacamole 手前での認証
- プライベートサブネット + NAT Gateway:受講者機とゲートウェイをインターネットから到達不能にし、外向きの送信元IPを1つに固定する
- LiteLLM ゲートウェイ + 仮想キー:Bedrock を上流に置いたまま、受講者ごとの予算・レート制御と当日のリアルタイムな可視化を実現する
- SG とリスナールールの2層ホワイトリスト:プロキシに到達できる相手と、使えるパスを両方絞る
- Brave Search MCP をゲートウェイに同居:APIキーを受講者に渡さず、検索コストも仮想キーの予算で抑える
- S3 ペイロード + EventBridge:SSM の 8KB 上限を回避しつつ、接続情報の自動再同期
- guacd の AZ 別分離:webapp を1台に保ったままスケールさせる
- セルフ照会ポータル:100名分の接続情報を、許可リスト + 動的割り当てで配る
構成はすべて Terraform で管理しているので、次回以降の開催は environments を複製して tfvars を差し替えるだけです。
振り返ってみると、判断のかなりの部分が「使い捨て環境である」という前提に支えられていました。ヘッダー認証の信頼モデルも、ポータルの時間ウィンドウも、最終的な安全性は「イベントが終わったら destroy する」ことで担保しています。同じ構成を常設で運用するなら、それぞれ格上げが必要になる箇所です。この前提と格上げ手順をコードと同じ場所に書き残しておくことが、結果的にいちばん効いた工夫だったかもしれません。
環境構築の詳細に興味のある方は、クラスメソッド 人材ソリューション部までお気軽にお問い合わせください。







