0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloud Run でステートフルな MCP サーバーを安全に公開する(n8n × Playwright MCP の例)

0
Posted at

TL;DR

  • n8n から Google ログインが必要なページを Playwright MCP 経由で操作したかった
  • サイドカー方式は手軽だが、認証とチーム分離の観点で穴がある
  • ingress: internal + IAM(roles/run.invoker)+ ID トークンによるサービス間認証 + Go 製 auth-proxy + Secret Manager で多層防御を組んだ
  • ステートフルな MCP は maxScale=1 でスケールアウトを止め、セッションが別インスタンスへ飛ぶのを防ぐ

想定読者

  • Cloud Run 上で MCP サーバーを動かしたい人
  • Playwright MCP で Google ログインが必要なページを自動操作したい人
  • n8n を複数チームで共有しており、チームごとに別アカウントでログインが必要なページを Playwright MCP で扱いたい人
  • Cloud Run のサービス間認証(ID トークン + IAM)を実戦的に組みたい人

背景

n8n のワークフローから Looker Studio などの Google ログインが必要なページ を操作・キャプチャしたい、というのが出発点でした。Playwright MCP を使えば実現できそうだったので試したものの、運用に乗せようとすると次の課題に突き当たりました。

  • n8n を複数チームで共有しているため、チームごとに別アカウントのログイン状態を使い分けたい
  • そもそも Playwright MCP に「誰でも叩ける」状態のエンドポイントを生やしたくない

問題:サイドカー構成の穴

最初に思いつくのは、n8n と同じ Cloud Run インスタンスに playwright-mcp をサイドカーとして同居させる構成です。手軽ですが、上記の課題に対しては穴があります。

[Before: 危険な構成]
┌─ Cloud Run instance ─────────────────────────┐
│  n8n (port 5678)                             │
│        │ localhost:3000 (認証なしで叩ける) │
│        ▼                                     │
│  playwright-mcp (port 3000)                  │
│        ※ Google ログイン済みセッション持ち  │
└──────────────────────────────────────────────┘

サイドカーなので playwright-mcp は外部からは見えません。ただし同一インスタンス内の n8n からは localhost:3000 を認証なしで叩けてしまいます。

playwright-mcp は Google ログイン済みの状態(Storage State)を抱えているため、

  • n8n のワークフローを作れる人は全員、共通のログイン情報を使い回せてしまう
  • チームごとに別アカウントを使い分けたいユースケースに応えられない

という結果になります。サイドカーを外して別 Cloud Run サービスに切り出せば疎結合にはなりますが、ただ切り出すだけでは「インターネット側か VPC 側のどちらに公開するのか」「どう認証するのか」が宙に浮きます。

解決アーキテクチャ

最終的に以下の構成を採用しました。

[After: 多層防御構成]

n8n (Cloud Run, ingress=internal)
 │  Mcp-Auth-Key: <チーム別APIキー>
 │  Path: /playwright-mcp-team-a/...
 │
 │  ※ n8n は vpc-access-egress=all-traffic で
 │    内部 Cloud Run へ VPC 経由でルーティングされる
 ▼
auth-proxy (Cloud Run, ingress=internal)
 │  - Mcp-Auth-Key を検証
 │  - URL の第1セグメントでバックエンドを選択
 │  - 自サービスアカウントの ID トークンを付与して転送
 ▼
playwright-mcp-team-a (Cloud Run, ingress=internal, maxScale=1)
   - IAM: roles/run.invoker は auth-proxy の SA のみに付与
   - 受信時に ID トークンを Cloud Run 側が検証
   - Storage State を Secret Manager 経由でマウント

防御層は次の4段です。これらは 直列に重なる関門 であり、どれか1つでも抜けると残りの層の意味が薄れます。

