🥱

外部IPなしのCloud WorkstationsにCloud Run経由でアクセスする

に公開

kura@ちゅらデータのエンジニア(45)です。
はっぴばーすでーとぅみーー、はっぴばーすでーとぅみーー

四捨五入したら50歳、・・・そんな年齢になりました!

今年の無駄知識はPython aiohttpでWebSocket対応プロキシを実装し、VPNなしで外部IPなしのCloud Workstationsに接続する方法を解説します。

はじめに

Cloud Workstationsは、Google Cloud上で動くマネージドな開発環境です(公式サイト)。Private Endpoint設定を有効にするとVPC内からしかアクセスできなくなり、セキュリティは上がりますが、普通はVPNか踏み台サーバーが必要です。踏み台サーバとかできれば作りたくないですよねー。

この記事では、Cloud Runをプロキシにして、VPNなしで外部IPなしのCloud Workstationsにアクセスする方法を紹介します。

なぜCloud Runプロキシなのか

  • VPNを構築しなくていい
  • 踏み台サーバーの管理が不要

アーキテクチャ概要

Workstations側の構成

Private Clusterに必要なリソース

リソース 説明
Workstation Cluster enable_private_endpoint = true でPrivate化
PSC Endpoint Private Service Connectでクラスターに接続
DNS Zone cloudworkstations.dev のプライベートゾーン
DNS Records クラスター + ワイルドカード (*.cluster-xxx)
Cloud NAT コンテナイメージのプル用

ポイントはワイルドカードDNSレコードです。Workstationのホスト名は dev-workstation.cluster-xxx.cloudworkstations.dev という形式なので、*.cluster-xxx.cloudworkstations.dev をPSCエンドポイントのIPに向けます。

Cloud Workstations Console

認証の課題と解決

問題:認証リダイレクト

単純にHTTPリクエストをプロキシするだけだと、Workstationsの認証ページにリダイレクトされます。

Cloud Run → Workstation → 302 Redirect → Google認証ページ

解決:Workstation API generateAccessToken

Workstation APIの generateAccessToken を使えば、Workstation専用のアクセストークンを取得できます。

async def get_workstation_access_token() -> str:
    """Workstation APIからアクセストークンを取得"""

    # まずGoogle Cloudアクセストークンを取得(メタデータサーバーから)
    gcp_token = await get_gcp_access_token()

    # Workstation API呼び出し
    api_url = (
        f"https://workstations.googleapis.com/v1/projects/{PROJECT_ID}/"
        f"locations/{REGION}/workstationClusters/{CLUSTER_NAME}/"
        f"workstationConfigs/{CONFIG_NAME}/workstations/{WORKSTATION_NAME}:generateAccessToken"
    )

    headers = {
        "Authorization": f"Bearer {gcp_token}",
        "Content-Type": "application/json"
    }

    # 1時間後に期限切れ
    expire_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 3600))
    body = {"expireTime": expire_time}

    async with aiohttp.ClientSession() as session:
        async with session.post(api_url, headers=headers, json=body) as resp:
            if resp.status == 200:
                data = await resp.json()
                return data["accessToken"]

認証フロー全体

IAM権限の設定

Cloud RunのサービスアカウントにWorkstation Userロールを付与します:

resource "google_workstations_workstation_iam_member" "cloud_run_user" {
  role   = "roles/workstations.user"
  member = "serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
}

proxy.py の実装解説

HTTPプロキシ部分

async def handle_request(request):
    """HTTPリクエストプロキシ"""

    # WebSocketアップグレードの確認
    if request.headers.get('Upgrade', '').lower() == 'websocket':
        return await handle_websocket(request)

    # Workstationアクセストークン取得
    token = await get_workstation_access_token()

    # プロキシ先URL
    target_url = f"{WORKSTATION_URL}{request.path}"
    if request.query_string:
        target_url = f"{target_url}?{request.query_string}"

    # ヘッダー準備(Host, Authorization を書き換え)
    headers = {}
    for key, value in request.headers.items():
        if key.lower() not in ('host', 'transfer-encoding', 'content-length', 'authorization'):
            headers[key] = value

    headers['Authorization'] = f"Bearer {token}"
    headers['Host'] = WORKSTATION_HOST

    # リクエスト転送
    async with aiohttp.ClientSession() as session:
        async with session.request(
            method=request.method,
            url=target_url,
            headers=headers,
            data=await request.read(),
            allow_redirects=False
        ) as resp:
            # レスポンス返却(Locationヘッダーは書き換え)
            ...

