1. AWSの”あれ”、GCPでどうやるの?
Google Cloudを利用していて、AWSのCloudFrontのようにカスタムエラーレスポンスをきめ細かく制御したいと思ったことはありませんか?日本語でこのテーマを扱った記事が見当たらなかったので、今回備忘録としてまとめておくことにしました。
ことの始まりは、Single Page Application (SPA) でよく遭遇する「直リンクすると404エラーになる問題」でした。AWSではAmazon CloudFrontで回避するパターンが知られていますが、同じことをGoogle CloudのCDNサービスであるCloud CDN単体では実現できません。 そのためGoogle Cloudで何とか解決しようと調べていたところ、ロードバランサの設定を変えることによって対応できることがわかりました。
CloudFrontでのカスタムエラーレスポンスの考え方は、AWS公式ドキュメントに整理されています。この記事では、それと同じ発想をGoogle Cloudのロードバランサ上でどう実現し、どこでつまずきやすいかを整理します。
この記事では、そのときの経験をベースに、次の二つのテーマを扱います。
- Google Cloudロードバランサのカスタムエラーレスポンス機能を使って、SPAの404問題を解決する方法
- アーキテクチャを拡張してバックエンドAPIを追加した際に起きた設定ミスと、その対処方法
同じような課題に直面している誰かの助けになれば嬉しいです🙏
2. AWSで言うところのカスタムエラーレスポンスをGoogle Cloudで実現する方法
モダンなWebフロントエンド開発において、ReactやVueなどで作られたSPAをGoogle Cloud Storage (GCS)のような静的ホスティングに配置し、ロードバランサとCDN経由で配信する構成は王道の流れかと思います。
この構成はシンプルでスケーラブルですが、ある厄介な問題がついて回ります。それはSPAの「直リンク404問題」です。
例えば、ユーザーがブラウザのアドレスバーに直接 https://example.com/users/123と入力したとします。リクエストはロードバランサを通り、GCSに到達しますが、GCSには /users/123 という名前の物理ファイルは存在しません。そのため、GCSは素直に 404 Not Found を返し、ユーザーにはデフォルトのエラーページが表示されます。本来であれば、SPAが起動し、クライアントサイドルーターが /users/123 というパスを解釈してユーザーページを表示してほしいところです。
この問題を解決してくれるのが、Google Cloudロードバランサの「カスタムエラーレスポンス」機能です。この機能を使うと、バックエンド(この場合はGCS)から返された特定のHTTPステータスコード(例えば404など)をロードバランサが検知し、あらかじめ定義した別のレスポンスに差し替えることができます。

