
Summary
この文書の要点
- 2026年7月26日の実測では、LBが消費するGLOBAL_EXTERNAL_PROXY_LB_BACKEND_SERVICESは75枠中75枠まで枯渇し、未参照の10件を削除して65枠まで戻しました。
- quotaはプロジェクト単位なので、ロードバランサを新設しても上限は増えません。サービスごとの直接接続をやめ、静的配信はgateway、APIはfacadeへ入口を集約します。
- facadeはパスを書き換えて内部サービスへ転送するリバースプロキシです。画面向けにAPIを集約・整形するBFFとは責務が異なります。
- 集約は単一障害点、追加レイテンシ、エラー処理の不統一を生みます。ルートを増やす前にp95・p99、タイムアウト、最大インスタンス数、静的配信の404挙動を実測します。
サービス分割より先に、LBのbackend serviceが上限へ達する
2026年7月26日に同一のクラウドプロジェクトで確認した実測値は、GLOBAL_EXTERNAL_PROXY_LB_BACKEND_SERVICESが65/75、BACKEND_BUCKETSが9/9、SSL_CERTIFICATESが24/30、NETWORK_ENDPOINT_GROUPSが96/1000、Cloud Runサービスが155件でした。作業開始時、GLOBAL_EXTERNAL_PROXY_LB_BACKEND_SERVICESは75/75まで枯渇しており、URLマップから参照されていないbackend serviceを10件削除して65まで下げています。
ここで注意したいのは、quotaがロードバランサ単位ではなくプロジェクト単位だという点です。別のロードバランサを作っても、75という上限は共有されたままです。また、似た名前のBACKEND_SERVICESは0/75と表示されていましたが、この構成で実際に消費していたのはGLOBAL_EXTERNAL_PROXY_LB_BACKEND_SERVICESでした。名前が近いため、使用量を調べる時はmetric名まで一致させます。
Cloud Runサービスをロードバランサへ直接公開すると、サービス本体に加えて、serverless NEG、backend service、URLマップのpath ruleが一組ずつ増えます。このうちbackend serviceの上限が75であるため、サービスを細かく分割するほど、アプリケーションの実行枠より先に公開経路の枠が詰まります。サービスの分割単位と、インターネットへ公開する単位を同じにしないことが、構造的な対策になります。
- Cloud Run service 1件、NEG 1件、backend service 1件、path rule 1件が、直接公開するサービスごとに増える。
- GLOBAL_EXTERNAL_PROXY_LB_BACKEND_SERVICESとBACKEND_SERVICESは別metricであり、表示上の残量を取り違えない。
- ロードバランサの追加ではなく、公開入口の集約によってbackend serviceの増加を止める。
- 本稿の数値は2026年7月26日の実測であり、現在値は運用環境で改めて確認する。
解決策は新設せず、稼働中の二つの共有入口へそろえる
確認した環境では、quotaを節約する二つのパターンがすでに稼働していました。一つは静的コンテンツ向けのstatic gateway、もう一つはAPI向けのfacadeです。どちらも、サービスやホストを減らすのではなく、ロードバランサから見えるbackend serviceの数だけを集約します。
static gatewayはCloud Run上のnginxで動き、Hostヘッダーから配信先のオブジェクトストレージを決めます。実測では、一つのbackend serviceで162ホストを配信していました。テナントごとにCloud Runサービスを増やさず、ホスト名から抽出した識別子をバケット名へ写像しています。
API facadeは、Honoで実装された公開API入口がパスごとに内部Cloud Runサービスへ転送する方式です。実測では、入口アプリケーションにapp.routeによる登録が19件ありました。したがって本稿の設計は新方式の提案ではなく、例外的に残っている直接公開を、稼働実績のあるパターンへそろえるための明文化です。
- 静的配信: Host → static gateway → オブジェクトストレージ。
- API: Browserまたは外部クライアント → LB → 公開API入口 → 内部Cloud Runサービス。
- Cloud Runサービスの分割は維持し、LBのbackend serviceだけを共有する。
static gatewayはHostから配信先を決め、テナント数をLBから隠す
static gatewayでは、nginxのmapでHostを検査し、正規表現から環境とテナント識別子を取り出して、対応するバケット名を組み立てます。同じテナントが複数の公開ホスト名を持つ場合も、正規表現の分岐で同じバケットへ解決できます。ホストごとのCloud Runサービスやbackend bucketを用意する必要はありません。
この方式で集約しているのは配信経路であり、静的成果物そのものではありません。テナントごとのビルド成果物と保存先は分離したまま、gatewayだけを共有します。そのため、追加時の主な変更はホスト命名規則とバケット配置の整合を取ることであり、ロードバランサのbackend serviceを増やすことではありません。
一方、nginxがオブジェクトストレージの配信挙動を完全に再現できるとは限りません。SPA向けのfallbackと、多ページ静的サイトのディレクトリindexでは必要な探索順が異なります。この制約は後述する既知の欠陥として扱い、すべての静的サイトを無条件にgatewayへ移すことはしません。
API facadeは公開パスを内部URIへ転送する薄いリバースプロキシ
APIの流れは、Browserまたは外部クライアントからロードバランサへ入り、共通の公開API入口を通って、内部Cloud Runサービスへ進みます。facadeは公開パスを判定し、必要なpathの付け替えを行い、HTTP method、query、header、bodyを内部サービスへ転送します。転送先のCloud Run URIは環境変数で注入し、コードへ環境固有URLを書き込みません。
ブラウザから直接呼ばれるAPIにも、この構成を適用できます。確認した環境では、チャット、フォーム、メディア、注文、問い合わせ、学習、管理画面に相当する公開APIがすでにfacade経由で稼働していました。「ブラウザ向けAPIは個別にLBへ載せる必要がある」という前提は成り立ちません。
公開問い合わせAPIのhealth endpointに一意のquery markerを付け、公開側の応答と内部Cloud Runのリクエストログを突き合わせたところ、LB、公開API入口、HTTP proxy、内部サービスという経路で200を返すことを確認できました。URLマップに内部サービスのpath ruleがなくても、facade経由なら到達できます。これは設定の読み取りではなく、実際の応答とログで確認した事実です。
- LBへ直接載せる主な対象は、共通の公開API入口、static gateway、個別要件を持つ特殊経路に絞る。
- 内部サービスはCloud Run URIを環境変数で受け取り、ロードバランサの公開経路を持たない。
- ブラウザ向けかどうかではなく、特殊な公開経路が必要かどうかで直接接続を判断する。
facadeとBFFは、同じアプリケーション層でも責務が違う
static gatewayはnginxがHostから配信先を選ぶインフラ寄りの層です。API facadeはHonoのrouteとして動くアプリケーション層ですが、原則として行うのはpathの書き換えとHTTP転送です。複数APIの集約、画面用DTOへの変換、表示都合のキャッシュ、部分失敗時の画面判断を担うBFFとは目的が異なります。
ただし、境界は完全な二択ではありません。確認した最小のfacade routeは59行で単純なproxyでしたが、別のrouteは262行あり、認証やレスポンス整形を含んでいました。facadeが認証、集約、変換を持つほどBFFへ近づきます。重要なのは名称ではなく、そのrouteに業務判断や画面都合が入り始めていないかをレビューできることです。
quota節約だけが目的なら、facadeは薄く保ちます。認証主体の確定や共通の安全対策は入口に置けますが、業務ルールの本体は内部サービスへ残します。画面別の集約が必要になった場合は、BFFとして責務、契約、テストを明示し、単なる転送routeに見せかけません。
- static gateway: Hostから保存先を選ぶ。業務ロジックは持たない。
- facade: pathを書き換えて内部サービスへ転送する。原則として業務ロジックを持たない。
- BFF: 複数APIを集約・整形し、画面別の使いやすい契約を提供する。
- 薄いfacadeから厚いfacade、BFFまでは連続的なので、行数ではなく責務で判定する。
新しい内部サービスは、LB経路を作らずfacadeへ接続する
新しいAPIサービスを追加する時は、最初にサービスマニフェストへ定義を加えます。内部専用サービスにはpublicUrlを設定しません。次にTerraformのbackend service定義でpath_prefixesを空配列にし、NEGとbackend serviceの生成対象から外します。この条件分岐はすでに導入済みで、path_prefixesがないサービスにインフラ資源を無条件生成しない構成になっています。
続いて、公開API入口のroutesディレクトリへfacade routeを実装し、入口のapp.routeへ一行登録します。転送先のCloud Run URIはTerraformから環境変数として注入します。これで内部サービス自体は独立したCloud Runサービスとしてデプロイしながら、外部公開は既存のbackend serviceを共有できます。追加するbackend serviceは0件です。
直接LBへ載せるのは、共通入口では扱えないプロトコル、独立したセキュリティ境界、個別のキャッシュやタイムアウト、障害分離など、明確な要件がある場合に限ります。「新サービスだから」という理由だけでは直接公開しません。例外にする場合は、消費するquotaと、facadeで代替できない理由を設計記録へ残します。
- 1. ci/services.jsonへサービス定義を追加し、内部専用ならpublicUrlを付けない。
- 2. Terraformのbackend service定義へpath_prefixes = []で追加する。
- 3. 公開API入口のroutesディレクトリへfacadeを実装する。
- 4. 入口のapp.routeへrouteを登録する。
- 5. Terraformから内部Cloud Run URIを環境変数として注入する。
- 結果: backend serviceの追加消費は0件。
共通proxy helperとデプロイ規則を、追加実装の雛形にする
デプロイスクリプトには、publicUrlが非空でsmokePathsが空の場合にデプロイを拒否する規則があります。LB経路を持たない内部サービスへpublicUrlを付けると、公開疎通確認の設定漏れとして停止します。これは回避すべき検査ではなく、サービスの性質をマニフェストへ正しく表すための境界です。内部専用サービスにはpublicUrlを付けず、公開側のsmokeはfacadeのpathを対象にします。
proxy実装は既存の共通helperへそろえます。確認したhelperには、既定15秒の内部fetch timeoutとAbortController、内部API base URLの解決、forward bodyの読み取り、hop-by-hop response headerの除去、管理API requestの転送が実装済みでした。同じ処理をrouteごとに書き直すと、timeout、header、body、エラーの扱いが少しずつずれます。
テストでは、method、path、query、request body、認証header、response status、response header、timeoutを確認します。単純proxyのテストと、既存のメディア系proxyテストが雛形になります。実サービスへ接続する結合テストでは、一意のmarkerを付けたリクエストとCloud Runログを突き合わせ、公開応答だけでなく転送先まで確認します。
routeごとに異なるtimeoutとエラー変換を、増設前にそろえる
既存routeには非対称性があります。実測した最小routeは裸のfetchを使い、timeout、retry、AbortSignalを持っていません。fetchがrejectすると、route内で変換されず外側へ例外が伝播します。一方、認証と整形を持つ厚いrouteは上流エラーを専用関数で処理し、400、403、404、500を維持しながら、それ以外を502へ正規化していました。
入口アプリケーションからapp.onErrorの登録は検索で見つかりませんでした。Hono既定のerror handlerに委ねている可能性がありますが、これはコード検索からの推測であり、実際の例外応答までは確認できていません。実リクエストでstatus、body、ログを測り、独自handlerの有無と合わせて確定する必要があります。
routeを19件から50件規模へ増やすなら、少なくとも内部fetchを共通のtimeout付きhelperへそろえ、例外を502または504へ変換する境界、request IDの引き継ぎ、上流サービス名を外部へ出さないログ規則を決めます。retryは安全なmethodと冪等性を確認できる場合だけに限定します。遅い内部サービス一つが、共通入口の接続枠を長時間占有しないことを検証します。
- 確認済み: route間でtimeoutと上流エラーの処理が統一されていない。
- 未確認: app.onErrorがない場合の実際のstatus、body、ログ。
- 改善判断: timeout、AbortSignal、error mapping、request IDを共通helperへ集約する。
- retryは一律に追加せず、冪等性と重複実行の影響を先に確認する。
入口の集約は、単一障害点と性能上限も集約する
確認時点の公開API入口は、containerConcurrency 80、minScale 0、maxScale 10、timeoutSeconds 300、CPU 1、memory 512Mi、cpu-throttling有効でした。Terraformの宣言値と実環境にdriftはありません。単純計算では最大80×10で800同時接続ですが、これは安定して800件を処理できるという性能保証ではありません。内部fetchの待ち時間、CPU使用率、レスポンスサイズ、cold startによって実効値は変わります。
全APIが同じ入口を通るため、入口の障害や過負荷は複数サービスへ波及します。minScale 0は待機費用を抑える一方、cold startを許容する設定です。maxScale 10は下流への急激な負荷を抑える上限にもなりますが、route数やトラフィックが増えた時には入口側の待ち行列を長くする可能性があります。
実トラフィックのp95とp99、および19routeから50route規模へ増やした時にmaxScale 10で足りるかは未計測です。facadeによる追加1hopのレイテンシも未計測です。routeを大きく増やす前に、path別のリクエスト数、応答時間、5xx、instance数、concurrency、内部fetch時間を計測し、負荷試験でmaxScaleとtimeoutを決めます。
- 実測済み: concurrency、scaling、timeout、CPU、memoryとTerraformのdriftなし。
- 未計測: 実トラフィックのp95・p99、追加1hopの遅延、50route時の必要instance数。
- route数ではなく、path別の到着率、処理時間、内部接続の占有時間から容量を判断する。
- facadeへ集約するほど、監視、request ID、path別SLO、障害時の切り分けが重要になる。
static gatewayのSPA fallbackは、多ページサイトで誤った200を返す
static gatewayには既知の欠陥があります。存在しないpathへリクエストすると、try_filesのSPA fallbackによってindex.htmlを返し、status 200になりました。SPAとしては意図した挙動ですが、存在しないページを404にしたい多ページサイトでは誤りです。162ホストが同じgatewayを共有するため、検索エンジンとcrawlerに誤った成功応答を返す影響も共有されます。
もう一つは、ディレクトリ配下のindex.htmlを探索しない点です。確認した多ページ静的サイトでは171 object中144 objectがディレクトリ配下のindex.htmlでした。backend bucket経由では記事pathが正しく200、存在しないpathが404になりましたが、gateway経由では記事pathにトップページを200で返し、存在しないpathにもトップページを200で返しました。
この挙動があるため、その多ページサイトはgatewayへ移せず、backend bucketを一枠使い続けました。対策は、配信対象の性質に応じてtry_filesの探索順へ$uri/index.htmlを加え、最後のSPA fallbackを適用する条件を分けることです。backend bucketの使用量は作業前の9/9から、適合する4経路をgatewayへ移して5まで下がりましたが、挙動が一致しないサイトをquota節約だけで移してはいけません。
- SPA: 未知のpathをindex.htmlへfallbackする設計が成立する。
- 多ページ静的サイト: $uri、$uri/index.html、404の順を明示し、無条件にトップページへ戻さない。
- 移行判定では、代表ページだけでなく、ディレクトリindex、asset、末尾slash、存在しないpathを比較する。
- quota削減数だけでなく、status codeとbodyが移行前後で一致することを完了条件にする。
設定を読むだけでなく、実際の応答・ログ・実体を突き合わせる
復旧作業では、設定ファイルから結論を出し、実際の応答を測らなかったために誤診が繰り返されました。URLマップに公開問い合わせpathのruleがないことから内部サービスへ届いていないと判断しましたが、実測するとfacadeのHTTP proxy経由で到達していました。404の原因は経路ではなく、稼働中のコンテナイメージが古かったことでした。
また、gcloud describeがresource not foundを返した結果を、usedByが空、つまり未参照だと読み違えました。存在しないresourceと、存在するが参照されていないresourceは別です。describe自体の成否を先に確認し、成功したresourceについてのみ参照関係を評価します。
URLマップから参照されていないbackend bucketを不要と判断しかけた例では、Terraform configに条件なしで定義されていたものの、quota超過でapplyが完了していませんでした。未参照と未適用も別です。config、Terraform state、live resourceの三者を比較し、どの段階で差が生じたかを特定します。
経路検証では、curlの成功だけで終えません。一意のquery markerまたはrequest IDを付け、入口と転送先のログで同じrequestを追います。静的配信ではstatus codeだけでなくbodyの内容を比較します。検査側の設定が正しく見えることではなく、利用者が通る経路の実応答が期待どおりであることを証拠にします。
- 経路: curl、一意marker、入口ログ、転送先ログを一つのrequestで突き合わせる。
- resource: describeの成否を確認してから、usedByや参照関係を読む。
- IaC: config、state、liveの三者を比較し、未定義・未適用・未参照を区別する。
- 静的配信: 200かどうかだけでなく、期待したHTMLか、未知pathが404かまで確認する。
公開入口は共有し、例外だけを測定可能な形で分離する
Cloud Runのサービス分割とロードバランサのbackend service数を一対一にする必要はありません。通常の静的配信はstatic gatewayへ、通常のHTTP APIはfacadeへ集約し、内部サービスは独立したデプロイ、スケーリング、ログ、権限を保ちます。これにより、サービスを増やしてもLB quotaの消費を0件にできます。
ただし、入口の集約は無料ではありません。API側では単一障害点、concurrency、timeout、error mapping、追加hopを管理し、静的側ではSPAと多ページサイトの配信規則を分けます。特殊なprotocol、独立したセキュリティ境界、個別のキャッシュ・SLO・障害分離が必要なら、直接backend serviceを持つ方がよい場合もあります。
実務上の判断軸は、ブラウザから呼ばれるかではなく、共有入口が契約を変えずに転送できるかです。転送だけで済むならfacade、Hostから保存先を決められるならstatic gateway、画面向けの集約・整形が必要ならBFF、共有入口では満たせない非機能要件がある時だけ直接LBを選びます。その判断と実測結果を残すことで、quota節約が新たな性能障害や誤配信へ置き換わることを防げます。