役割
ネットワーク ingress: internal で外部からの直接アクセスを遮断
IAM roles/run.invoker を auth-proxy の SA だけに付与し、他のプリンシパルを排除
サービス間認証 auth-proxy が Google 署名付き ID トークンを Authorization ヘッダで提示する
アプリケーション auth-proxy が Mcp-Auth-Key を検証してチームごとのバックエンドへ振り分ける

ID トークンと IAM がどう噛み合っているかは 後述のサービス間認証セクション で詳しく整理します。

Google ログイン状態の永続化:storage-state

Playwright には --storage-state オプションがあり、ログイン済みのセッション情報(Cookie や localStorage)をファイルとして保存・再利用できます。これを Secret Manager に保存して Cloud Run のボリュームとしてマウントすると、コールドスタート後もログイン状態を保てます。

volumes:
  - name: playwright-storage-state
    secret:
      secretName: PLAYWRIGHT_STORAGE_STATE
      items:
        - key: "1"
          path: storage-state.json
containers:
  - name: playwright-mcp
    args:
      - "--storage-state=/etc/playwright/storage-state.json"
    volumeMounts:
      - name: playwright-storage-state
        mountPath: /etc/playwright
        readOnly: true

ログイン状態を更新したいときは、別の端末でログインし直して新しい storage-state.json を Secret Manager の新バージョンとして登録するだけです。サービスを再起動すれば反映されます。

mcp-auth-proxy の実装(Go)

認証とリバースプロキシを担う軽量サービスを Go で実装しました。リバースプロキシの土台は標準ライブラリの net/http/httputil.ReverseProxy で済み、ID トークン取得のために google.golang.org/api/idtoken だけを追加で使っています。

バックエンド一覧

新しいチームを追加するときに触るのはここだけです。

var routeSpecs = []struct {
    PathID        string
    APIKeyEnv     string
    BackendURLEnv string
}{
    {
        PathID:        "playwright-mcp-team-a",
        APIKeyEnv:     "TEAM_A_PLAYWRIGHT_MCP_KEY",
        BackendURLEnv: "TEAM_A_PLAYWRIGHT_MCP_URL",
    },
    // チーム B を追加するならここに 1 要素足す
}

認証

Mcp-Auth-Key ヘッダの値と環境変数のキーを比較するだけのシンプルな仕組みです。subtle.ConstantTimeCompare でタイミング攻撃を避けています。

const authHeader = "Mcp-Auth-Key"

func authenticate(r *http.Request, routes map[string]*route) (string, *route, bool) {
    providedKey := r.Header.Get(authHeader)
    if providedKey == "" {
        return "", nil, false
    }

    // URL の第1セグメントでバックエンドを決定
    routeID := strings.TrimPrefix(r.URL.Path, "/")
    if i := strings.IndexByte(routeID, '/'); i > 0 {
        routeID = routeID[:i]
    }

    matched, found := routes[routeID]
    if !found {
        return routeID, nil, false
    }

    if subtle.ConstantTimeCompare([]byte(matched.apiKey), []byte(providedKey)) != 1 {
        return routeID, nil, false
    }

    return routeID, matched, true
}

Cloud Run のサービス間認証のしくみ

auth-proxy からバックエンドの Cloud Run を呼び出すときは、Cloud Run のサービス間認証を使います。これは「呼び出し元が何者かを Google が署名した ID トークンで証明し、それを受け取った Cloud Run 側が IAM ポリシーと突き合わせて入場可否を判定する」という二段階のしくみです。

[auth-proxy SA] ── Authorization: Bearer <ID token (aud=backend URL)> ──▶ [Cloud Run frontend]
                                                                            ① ID トークン検証
                                                                            ② 発行元プリンシパルが
                                                                               roles/run.invoker を
                                                                               持つか IAM でチェック
                                                                            ③ OK → コンテナにルーティング
                                                                               NG → 403 を返す

ここで誤解しやすいのが、「ID トークンを送れば呼べる」のではないという点です。判定の本体は IAM 側にあります。ID トークンは「呼んでいるのが誰か」を証明する身分証で、「その身分の人を通すか」は roles/run.invoker の付与状況で決まります。