カスタムエラーレスポンスの設定画面。右側の「コードガイダンス」から、YAMLの例を確認しつつ設定内容を編集できる。
なお、カスタムエラーレスポンス機能の全体像や詳細な仕様は、Google Cloud公式ドキュメントに記載されています。ここでは、それらの内容を前提に、SPAとAPIを同じロードバランサ配下に置いたときの挙動に焦点を当てます。
今回のSPAの問題に対する解決策は次の通りです。(実際には、404 だけを対象にするか、401 や 403 をどう扱うかといったポリシーは、アプリケーションの要件に合わせて検討する必要があります。)
- バックエンドのGCSが 4xx 系のエラーを返したら、それをトリガーとする
- レスポンスボディを、SPAのエントリーポイントである
/index.htmlに差し替える - クライアントに返すHTTPステータスコードを
200 OKに書き換える
この設定により、ユーザーのブラウザは 200 OK と /index.html のコンテンツを受け取れる。URLは https://example.com/users/123 のままなので、ブラウザ上で実行されたSPAのJavaScriptが現在のURLパスを読み取り、クライアントサイドルーティングによって適切なページコンポーネントを描画してくれる、という仕組みになる。
URLマップのYAML設定で表現すると、このようになります。
# ... (pathMatchersの一部) ...
- name: path-matcher-1 # フロントエンド(SPA)用のパスマッチャー
defaultService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
defaultCustomErrorResponsePolicy:
errorResponseRules:
- matchResponseCodes:
- '4xx' # 400番台のエラーをすべて捕捉する
overrideResponseCode: 200
path: /index.html
errorService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
これで、どんな深さのパスに直接アクセスしても、まずはSPAがきちんと表示されるようになりました。この時点では、素直で分かりやすい解決策に見えます。
3. フロントとバックエンドを同居させたらAPIが沈黙した話
実際のWebアプリケーションでは、フロントエンドのSPAとバックエンドのAPIをセットで動かすことが多いです。例えば、Set-Cookie ヘッダの Domain 属性の管理などをシンプルにするため、両者を同じドメイン(例: example.com と example.com/api)で提供する構成があったりします。
そこで、GCSでSPAをホスティングしている既存のロードバランサに対して、APIをホストするCloud Runのバックエンドサービスを追加しました。
Cloud Load Balancingの用語でいう「フロントエンド」「バックエンド」は、HTTP(S) を受けるエントリポイント(IP・ポート・証明書など)と、その後ろにぶら下がるバックエンドサービス群を指す。今回のケースだと、GCSバケットやCloud Runサービスなどは、Cloud Load Balancingでは「バックエンドサービス」「バックエンドバケット」として扱われる。アプリケーション開発で日常的に使う「フロントエンド(画面)」「バックエンド(API)」とはレイヤーが違うので、Cloud Load Balancing overview あたりを一度眺めておくと、ドキュメントを読むときに混乱しにくくなる。
URLマップを更新し、/api/* へのリクエストはCloud Runへ、それ以外の /* へのリクエストはGCSへ流れるように、パスベースのルーティングを設定します。
ここで問題となったのが、先ほど設定した「4xxエラーを /index.html に書き換える」カスタムエラーレスポンスポリシーを、そのまま app.example.com というホストに紐づく path-matcher全体に適用していたことでした。一見すると、「このホストに来たリクエストでエラーが起きたらSPAにフォールバックさせる」という方針は筋が通っているように見えます。
しかし、この設定には副作用がありました。
APIのテスト中、エンドポイント POST /api/v1/auth/otp/email に対して、purposeの値をあえて不正にしてリクエストを送ってみました。バックエンドのGoアプリケーションは、バリデーションエラーとして 400 Bad Request とエラー詳細を含むJSONを返す想定です。
ところが、クライアントが受け取ったレスポンスは次のようなものでした。
- ステータスコード:
200 OK -
Content-Type:text/html - ボディ: SPAの
index.htmlの内容
APIが返したはずの 400 Bad Request は途中で失われ、代わりにフロントエンドのHTMLが返ってきていました。APIのエラー応答が、クライアント側からは確認できない状態になっていた、ということです。
この時点で、「どこかの段階でレスポンスが書き換えられている」ことだけは明らかでした。
不備があったURLマップのYAMLは以下のような形になります。
# 悪い例: path-matcher全体にカスタムエラーをぶら下げているパターン
pathMatchers:
- name: path-matcher-1 # app.example.com 用
defaultService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
# ここにdefaultCustomErrorResponsePolicyを書いてしまっているのが問題
# このレベルに書くと、/api/* を含むすべてのルールにポリシーが適用されてしまう
defaultCustomErrorResponsePolicy:
errorResponseRules:
- matchResponseCodes:
- '4xx'
overrideResponseCode: 200
path: /index.html
errorService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
routeRules:
# /api/* はCloud Runに振り分ける
- priority: 1000
matchRules:
- prefixMatch: /api/
service: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendServices/your-api-service
# それ以外はSPA用のGCSバケットへ
- priority: 2000
matchRules:
- prefixMatch: /
service: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
4. レスポンスが書き換わっていた理由とLB設定の直し方
インフラで不可解な挙動が起きたときは、各コンポーネントを切り分けながら確認していくのが安全です。今回は次の順序で確認しました。
-
APIアプリケーションのコードは壊れているか?
ローカル環境で同じGoバイナリを起動し、curlでlocalhostに同じリクエストを投げてみました。結果は、想定通り400 Bad RequestとJSONボディが返ってきました。アプリケーションのロジック自体は問題なさそうです -
正しいコードはCloud Runにデプロイされているか?
Cloud Loggingで該当サービスのログをフィルタリングすると、APIへのリクエストに対応するログエントリのstatusが400と記録されていました。Cloud Run上で動いているアプリケーションも、正しくエラーを返しています -
クライアントが実際に受け取っているものは何か?
curl -vでレスポンスヘッダを確認すると、server: UploadServerというヘッダが含まれていました。これはレスポンスの提供元がCloud Runではなく、Google Cloud Storage (GCS) であることを示します
ここまで確認すると、レスポンスがCloud Runからクライアントに届くまでの間に、ロードバランサで差し替えられている可能性が高いことが分かります。
実際の処理の流れを整理すると次のようになります。
- クライアントが
/api/*にリクエストを送信 - ロードバランサがリクエストをCloud Runバックエンドに転送
- Cloud Run上のアプリがリクエストを処理し、バリデーションエラーとして
400 Bad Requestを返却 - ロードバランサがCloud Runからの
400レスポンスを取得 - path-matcherに設定されたポリシーが「4xx系のエラー」を検知し、カスタムエラーレスポンスポリシーを適用
- ポリシーの指示通り、ロードバランサはCloud Runからのレスポンスを破棄し、代わりにGCSバケットから
/index.htmlを取得して、200 OKとしてクライアントに返却
つまり、原因は「カスタムエラーレスポンスポリシーの適用範囲が広すぎた」ことでした。APIのレスポンスにも同じポリシーが適用されてしまい、意図しない置き換えが発生していた、という形です。
「ホスト単位」「パス単位」のどこにポリシーを書くかで、効く範囲が変わる。URLマップのリファレンスを見ると、UrlMap.defaultCustomErrorResponsePolicy → pathMatchers[].defaultCustomErrorResponsePolicy → pathMatchers[].routeRules[].customErrorResponsePolicy の順で、より下位の設定が上書きする階層構造になっているのがわかる(たとえば regionUrlMaps の API リファレンス など)。どのレベルにポリシーを書いているのかを意識しておかないと、今回のように「想定外のバックエンドにも効いてしまう」という状態になりやすい。
この問題を解決するには、カスタムエラーレスポンスポリシーの適用範囲をフロントエンド用のルーティングに限定する必要があります。具体的には、API用のルールとSPA用のルールを分離し、SPAのルールにのみ「404を /index.html に書き換える」ポリシーを適用します。
| 構成アプローチ | 説明 | 結果 |
|---|---|---|
| 悪い例 ❌ | ホスト全体 (path-matcher) にカスタムエラーポリシーを適用する |
/api/* へのリクエストでCloud Runが400や404エラーを返しても、ポリシーがそれを検知し、フロントの index.html を返してしまう。APIのエラー詳細がクライアントに届かない。 |
| 良い例 ✅ | パスごと (routeRules) にルールを分離する |
/api/* のルールにはカスタムエラーポリシーを適用しない。/* のルールにのみ、SPA用の404→200書き換えポリシーを適用する。ポリシーの適用範囲をフロントエンドのアセットを処理するルーティングルールだけに限定することで、APIのエラーはそのままクライアントに届き、SPAの直リンク問題だけを解決できる。 |
この修正により、APIは 400 Bad Request などのエラーを正しく返すようになりつつ、SPAのフォールバックも期待どおりに動作するようになりました。
正しい設定例として、routeRules を使ってAPIとSPAのルールを分離し、カスタムエラーポリシーをSPA側にのみ適用したURLマップの pathMatcher 部分の概念的な設定は次のようになります。
pathMatchers:
- name: path-matcher-1 # app.example.com 用
defaultService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
routeRules:
# 1) API Rule: ここではカスタムエラーを設定しない
- priority: 1000
matchRules:
- prefixMatch: /api/
service: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendServices/your-api-service
# 2) SPA Rule: バケットにだけカスタムエラーポリシーを設定する
- priority: 2000
matchRules:
- prefixMatch: /
service: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
customErrorResponsePolicy:
errorResponseRules:
- matchResponseCodes:
- '404'
overrideResponseCode: 200
path: /index.html
errorService: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/backendBuckets/your-spa-bucket
5. 最後に
Google Cloudのロードバランサは、GCSの静的コンテンツ、Cloud Runのサーバーレスアプリ、VM上のサービスなど、多様なバックエンドを一つのドメイン配下にまとめられる、非常に強力で柔軟なツールです。
一方で、その柔軟性ゆえに、「カスタムエラーレスポンス」のような機能が、意図しないバックエンドにまで影響を併せ持つことがあります。今回のケースから得た教訓は、強力な機能ほど、その適用範囲を意識的に絞り込む必要がある、という点でした。特に、責務の異なる複数のサービスを一つのロードバランサで束ねる場合、設定の継承や影響範囲には慎重な確認が求められますね。
同様の構成を検討している方の検証や設計の一助になれば幸いです。