2023年くらいからの Next.js(App Router)の流行りもあり、昨今当たり前のように使われるようになった RSC。ユースケースによりますが、セキュリティや可用性向上のため、RSC は BFF のレイヤーとしてAPI サーバーは別に分離する構成を取るのは割と多いんじゃないでしょうか。
Next.js v16 では Vercel Data Cache への依存が強まり、ある程度ちゃんとNext.jsの機能を使うならVercel に乗るか OpenNext に頼るかの二択になりつつあります。Cloudflare Workers にデプロイする場合は OpenNext for Cloudflare を使うことになりますが、ここで問題になるのが Workers 上の RSC からバックエンドの Cloud Run にどうやってセキュアにアクセスするか です。
Cloud Run はInternal Onlyに設定してパブリック URL からのアクセスを遮断したい。でも Workers はあくまで Cloudflare のエッジで動いているので、GCP の VPC 内部からのアクセスにはなりません。外部 LB を立てれば簡単ですが、もう少しセキュアにできないか探ってみました。(アプリレイヤーで認証入れるとかではなく)
よくある「GCE に cloudflared を入れて Tunnel 経由で繋ぐ」パターンは、GCE の面倒を見る必要があるので今回は省きます。代わりに Cloud Run Worker Pool(BETA)で cloudflared を動かし、Cloudflare Tunnel 経由でアクセスする構成を試しています。
この記事では、Workers → Cloud Run のアクセスパターンをいくつか検討してみます。
全体像
先に構成の全体像を見せます。

ブラウザからの Client Component のリクエストも、Server Component のリクエストも、どちらも Cloudflare Workers 上で Service Token を付与してバックエンドに送ります。
Workers → Cloud Run アクセスパターン
Workers から INTERNAL_ONLY の Cloud Run にアクセスする方法をいくつか検討しました。
1: Workers VPC + Tunnel
Cloudflare Workers VPC を使えば、Workers から直接 VPC 経由で cloudflared に接続できるのでは?と考えました。
しかし Workers VPC は Tunnel 接続に QUIC(UDP)プロトコルを必須 としています。
Workers VPC also requires Cloudflare Tunnel to connect using the QUIC transport protocol using auto or quic. Ensure outbound UDP traffic on port 7844 is allowed through your firewall for QUIC connections. — Workers VPC - Tunnel Configuration
Cloud Run Worker Pool では UDP のアウトバウンドに制約があり、cloudflared を QUIC で動かすことができません。実際、cloudflared のデフォルト(QUIC / auto)だと no recent network activity エラーが出るため、明示的に --protocol http2 を指定しています。
つまり Workers VPC は QUIC 必須なのにCloud Run Worker Pool は HTTP/2 しか通せない。これでは繋がりません。
2: mTLS(Managed Client Certificate)
次に考えたのが、Cloudflare の Managed Client Certificateを使った mTLS です。Workers の mTLS バインディングでクライアント証明書を送り、バックエンドを認証する方式です。
# wrangler.toml [[mtls_certificates]] binding = "API_CLIENT_CERT" certificate_id = "xxxxxxxx-xxxx-..."
// Workers から mTLS 付き fetch const res = await fetch("https://api.example.com/endpoint", { cf: { clientCertificate: env.API_CLIENT_CERT }, });
しかしこれも使えませんでした。
Currently, mTLS for Workers cannot be used for requests made to a service that is a proxied zone on Cloudflare. If your Worker presents a client certificate to a service proxied by Cloudflare, Cloudflare will return a 520 error. — mTLS - Cloudflare Workers
Tunnel 経由のホスト名は DNS レコードが proxied = trueなので、Cloudflare Edge で SSL が終端されます。Workers → Cloudflare Edge 間でクライアント証明書を送っても、Edge で終端されてしまうので Tunnel の先にある Cloud Run には届きません。
3: Zero Trust Service Token
結局、一番シンプルな方法に落ち着きました。Cloudflare Access のService Token使ってリクエストを認証する方式です。ただ自前でローテだったりしないといけないので微妙だなーと思っています。
Service Token はマシン間通信用の認証トークンで、Dashboard から発行すると Client ID と Client Secret のペアが手に入ります。HTTP ヘッダーに付与するだけで認証できます。
CF-Access-Client-Id: <Client ID>CF-Access-Client-Secret: <Client Secret>
Workers の Secretsにトークンを登録しておけば、コード内で自由に使えます。
- Server Component →
server-onlyを使って直にAPIサーバーを叩く - Client Component →
/api/proxyAPI Route 経由でAPIサーバーにアクセス
どちらのケースでもWorkersがAPIサーバーのクライアントとなるようにします。
実装