roles/run.invoker の付与先 挙動
allUsers ID トークンなしでも誰でも呼べる(インターネットへの公開状態)
特定のサービスアカウント その SA が発行した ID トークンでのみ呼べる
付与なし 誰も呼べない

今回は auth-proxy のサービスアカウントだけに roles/run.invoker を付与 しています。ingress: internal で外部からの直接アクセスを遮断したうえで、VPC 内の他サービスからの直叩きも IAM で弾く形です。

allUsers を付けると何が起きるか

ありがちなアンチパターンが「動かないのでとりあえず allUsers に invoker を付ける」です。これをやると、

  • ingress: internal を残していても、VPC 内のあらゆるリソースから ID トークンなしで叩ける
  • ingress: all にしていれば、インターネット上の誰でも ID トークンなしで叩ける

つまり playwright-mcp が事実上の野良 API になります。Storage State にログイン済みの Google 認証情報が載っている都合上、被害は情報漏洩では済まず、該当アカウントの権限で操作可能なリソース全てにまで広がりかねません。roles/run.invoker の付与先は実装中も常に確認するくらいで丁度いいです。

ID トークンの取得元と更新

公式ドキュメントには複数の取得経路が載っていますが、Cloud Run 上のサービスから別の Cloud Run を呼ぶ場合、取り出し元は実質メタデータサーバー1つに集約されます

  • メタデータサーバー (http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=...) に問い合わせると、そのインスタンスにバインドされたサービスアカウントの ID トークンを返してくれる
  • トークンの寿命は約 1 時間。期限切れ前に取り直す必要がある
  • Google 提供の認証ライブラリ(Go なら google.golang.org/api/idtoken)は内部で同じメタデータサーバーを叩いており、取得・キャッシュ・更新を全部面倒見てくれる

公式ドキュメントに載っている他の選択肢(Workload Identity 連携ダウンロードしたサービスアカウントキー)は、いずれも「Google Cloud の外」から Cloud Run を呼ぶための仕組みです。Cloud Run 上で動かす今回のケースでは、メタデータサーバーがそのまま使えるので採用する理由がありません。特に SA キーをファイルとして配るのは、鍵の保管・ローテーションという別の運用課題を呼び込むのでなおさら避けたいところです。

実装上の選択肢としては「メタデータサーバーを自前で叩く」か「認証ライブラリに任せる」の二択ですが、前者を選ぶ理由はあまりありません。トークン期限の管理・並行リクエスト時のキャッシュ整合性まで含めて、ライブラリに寄せたほうが事故が少ないです。

呼び出す側のコード

呼び出し側は idtoken.NewClientaudience(受信側サービスのオリジン URL) を渡してクライアントを作ります。audience には呼び出し先 Cloud Run の https://<service>.run.app を指定します。これは ID トークンの aud クレームに入る値で、受信側の Cloud Run が「このトークンは自分宛か」を判定するために使います。

client, _ := idtoken.NewClient(ctx, audience) // audience = "https://<backend>.run.app"
prefix := "/" + spec.PathID                    // 例: "/playwright-mcp-team-a"

proxy := &httputil.ReverseProxy{
    Director: func(r *http.Request) {
        r.URL.Scheme = backendURL.Scheme
        r.URL.Host = backendURL.Host
        r.Host = backendURL.Host
        r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
        r.Header.Del(authHeader) // バックエンドへ API キーを転送しない
    },
    Transport: client.Transport, // ID トークンを自動付与・自動更新
}

client.TransportReverseProxy.Transport に渡しているのがポイントです。これだけで、auth-proxy が中継するすべてのリクエストに対し、メタデータサーバーから取得した ID トークンが自動で付与・更新されます。ReverseProxy は SSE のような長時間ストリーミング応答もそのまま流せるため、Streamable HTTP な MCP との相性も悪くありません。