WebSocketプロキシ部分(重要)

VS Code IDEはWebSocketを使うので、WebSocket対応は必須です。

async def handle_websocket(request):
    """WebSocketプロキシ"""

    ws_server = web.WebSocketResponse()
    await ws_server.prepare(request)

    token = await get_workstation_access_token()

    #  重要:Originヘッダーの設定 
    headers = {
        "Authorization": f"Bearer {token}",
        "Host": WORKSTATION_HOST,
        # Originを正しいWorkstationホストに設定(これがないと503エラー)
        "Origin": f"https://{WORKSTATION_HOST}",
    }

    # Cookie, User-Agent, Sec-WebSocket-Protocol を転送
    if 'Cookie' in request.headers:
        headers['Cookie'] = request.headers['Cookie']
    for h in ['User-Agent', 'Sec-WebSocket-Protocol']:
        if h in request.headers:
            headers[h] = request.headers[h]

    async with aiohttp.ClientSession() as session:
        async with session.ws_connect(
            f"wss://{WORKSTATION_HOST}{request.path}",
            headers=headers,
            heartbeat=30
        ) as ws_client:

            # 双方向プロキシ
            async def forward_to_client():
                async for msg in ws_client:
                    if msg.type == WSMsgType.TEXT:
                        await ws_server.send_str(msg.data)
                    elif msg.type == WSMsgType.BINARY:
                        await ws_server.send_bytes(msg.data)

            async def forward_to_server():
                async for msg in ws_server:
                    if msg.type == WSMsgType.TEXT:
                        await ws_client.send_str(msg.data)
                    elif msg.type == WSMsgType.BINARY:
                        await ws_client.send_bytes(msg.data)

            # 両方向を並行実行(どちらかが終了したら終了)
            done, pending = await asyncio.wait(
                [
                    asyncio.create_task(forward_to_client()),
                    asyncio.create_task(forward_to_server())
                ],
                return_when=asyncio.FIRST_COMPLETED
            )

            for task in pending:
                task.cancel()

ハマったポイント

1. WebSocket 503 エラー

問題: HTTPは動くがWebSocketで 503 Invalid response status エラー

原因: Origin ヘッダーが Cloud Run の URL になっていた

解決: Origin を明示的に Workstation ホストに設定

# NG: 元のリクエストのOriginをそのまま使う
# headers['Origin'] = request.headers.get('Origin')

# OK: Workstationホストを明示的に設定
headers['Origin'] = f"https://{WORKSTATION_HOST}"

2. DNS解決エラー

問題: Cloud Run から Workstation ホスト名が解決できない

