外部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に向けます。

認証の課題と解決
問題:認証リダイレクト
単純に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経由

接続方法
# access.sh
gcloud run services proxy workstation-proxy \
--region asia-northeast1 \
--port 8080
# ブラウザで http://localhost:8080 にアクセス
gcloud run services proxy はIAM認証を自動で処理して、ローカルの8080ポートをCloud Runに接続します。

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