ステートフルゆえの注意点:maxScale=1

ここまでで認証は塞ぎましたが、Playwright MCP には そもそもスケールアウトできないという運用上の制約があります。

なぜ Streamable HTTP はセッションを持つのか

MCP の通信路として現行仕様で推奨されているのが Streamable HTTP トランスポートです。これがなぜステートフルなのかを腹落ちさせるには、「普通の POST との違い」と「MCP がそもそも何をやり取りしているか」の 2 つを押さえる必要があります。

普通の POST と SSE の違い

ざっくり言うと、

  • 普通の HTTP POST は「手紙のやりとり」。クライアントが 1 通送り、サーバーが返事を 1 通だけ書いて送って終わり。
  • SSE(Server-Sent Events) は「電話の通話」。1 回つないだら、サーバー側がしゃべりたいことを 何回でも・好きなタイミングで 流せる。回線は切らない。

たとえば Playwright MCP に「このページのスクリーンショット撮って」とお願いするケースを考えます。中の処理は「ページに遷移 → ロード待ち → スクロール → 撮影 → エンコード」と、それなりに時間がかかります。

普通の POST だと、クライアントから見えるのは次のような流れです。

クライアント ──"撮って"──▶ サーバー
(10 秒経過。何も起きない)
クライアント ◀──"はい撮れました(画像データ)"── サーバー

ボディ全体が完成するまでクライアントには何も届きません。そのあいだ「死んでるのか動いてるのか」も分からないので、長時間ジョブには向きません。

SSE だと同じ処理が次のように見えます。

クライアント ──"撮って"──▶ サーバー
クライアント ◀──"ページに遷移したよ"── サーバー    (接続は開いたまま)
クライアント ◀──"ロード待ちしてる"── サーバー
クライアント ◀──"スクロールした"── サーバー
クライアント ◀──"はい撮れました(画像データ)"── サーバー
(ここでサーバーが close)

実際のレスポンスボディは Content-Type: text/event-stream で、こんなテキストが少しずつ追記されます。

data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":30}}

data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":70}}

data: {"jsonrpc":"2.0","id":1,"result":{"image":"..."}}

data: 行と空行 1 つがメッセージ 1 件の区切りです。クライアントはレスポンスボディを 読みながら順次 各メッセージを処理できます。

MCP は「短い応答で終わる場合は普通の application/json で返してもよい」「複数メッセージを返したい場合は SSE で返してもよい」と仕様で定めていて、サーバーが状況に応じて切り替えます。

セッションは何のためにあるのか

ここまでが「1 リクエスト分」の話ですが、MCP のクライアント・サーバー間にはもう 1 段上の概念として セッション があります。理由は MCP 自体がステートフルなプロトコルだからです。具体的には:

  • 接続するとまず initialize を投げ、お互いの能力(capabilities)をネゴシエート する。「このサーバーはどんなツールを持っているか」「どんな通知に対応しているか」をここで決め、以降ずっと前提にする
  • リソースの subscribe 状態(「このファイルが変わったら通知して」みたいなもの)もサーバーが覚えている

これらの状態を「どのクライアントの分か」と紐づけるための ID が Mcp-Session-Id ヘッダ です。サーバーが initialize 応答時に発行して、クライアントは以降のすべてのリクエストに同じ値を載せます。クッキーを HTTP ヘッダに置き換えたものと考えるとイメージしやすいです。

Playwright MCP の場合は何が乗っているか

Playwright MCP のセッションには、上記の MCP プロトコル状態に加えて 生きた Chromium プロセス + 開いているページ + Cookie + 実行中の操作 が紐づきます。これらは特定の Cloud Run インスタンスのメモリと OS リソースに張り付いている 生きたプロセス状態 なので、別インスタンスへ移送するのは現実的ではありません。

つまり「セッション ID をどこかに保存しておけば別インスタンスでも続きから処理できる」という性質のものではない、というのがポイントです。