原因: Private Clusterのホスト名はパブリックDNSに登録されておらず、PSCエンドポイント経由でGatewayにアクセスする必要がある(参考: Cloud Workstations architecture

解決: ワイルドカードDNSレコードを作成

各Workstationのホスト名(alice.cluster-xxx..., bob.cluster-xxx...等)は異なるが、全て同じPSCエンドポイントに向ける必要がある。Gatewayがホスト名を見て適切なVMにルーティングする。

resource "google_dns_record_set" "workstation_wildcard" {
  name    = "*.${cluster_hostname}."
  type    = "A"
  rrdatas = [psc_endpoint_ip]
}

3. 認証リダイレクトループ

問題: プロキシ経由でアクセスすると認証ページにリダイレクトされ続ける

原因: Workstation 独自の認証トークンが必要

解決: generateAccessToken API を使用

デプロイと接続方法

Cloud Run デプロイ

# deploy.sh
gcloud run deploy workstation-proxy \
  --source . \
  --region asia-northeast1 \
  --platform managed \
  --no-allow-unauthenticated \
  --timeout 3600 \
  --network "${NETWORK}" \
  --subnet "${SUBNET}" \
  --vpc-egress private-ranges-only \
  --set-env-vars "WORKSTATION_HOST=${WORKSTATION_HOST},PROJECT_ID=${PROJECT_ID},..."

ポイント:

  • --no-allow-unauthenticated: IAM認証を必須にする
  • --timeout 3600: タイムアウトを最大の60分に設定
  • --vpc-egress private-ranges-only: プライベートIP範囲への通信のみVPC経由

Cloud Run Console

接続方法

# access.sh
gcloud run services proxy workstation-proxy \
  --region asia-northeast1 \
  --port 8080

# ブラウザで http://localhost:8080 にアクセス

gcloud run services proxy はIAM認証を自動で処理して、ローカルの8080ポートをCloud Runに接続します。

VS Code IDE

60分制限について

Cloud Runの最大タイムアウトは60分です。ただし gcloud run services proxy は切断されると自動で再接続するので対応は不要です。

VS CodeもWebSocket切断時に自動再接続するので、長時間の開発作業でも問題ありません。

コスト比較:踏み台サーバー vs Cloud Run

東京リージョン(asia-northeast1)で、踏み台サーバー(Compute Engine)とCloud Runプロキシのコストを比較します。

前提条件

  • 利用シナリオ: 1日8時間 × 月20日 = 160時間/月の開発作業
  • Cloud Run設定: 1 vCPU、512 MiBメモリ、最小インスタンス数0

料金比較表

項目 踏み台サーバー (e2-micro) Cloud Run プロキシ
月額固定費 約 $7〜9 USD $0(最小インスタンス0の場合)
CPU料金 含む $0.000024/vCPU秒
メモリ料金 含む $0.0000025/GiB秒
リクエスト料金 なし $0.40/100万リクエスト

月額コスト試算(東京リージョン)

踏み台サーバー(e2-micro 24時間稼働)

e2-micro: 約 $0.0084/時間 × 730時間 ≈ $6.13/月
ディスク (10GB): 約 $0.40/月
合計: 約 $6.50〜7.00/月(常時稼働)

Cloud Run プロキシ(利用時のみ課金)

想定: 1日8時間 × 20日 = 160時間/月の開発
実際のCloud Run稼働: 接続中のみ(WebSocket維持)

CPU: $0.000024 × 160時間 × 3600秒 × 1vCPU ≈ $13.82/月
メモリ: $0.0000025 × 160時間 × 3600秒 × 0.5GiB ≈ $0.72/月
リクエスト: 無料枠内(200万リクエスト/月)

合計: 約 $14.54/月

損益分岐点

試算から、損益分岐点は約77時間/月です:

踏み台サーバー: $7.00/月(固定)
Cloud Run: $0.091/時間($14.54 ÷ 160時間)

$7.00 ÷ $0.091 ≈ 77時間

つまり

  • 月77時間未満: Cloud Runが安い
  • 月77時間以上: 踏み台サーバーが安い
  • 毎日8時間×20日(160時間): 踏み台サーバーが約半額

結論

利用パターン おすすめ 理由
毎日長時間使用(160h/月) 踏み台サーバー コストが約半分($7 vs $14.5)
週数回の利用(〜40h/月) Cloud Run 使わない時は$0
運用負荷削減 Cloud Run マネージドサービス

毎日フルタイムで使うなら、Cloud Runにコストメリットはありません。Cloud Runを選ぶ理由は「運用の手間削減」です。

Cloud Runのメリット

  • 踏み台サーバーのパッチ適用・監視が不要
  • 使わない月はほぼ$0(Workstations関連の料金は除く)
  • インフラ管理から解放される

まとめ

Cloud Runの柔軟性とPython aiohttpの非同期処理を組み合わせてプロキシサーバーを構築できました。

補足:Compute Engine無料枠とグローバルVPC

Compute Engineの無料枠(e2-micro)は米国リージョンのみが対象です。
ただしGoogle CloudのVPCはグローバルなリソースなので、リージョンをまたいだ構成が可能です

VPCは1つのVPC内に異なるリージョンのサブネットを持てます。これにより

  • 踏み台は無料枠の米国リージョンに配置
  • Workstationsは東京リージョンに配置
  • 同一VPC内でセキュアに通信

北米踏み台のコスト

コンピュート料金は無料枠でカバーされ、かかるのはリージョン間通信料のみ

項目 料金
e2-micro(us-central1等) $0(無料枠)
リージョン間通信(北米→東京) $0.08/GB

月10GBの通信量を想定すると、月額約$0.80で踏み台サーバーを運用できます。

注意: 踏み台→Workstations間のレイテンシは増加します。VS Codeは多少のレイテンシに耐えますが、快適さを優先するなら東京リージョンがおすすめです。

料金参考リンク

Discussion