Client Component 用のプロキシ
ProxyではRFC9110該当ヘッダー等を削除しないといけませんが、HonoのProxy Helperを使うと良いかなと思います。
import { Hono } from "hono"; import { proxy } from "hono/proxy"; import { handle } from "hono/vercel"; const app = new Hono().basePath("/api/proxy"); app.all("/*", async (c) => { // .... const res = await proxy(targetUrl, { ...c.req, headers: { ...forwardHeaders, ...cfAccessHeaders, // Service Token host: undefined, }, }); // ... return res; }); const handler = handle(app); export const GET = handler; export const POST = handler; export const PUT = handler; export const DELETE = handler; export const PATCH = handler;
Cloud Run Worker Pool で cloudflared を動かす
Tunnel のコネクタである cloudflared を常駐させるのに Cloud Run Worker Pool(BETA)を使っています。
Worker Pool は HTTP リクエストを受けずに常駐するプロセスを動かすためのリソースです。cloudflared は Cloudflare Edge に対して常時 HTTP/2 接続を維持するデーモンなので、リクエスト駆動型の Service だとインスタンスが落ちてしまいます。Worker Pool なら MANUAL scaling で 1 インスタンスを固定起動できます。
cloudflared → Cloud Run API 間は Private Google Access で内部ルーティングしています。Private DNS Zone で *.a.run.app を private.googleapis.com の VIP に向けることで、パブリックインターネットを経由しません。
↓と同じです。参考になります。
Cloudflare Tunnel で内部公開の Cloud Run にアクセスしてみる | CyberAgent Developers Blog
Cloud Run Serviceの負荷分散
cloudflared から Private Google Access 経由で Cloud Run API にアクセスする場合、Private DNS Zone で解決される 199.36.153.8/30 は Google Front Endの Anycastアドレスです。DNS ラウンドロビンではなく、BGP で最寄りの Google フロントエンドに到達し、そこから Cloud Run のオートスケール中のインスタンスに分散します。
つまり、Private Google Access 経由でも Cloud Run のオートスケール+ビルトイン LB はそのまま効きます。Public URL 経由と負荷分散の動作に差はありません。
cloudflared Worker Poolの負荷分散
Worker Pool は MANUAL scaling のみで、ビルトインのオートスケールはありません。HTTP エンドポイントを持たないのでリクエストベースのスケールトリガーがそもそも存在しないためです。
manual_instance_count を増やせば複数インスタンスを起動できます。cloudflared は同じ Tunnel Token で最大 25 レプリカまで動かせます。
By design, replicas do not offer any level of traffic steering (random, hash, or round-robin). — Tunnel availability and failover
Cloudflare は地理的に近いレプリカにリクエストをルーティングし、1 台が落ちたら別のレプリカにフェイルオーバーします。Cloud Run Worker Pool は同一リージョンなので、実質どれかに振られるようになります。(多分) 実運用ではオートスケールが必要なワークロードの場合は CREMA(Cloud Run External Metrics Autoscaler) を使って外部メトリクスベースでスケールさせることもできるぽいです。
まとめ
ここまで書きましたが、結局Service Tokenなら経路とか可用性考えても外部LBで良いかなと思いました。