Cloud Run のスケールとの相性

Cloud Run はリクエスト数に応じてインスタンスを増やします。Streamable HTTP のクライアントが 2 回目以降に投げるリクエストが別インスタンスへ飛ぶと、当然そちらにはセッションが存在せず、Session not found で落ちます。

最も確実な対策は インスタンスを増やさないことです。

metadata:
  annotations:
    autoscaling.knative.dev/maxScale: "1"
    autoscaling.knative.dev/minScale: "0" # 使われていない時はゼロまで畳む

社内のバッチ用途や少人数の対話用途であれば、1 インスタンスで十分なことがほとんどです。minScale=0 のままにしておけばコストはコールドスタート時のリクエストぶんで済みます。

なお auth-proxy 自体はステートレスなのでスケールアウト可能ですが、今回は呼び出し元が n8n 1 サービスに限られていてトラフィックも軽いため、合わせて maxScale=1 にしています。

「セッションアフィニティではダメなのか?」

ここで「インスタンスを固定するのではなく、同じクライアントを同じインスタンスに寄せればいいのでは?」と思うかもしれません。実際 Cloud Run にはセッションアフィニティがあり、それらしく使えそうに見えます。しかし Playwright MCP には効きません。理由は2つです。

  1. アフィニティはベストエフォートで、インスタンスを生かし続けない。公式ドキュメントは「リクエスト間で永続化が必要かつ簡単に再構築できないサーバー側セッションデータの保存には使うな」と明記しています。スケールイン・最大同時実行数・CPU 上限のいずれかでアフィニティは切れ、その瞬間に生きた Chromium プロセスごと失われます。「再構築できない状態」を抱える今回のセッションは、公式が名指しで非推奨としているユースケースそのものです。
  2. 識別の経路が噛み合わない。Cloud Run のアフィニティは GCLB が発行する独自 Cookie で識別しますが、MCP のセッション識別子は Mcp-Session-Id ヘッダです。両者は無関係で、MCP クライアントがその Cookie を保持・送り返す保証もありません。

そもそもスケールアウトに耐えるための定石は「状態を Redis などの外部ストアに逃がして、インスタンス自体はステートレスにする」ことです。MCP のプロトコル状態(capabilities や subscribe 状態)ならこれで外出しできますが、生きた Chromium プロセス + 開いているページ + 実行中の操作はシリアライズして外部に逃がせる類のものではありません。アフィニティは「壊れても状態を再構築できる」アプリ向けの最適化であって、生きたプロセスそのものがセッションの実体である Playwright MCP では対処にならず最適化にしかなりません

結局、状態を外出しできない以上、確実な手はインスタンスを増やさないことだけです。スケールが必要になったら「1 サービスを増やす」のではなく「チーム単位でサービスを増やす」軸で広げます。

まとめ

課題 解決策
サイドカーは n8n から素通しで叩ける auth-proxy 経由に変えて Mcp-Auth-Key で認証
インターネットからの直接到達 ingress: internal で外部到達点を消す
VPC 内の他サービスからの直叩き roles/run.invoker を auth-proxy SA だけに付与
サービス間通信の身分証明 idtoken.NewClient で ID トークンを自動付与・自動更新
チーム間のセッション分離 URL の第1セグメントとチーム別 API キーでルーティング
Google ログイン状態の維持 Storage State を Secret Manager 経由でマウント
ステートフルでスケール不可 maxScale=1 でインスタンスを固定(スケールはチーム単位で)

ネットワーク・IAM・サービス間認証・アプリケーションの4層で塞ぐと、それぞれの層が単独では成り立たない攻撃面がまとめて消えます。今回の構成は Playwright MCP に限らず、ステートフルな MCP サーバーを Cloud Run 上で安全に公開する汎用パターンとして使えるはずです。MCP サーバーを社内向けに公開したいが認証をどう組むか悩んでいるチームの参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?