Part 1 で整理した要件 (カスタムドメイン + HTTPS + Cloud CDN などへのキャッシュ禁止 + 自己管理のコンピュートリソースの回避) のもと、Part 2 では案 D: External ALB + Internet NEG + GCS が GET / PUT / Range / Resumable / 3 GiB 単一 PUT のいずれも透過的に成立することを実機で確認しました。
ただし、Part 2 の構成が動作する根拠のうち以下 2 点は、公式ドキュメントに明示されていないデフォルト挙動でした。
- Resumable Upload の session URI はカスタムドメイン利用時もリクエスト Host に基づいて組み立てられる
- NEG エンドポイントに
c.storage.googleapis.com を使うのは、一般的なカスタムドメイン利用のために提供されているガイドの CNAME 設定を派生的に利用している
そうなると「Google 公式にマネージドサービスへの正式な接続経路として案内されている仕組みに置き換えれば、このデフォルト挙動依存のいくつかを確実な仕様に置き換えられるのではないか」という期待が出てきます。本記事 (Part 3) はその候補として、Part 1 で案 E に挙げた External ALB + Private Service Connect NEG (PSC NEG) + GCS を実機検証し、Internet NEG と同じユースケース (GET / PUT / Range / Resumable Init / Resumable Chunk) の成立可否を確認します。
結論としては、PSC NEG でも同じ要件は成立しますが、Internet NEG よりも深刻な制約 (Envoy による Host 書き換え・バケット名の URL パス露出・timeoutSec 30 秒固定) を受け入れる必要があります。さらに、期待していた「公式の仕様による裏付け」は得られず、公式ドキュメント非明示のデフォルト挙動に乗っている点については変わらないという整理になりました。したがって本記事の結論としては「実用上は Internet NEG ほぼ一択であり、PSC NEG は別解として検証したものの採用できる場面は相当に限定的」となりました。
本シリーズが扱う要件を再掲しておきます。
| 要件 |
要求水準 |
| カスタムドメイン |
必須 |
| HTTPS |
必須 |
| Cloud CDN などエッジへのキャッシュ |
禁止 |
| 自己管理のコンピュートリソース |
できれば持ちたくない |
また、これまでの関連記事は以下から参照できます。
なお、本記事に掲載するコマンド実行結果や参照する公式ドキュメントの記載は Google Cloud のアップデートに応じて変更される可能性があり、あくまで検証時点 (2026-04) のスナップショットです。現行仕様を確認する際は各引用元の最新版をあわせて参照してください。
LB 構成における PSC NEG の位置づけ
検証に入る前に、PSC NEG が LB の構成上どのような位置に入るのかを整理しておきます。
Private Service Connect (PSC) は、Google が提供するマネージドサービスや VPC 内の公開サービスに対し、プライベートなエンドポイントを通じて接続するための仕組みです (Private Service Connect overview)。PSC NEG はこの PSC の仕組みを LB のバックエンドとして扱えるようにしたもので、ターゲットの種別によって 2 つの使い方があります (Private Service Connect overview)。
- Published service をターゲットとする: 自社または他社が PSC で公開したサービス (VPC 内部のサービス) を指す
- Google API をターゲットとする:
storage.googleapis.com / bigquery.googleapis.com などの Google Cloud のマネージド API を指す (公式ドキュメント上はさらに regional endpoint と global endpoint の 2 種に分かれますが、本記事は GCS をターゲットとする global endpoint の挙動に絞って検証します)
今回は後者です。NEG 作成時に --psc-target-service=storage.googleapis.com を指定することで「この NEG は GCS API を指す」という宣言になります。
$ gcloud compute network-endpoint-groups create gcs-psc-poc-neg \
--region=asia-northeast1 \
--network-endpoint-type=private-service-connect \
--psc-target-service=storage.googleapis.com
ALB のリソース階層は次のようになります (括弧内は検証時の設定)。
Forwarding Rule (Global static IP + :443)
└── Target HTTPS Proxy
├── SSL Certificate (Google-managed)
└── URL Map
└── Backend Service (HTTPS, CDN 無効, timeoutSec=30 固定)
└── PSC NEG (network-endpoint-type=private-service-connect)
└── --psc-target-service=storage.googleapis.com
Internet NEG が global かつ internet-fqdn-port だったのに対し、PSC NEG は regional の private-service-connect 型です。この違いは後述のタイムアウト制約にもつながってきます。
検証環境
構成図

各コンポーネントと値
- ドメイン:
gcs-psc-poc.example.com (実際には個人の技術検証用ドメインを使用)
- バケット:
gs://gcs-psc-poc.example.com (asia-northeast1, UBLA 有効)
- LB IP: Global static IP (実 IP はマスキング)
- LB タイプ: Global External Application Load Balancer (
EXTERNAL_MANAGED)
- LB 構成要素: Forwarding Rule + Target HTTPS Proxy + URL Map + Backend Service (HTTPS, CDN 無効,
timeoutSec=30 固定)
- 証明書: Google-managed (ACTIVE)
- NEG タイプ: PSC NEG (
network-endpoint-type=private-service-connect, asia-northeast1)
- PSC ターゲット:
--psc-target-service=storage.googleapis.com
なお、本記事は Part 2 とは別の検証環境・別バケットで PSC NEG を新規構築し検証しています。ドメイン名が Part 2 の gcs-poc.example.com と異なるのはそのためです。
環境構築に関する補記
Part 2 で触れた「バケット名 = ドメイン名の一致 (Search Console 経由のドメイン所有権確認)」「Cloudflare は DNS only (プロキシ OFF)」「Cloud CDN は無効」といった前提は本記事でも共通です。詳細は Part 2 の「環境構築に関する補記」 を参照してください。以下は PSC NEG 固有の補記です。
Backend Service の timeoutSec は変更不可
Part 2 で Internet NEG のときに 3600 秒まで延長できていた timeoutSec は、PSC NEG (Google API ターゲット) の Backend Service では設定できません。--timeout=3600 を指定した Backend Service の作成自体は通ってしまいますが、その Backend Service に PSC NEG を add-backend しようとしたタイミングで次のエラーで弾かれます。
ERROR: (gcloud.compute.backend-services.add-backend) Could not fetch resource:
- Invalid value for field 'resource.timeoutSec': '3600'. Timeout sec is not
supported for a backend service with Private Service Connect network
endpoint groups targeting Google API.
公式ドキュメントでこの制約の明示的な記述を見つけることはできませんでしたが、エラーメッセージから読み取る限り「PSC 経由で Google API にアクセスする場合は固定タイムアウトになる (変更できない)」と考えられます。この制約は、後述の検証項目 (3 GiB 単一 PUT の割愛) にも波及します。
実機検証
構築した環境に対して署名付き URL を発行し、ユースケースごとに実機で動作を確認していきます。Part 2 と同じ要領で進めたいところでしたが、同じやり方で試すと 404 エラーが返ったため、原因切り分けを挟んでから回避策を適用することとなりました。Part 2 と同じ試験を実施するために必要となった回避策とその検討過程を簡単にご紹介したのち、回避策を適用した状態での動作検証の結果を見ていきます。
なお、本節の見出し番号 (1. 症状 〜 5. 3 GiB 単一 PUT) は「調査の流れ」を示す番号で、Part 2 実機検証の「検証したユースケース」を示す番号とは意味が異なります。各ユースケースの動作検証結果は「4. 回避策を適用しての動作検証」にまとめて 4-1〜4-4 として格納しています。
署名付き URL の発行
Part 2 と同じく Python の google-cloud-storage で V4 署名付き URL を生成し、ホスト部を gcs-psc-poc.example.com にするために generate_signed_url の bucket_bound_hostname を指定します。
from datetime import timedelta
from google.cloud import storage
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file("signer-key-psc.json")
client = storage.Client(credentials=creds, project=creds.project_id)
bucket = client.bucket("gcs-psc-poc.example.com")
url = bucket.blob("test-small.txt").generate_signed_url(
version="v4",
expiration=timedelta(minutes=15),
method="GET",
bucket_bound_hostname="https://gcs-psc-poc.example.com",
)
生成される URL は Part 2 と同形式で、host=gcs-psc-poc.example.com を canonical string に含めた Virtual Hosted Style です。Part 2 ではこの形でそのまま動作しましたが、PSC NEG 経由では以下のとおり (そのままでは) 動作しません。
1. 症状 (Part 2 と同形式の URL が返す 404)
上の署名付き URL を curl で叩くと、次のようなレスポンスが返ってきます。
$ curl -i "https://gcs-psc-poc.example.com/test-small.txt?X-Goog-Signature=..."
HTTP/2 404
content-type: application/xml; charset=UTF-8
x-guploader-uploadid: AMNfjG0MAmh...
content-length: 133
server: envoy
via: 1.1 google
<?xml version='1.0' encoding='UTF-8'?><Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist.</Message></Error>
Internet NEG では server: UploadServer が返っていましたが、PSC NEG では server: envoy が返ってきます。経路上に Envoy ベースのプロキシ層が介在している、ということが server ヘッダから読み取れる形になりました。
もうひとつ目を引いたのは、404 の body に <Code>NoSuchBucket</Code> が返っている点です。本来 GCS は XML 形式で <Error><Code>...</Code>...</Error> の body を返します (オブジェクト不在なら 404 + NoSuchKey、署名崩れなら 403 + SignatureDoesNotMatch など。HTTP status and error codes for XML API)。つまり GCS 自身が「そんなバケットは存在しない」と応答しており、LB → PSC NEG → GCS の経路は成立しているが、GCS に届いた時点でバケット解決に失敗している、と読めます。署名検証よりも手前の段階 (バケット解決) で落ちているようです。
2. 原因切り分け (署名時 host と配信時 host の 4 パターン検証)
署名付き URL が失敗するとしたら、候補として最初に思い当たるのは V4 署名検証での host 不一致です。V4 署名の canonical headers では host が必須である (Canonical requests の "The following headers must always be defined in the canonical headers") ため、クライアントが署名した host の値と、GCS が実際に受け取った Host が一致しなければ署名は成立しません。
この仮説を確かめるために、署名時の host と配信時の host の組み合わせを 4 パターン試してみました。
| # |
署名時 host |
配信時 host |
署名スタイル |
結果 |
| A |
カスタム (gcs-psc-poc.example.com) |
カスタム |
Virtual Hosted Style |
404 (NoSuchBucket XML) |
| B |
storage.googleapis.com |
storage.googleapis.com |
Path Style |
200 (LB 非経由で GCS に直アクセス) |
| C |
storage.googleapis.com |
カスタム |
Path Style |
200 (LB 経由) |
| — |
無署名 |
カスタム |
Path Style・署名なし |
403 AccessDenied (GCS が XML で正常応答) |
注目すべきは次の 2 点です。
- C の成立が経路途中での Host 書き換えを示している: C はクライアントが Host=カスタムドメインで LB に投げるにもかかわらず、署名時の host は
storage.googleapis.com でした。それでも署名検証を通っているということは、GCS が署名検証に使った Host は storage.googleapis.com であり、つまり LB → GCS の経路のどこかで Host が書き換わっている、と推測できます。書き換えの主体が Envoy かどうかは実験そのものからは断定できず、レスポンスの server: envoy ヘッダの観測 (検証 1 を参照) と合わせて Envoy ベースの Managed Proxy 層が関与している、と推定しています。
- A の 404 body が
NoSuchBucket である: A の失敗は「経路が壊れている」のではなく、GCS に届いた時点でバケット解決に失敗している、と読めます。書き換え後の Host が storage.googleapis.com であれば、Virtual Hosted Style ではなく Path Style として解釈され、先頭パスセグメント (test-small.txt) をバケット名として探しに行き、そんなバケットは存在しないので NoSuchBucket が返っていると思われます。つまり A は署名検証まで到達する前に、その手前のバケット解決で落ちている、と考えられます。
なお、Virtual Hosted Style と Path Style の違いは、GCS に届いた Host やパスからバケットを解決する経路には影響しますが、V4 署名検証の host 一致判定には影響しません (どちらのスタイルでも canonical headers の host は実リクエスト時の Host ヘッダと一致している必要があり、スタイルの違いで一致判定のルールが変わるわけではない)。したがって上の表の A と C の差分は、実質的に「署名時 host がカスタムか storage.googleapis.com か」という 1 変数に還元できます。
この前提で整理すると、「PSC NEG (Envoy) は経路途中で Host を storage.googleapis.com に書き換えてから GCS に転送している」と考えると、観測した挙動に説明がつきそうです (C は書き換え後の Host と署名時の host が一致するため 200、A は書き換え後の Host storage.googleapis.com のもとでバケット解決が Path Style として走り、先頭パスセグメントをバケット名として探しに行くため NoSuchBucket の 404)。Internet NEG が Host を素通しで渡していたのとは対照的な挙動です。
なお、純粋に署名が一致しないケース (Path Style 署名 URL の X-Goog-Signature= のパラメータを 1 バイト書き換えて配信) を別途試してみたところ、こちらは 403 SignatureDoesNotMatch の XML ボディが返ってきました。したがって、A が返すのは 404 + NoSuchBucket であり、署名検証で弾かれたときの応答形とは異なる、という点から「A の 404 は署名不一致ではなくバケット解決の失敗」ではないかと考えました。
この「Envoy が Host を書き換えている」という挙動は公式ドキュメントに明示的な記述を見つけられませんでしたが、4 パターンの結果からこのような推定であれば説明がつくと考えました。以降は、この推定をもとに検討した回避策について説明します。
3. 回避策 (Path Style 署名とホスト置換)
Envoy によって Host が storage.googleapis.com に書き換えられるのであれば、「最初から host=storage.googleapis.com で署名しておけば、書き換え後の Host と一致して署名検証を通せるのでは」と考えて試してみました。
具体的には次のようになります。
from datetime import timedelta
from google.cloud import storage
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file("signer-key-psc.json")
client = storage.Client(credentials=creds, project=creds.project_id)
bucket = client.bucket("gcs-psc-poc.example.com")
url = bucket.blob("test-small.txt").generate_signed_url(
version="v4",
expiration=timedelta(minutes=15),
method="GET",
virtual_hosted_style=False,
)
delivery_url = url.replace(
"https://storage.googleapis.com",
"https://gcs-psc-poc.example.com",
)
Internet NEG で使えた bucket_bound_hostname は PSC NEG 接続では使えません (検証 1 より、使うと 404 になる)。代わりに virtual_hosted_style=False で Path Style の署名付き URL を生成し、クライアントに渡す直前にホスト部だけを置換する、というプロセスになります。
この署名付き URL をクライアントが叩くと、次の 4 ステップでリクエストが処理されるはずです。
- クライアントが
Host: gcs-psc-poc.example.com、path /gcs-psc-poc.example.com/test-small.txt で LB に到着 (URL のホスト部から Host ヘッダが自動生成される)
- LB → PSC NEG に転送、Envoy が Host を
storage.googleapis.com に書き換え (推定)
- GCS フロントエンドで受信:
Host: storage.googleapis.com、path /gcs-psc-poc.example.com/test-small.txt (Path Style としてバケット解決)
- 署名は
host=storage.googleapis.com で作られているため canonical string と一致 → 署名検証 OK、200
副作用として、この回避策では URL にバケット名がパスとして露出します。https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/test-small.txt?X-Goog-... という、同じ文字列が 2 回出てくる少し奇妙な URL になります。バケット名とドメイン名を揃えなければならないため、この重複は避けられません (バケット名を UUID 等にしてもホスト部との一致要件から同じ文字列が必ず 2 箇所に出現するため、ドメインとバケット名を一致させる限り重複は回避不能です)。
4. 回避策を適用しての動作検証
回避策 (Path Style 署名 + ホスト置換) のもとで、Part 2 と同じユースケースを一つずつ確認します。
4-1. 単発 GET
$ curl -sI "https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/test-small.txt?X-Goog-Signature=..." \
| grep -Ei "^(HTTP|content-type|content-length|server|via)"
HTTP/2 200
content-type: text/plain
content-length: 930
server: envoy
via: 1.1 google
930 B のテキストが 200 で返り、server: envoy であることがここでも確認できます。
4-2. 単発 PUT (10 MiB / 100 MiB)
$ dd if=/dev/urandom of=/tmp/put-10mib.bin bs=1M count=10 2>/dev/null
$ curl -X PUT --data-binary @/tmp/put-10mib.bin -w "%{http_code}\n" \
"https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/uploaded-10mib.bin?X-Goog-Signature=..."
200
$ dd if=/dev/urandom of=/tmp/put-100mib.bin bs=1M count=100 2>/dev/null
$ curl -X PUT --data-binary @/tmp/put-100mib.bin -w "%{http_code}\n" \
"https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/uploaded-100mib.bin?X-Goog-Signature=..."
200
10 MiB・100 MiB ともに 200 で応答が返り、バケット側のサイズ一致も確認しました。
4-3. Range GET
$ curl -sD - -o /dev/null -H "Range: bytes=0-1048575" \
-w "\n%{http_code} %{size_download}\n" \
"https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/uploaded-10mib.bin?X-Goog-Signature=..." \
| grep -Ei "^(HTTP|content-range|content-length|[0-9]+ [0-9]+)"
HTTP/2 206
content-range: bytes 0-1048575/10485760
content-length: 1048576
206 1048576
HTTP/2 206 で 1 MiB ちょうどが返り、content-range も期待どおりです。Range ヘッダは Envoy を通過して GCS まで届いていると言えます。
4-4. Resumable Upload
Resumable Upload の Init POST を、Path Style 署名 + ホスト置換で叩きます。Init 用の署名は method="POST" + headers={"x-goog-resumable": "start"} で発行し、SignedHeaders に x-goog-resumable を含めます。
$ curl -i -X POST \
-H "x-goog-resumable: start" \
-H "Content-Length: 0" \
"https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/resumable-target.bin?X-Goog-Signature=..."
HTTP/2 201
location: https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/resumable-target.bin
?X-Goog-Algorithm=GOOG4-RSA-SHA256
&X-Goog-Credential=gcs-psc-signer%40<PROJECT_ID>.iam.gserviceaccount.com%2F20260417%2Fauto%2Fstorage%2Fgoog4_request
&X-Goog-Date=20260417T024446Z
&X-Goog-Expires=3600
&X-Goog-SignedHeaders=host%3Bx-goog-resumable
&X-Goog-Signature=...
&upload_id=AMNfjG1a...
content-length: 0
server: envoy
(※ 上の location: ヘッダは可読性のため ? 以降を改行して整形しています。実際の HTTP レスポンスでは 1 行に収まります。)
ここで注目したのは、返ってきた session URI のホスト部が gcs-psc-poc.example.com (カスタムドメイン) になっている点です。GCS 本体は受け取った Host に基づいて session URI を組み立てるはずですが、Envoy が往路で Host を storage.googleapis.com に書き換えているので、単純に考えると session URI も https://storage.googleapis.com/... で返ってくるのではと考えられます。
ところが、実際にはカスタムドメインで返ってきます。この結果は、Envoy が往路の Host 書き換えに加えて「復路のレスポンスヘッダ (Location) も逆方向に書き戻している」ことを示唆しています (代替仮説として、GCS 側が X-Forwarded-Host を参照して session URI を組み立てている可能性もあります。ただしその場合でも Envoy が往路でヘッダを付加する挙動に依存することは変わらず、「経路途中の Envoy の挙動に成否が依存している」という本記事の結論は変わりません)。詳しくは次の考察セクションで整理します。
続けて、この session URI に 8 MiB × 4 回の chunk PUT をかけます。
$ SESSION_URI="https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/resumable-target.bin?...&upload_id=AMNfjG1a..."
$ curl -X PUT --data-binary @chunk0.bin \
-H "Content-Range: bytes 0-8388607/33554432" -w "%{http_code}\n" "$SESSION_URI"
308
$ curl -X PUT --data-binary @chunk1.bin \
-H "Content-Range: bytes 8388608-16777215/33554432" -w "%{http_code}\n" "$SESSION_URI"
308
$ curl -X PUT --data-binary @chunk2.bin \
-H "Content-Range: bytes 16777216-25165823/33554432" -w "%{http_code}\n" "$SESSION_URI"
308
$ curl -X PUT --data-binary @chunk3.bin \
-H "Content-Range: bytes 25165824-33554431/33554432" -w "%{http_code}\n" "$SESSION_URI"
200
前半 3 回は 308 Resume Incomplete、最後の chunk で 200 が返り、33,554,432 バイト (32 MiB) のオブジェクトがバケットに格納されました。session URI がカスタムドメインで返ってくるため、追加の URL 書き換えをクライアント側に要求しなくても chunk PUT が LB 経由で完結します。
5. 3 GiB 単一 PUT は今回割愛
Part 2 では 3 GiB の単一 PUT を約 296 秒で完走させましたが、PSC NEG の Backend Service は timeoutSec=30 固定のため、30 秒で終わるサイズ帯でしか単一 PUT の計測は成立しません。クライアント側の上り帯域が細い環境でそれなりのサイズを単一 PUT する用途は、この構成では現実的ではない、ということになります。したがって本記事では単一 PUT 系の大容量検証は割愛します。
大容量ファイルを PSC NEG 経由で扱う必要がある場合、各 chunk の PUT が 30 秒以内に収まるよう刻んだ Resumable Upload で間接的に対応する、という選択肢はあります (検証 4-4 より、有効期限内であれば chunk ごとの PUT は正常に動作します)。
検証結果のまとめ
| # |
検証項目 |
結果 |
HTTP |
備考 |
| 0 |
Virtual Hosted Style 署名で単発 GET |
❌ |
404 (NoSuchBucket XML) |
server: envoy。回避策不使用 |
| 1 |
Path Style 署名 + ホスト置換で単発 GET (930 B) |
✅ |
200 |
body 一致を確認 |
| 2-1 |
単発 PUT (10 MiB) |
✅ |
200 |
バケット側サイズ一致 |
| 2-2 |
単発 PUT (100 MiB) |
✅ |
200 |
バケット側サイズ一致 |
| 3 |
Range GET (bytes=0-1048575) |
✅ |
206 |
1 MiB 取得、content-range 一致 |
| 4-1 |
Resumable Init (POST + x-goog-resumable: start) |
✅ |
201 |
session URI がカスタムドメインで返る |
| 4-2 |
Resumable Chunk PUT (4 × 8 MiB = 32 MiB) |
✅ |
308, 308, 308, 200 |
最終 chunk で 33,554,432 バイトが格納された |
| 5 |
3 GiB 単一 PUT |
— |
— |
timeoutSec=30 固定のため割愛 |
回避策 (Path Style 署名 + ホスト置換) を適用した条件下では、GET・PUT・Range・Resumable のいずれも期待どおりに動作することが確認できました。3 GiB 単一 PUT を除き、Part 2 で確認したユースケースは PSC NEG でもひととおり成立します。
考察: なぜこの構成が動作するのか
ここまでの検証で、PSC NEG 経由でも回避策のもとでは署名付き URL が期待どおりに動作することが確認できました。続けて「なぜ動作するのか」を整理します。
そもそも、この構成が動くかどうかは実質的に次の 3 点に収斂します。
- PSC NEG (Google API ターゲット) は LB → upstream 区間で Host をどう扱うか
- 往路で Host が書き換えられるなら、復路の Location はどう扱われるか
- 上の 2 点をふまえて、署名検証を通す URL 設計は成立するか
いずれも公式ドキュメントには明示されておらず、ここまでの検証はこの 3 点に対する実機の回答でもあります。以下はこの観点からの整理です。
GCS 側が要求している前提
PSC NEG 側の挙動を見る前に、GCS 側の制約を簡単に整理しておきます。
- HTTP 層の都合: GCS はバケット解決を Host ヘッダ (Virtual Hosted Style) または URL パスの先頭セグメント (Path Style) で行います。Request endpoints に示されている 2 形式 (
https://BUCKET_NAME.storage.googleapis.com/OBJECT_NAME と https://storage.googleapis.com/BUCKET_NAME/OBJECT_NAME) から読み取れる挙動です。さらに V4 署名検証で host を canonical headers に含めて署名を計算します (Canonical requests)。したがって「GCS が受け取った Host」と「署名発行時の host」が一致していなければ署名検証は必ず失敗する
- TLS 層の都合: GCS のフロントエンドが提示する TLS 証明書は
*.storage.googleapis.com 配下のワイルドカードのみで、任意のカスタムドメインはカバーしない
PSC NEG 経路では Internet NEG と異なり --psc-target-service=storage.googleapis.com という宣言ベースの指定になるため、LB が明示的に SNI を組み立てる挙動を本記事では直接観測していません。TLS ハンドシェイクが常に成立している (検証 1〜4 でレスポンスが返ってきている) 以上、GCS フロントエンドの証明書スコープと一致する形で SNI がセットされていると考えられます。
Envoy が双方向に Host / Location を書き換えている
Host / Location の扱いは、検証結果を見る限り、次のような挙動になっていると考えられます。
- 往路 (リクエスト): LB のバックエンド方向に流れるリクエストを PSC NEG 経由で受け取った Envoy が、Host (
gcs-psc-poc.example.com) を storage.googleapis.com に書き換えてから GCS に転送
- 復路 (レスポンス): GCS が返した
Location: https://storage.googleapis.com/... を、Envoy が https://gcs-psc-poc.example.com/... に書き戻してからクライアントに返却
往路の書き換えは、回避策を検討する際の原因切り分け (4 パターン検証) の過程で仮説として導かれました。復路の書き戻しは、検証 4-4 で Resumable Init のレスポンス Location がカスタムドメインで返ってきた事実から導かれます。往路の Host 書き換えがあるにもかかわらず session URI がカスタムドメインで返る、という現象は、復路も対称的に書き戻されていると考える以外に説明がつきにくいためです。
ホップごとに整理すると次のようになります (Envoy の挙動は観測ベースの推定、それ以外は公式ドキュメントに明示)。
| ホップ |
HTTP Host (リクエスト) |
レスポンス Location |
決定主体 |
| (1) Client → LB |
gcs-psc-poc.example.com |
— |
クライアント (URL のホスト部から自動生成) |
| (2) LB 内部 (TLS 終端・URL Map ルーティング) |
gcs-psc-poc.example.com (改変なし) |
— |
LB |
| (3) LB → PSC NEG → Envoy |
gcs-psc-poc.example.com → storage.googleapis.com に書き換え |
— |
Envoy (推定) |
| (4) GCS 側で受信 |
storage.googleapis.com (Path Style としてバケット解決) |
https://storage.googleapis.com/... を発行 |
GCS |
| (5) Envoy 復路 |
— |
https://storage.googleapis.com/... → https://gcs-psc-poc.example.com/... に書き戻し |
Envoy (推定) |
| (6) クライアント受信 |
— |
https://gcs-psc-poc.example.com/... |
クライアント |
ポイントはホップ (3) と (5) で、Envoy は往路と復路を対称に書き換えていると思われる点です。詳しくは次のセクションで説明します。
Resumable の session URI も書き戻される意味
検証 4-4 で session URI がカスタムドメインで返ってくる挙動は、公式ドキュメントからは特定できず、実機で Init POST を叩いて初めてわかった挙動です。
もし session URI が https://storage.googleapis.com/... のまま返ってきていた場合、次のどちらかの破綻が生じるはずです。
storage.googleapis.com のまま返ってきた場合: クライアントが chunk PUT の宛先として storage.googleapis.com を叩くことになり、LB を迂回して直接 GCS に接続することになります。「エンドユーザから見えるホスト名は常にカスタムドメイン」という本シリーズの設計原則が崩れます
- 別の形式で返ってきた場合: クライアント側で session URI を書き換える自前の実装を組み込む必要が生じ、SDK の透過的利用が難しくなります
もし往路だけ書き換えて復路を素通しすると、Resumable の session URI がクライアント側では使えない URL (storage.googleapis.com) で返り、回避策のもとでも Resumable が破綻します。Envoy が復路で Location を書き戻していることで、クライアントはこの余計な実装を持たずに済んでいます。これは Internet NEG が「Host を素通しするおかげで session URI もカスタムドメインで返ってくる」のとは異なるアプローチ (書き換え × 書き戻しの対称性) で、同じ結果に到達している、と読むことができます。
このデフォルト挙動に依存してよいか
ここまでで見てきた「Envoy が Host を storage.googleapis.com に書き換える」「復路の Location も逆方向に書き戻す」という挙動は、いずれも公式ドキュメントが仕様として明文化しているわけではなく、実機で観測されたデフォルトの動作です。
Part 2 で検証した Internet NEG の構成と比較してみると、両者のデフォルト挙動依存の重さには偏りがあります。
- 公式ドキュメントで明示されていない挙動に依存しているポイントは「
c.storage.googleapis.com を Internet NEG エンドポイントとして使ってよいか (公式案内は CNAME 用)」および「Resumable session URI がリクエスト Host から組み立てられるか」
- PSC NEG: 経路途中に介在する Envoy の Host 書き換え・Location 書き戻しという、構成の成否そのものを握る挙動が公開されていない
つまり、PSC NEG を採用してもデフォルト挙動への依存を減らすどころか、Internet NEG よりも踏み込んだ領域 (経路途中の Host 書き換え / Location 書き戻し) でデフォルト挙動に依存することになる、というのが観察からの整理です。
「PSC + Google API ターゲット」は、Backend buckets overview で "Backend buckets offer a seamless, fully-integrated experience … However, Private Service Connect offers an alternative deployment type where you create a Private Service Connect NEG that points to Cloud Storage API endpoints" と、Backend Bucket に対する "alternative deployment type" として案内されています。しかしそこに Envoy が介在する挙動そのものは開示されていないため、仕様として確定的に扱える状態とは言えません。
PSC NEG 採用時の注意点
本記事の要件で PSC NEG を採用する場合、Internet NEG と共通する前提 (バケット名 = ドメイン名の制約など) に加えて、以下の運用上の手当てが必要になります。
- Path Style 署名 + ホスト置換:
virtual_hosted_style=False で generate_signed_url を呼び、クライアントに渡す直前にホスト部を置換する。bucket_bound_hostname は使えない (404 になる)。発行側の実装ミスを防ぐため、この置換をライブラリ関数で包んでおくのが無難です。
- URL にバケット名がパスとして露出することを許容: バケット名を URL から隠蔽したい要件がある場合、PSC NEG は不適です。バケット名を UUID など任意の名前にしてもパスには必ず出てきます。
- タイムアウト 30 秒による上限を前提にする: 上り帯域が細い・レイテンシが大きいクライアントから数 GiB を単一 PUT する用途は現実的ではありません。大容量アップロードが想定される場合は、各 chunk を 30 秒以内に収まるよう刻んだ Resumable Upload を使うか、Internet NEG を選択します。
- デフォルト挙動の継続性を外形監視で担保する: 考察で触れた「Envoy の Host 書き換え・Location 書き戻し」は公式ドキュメントが仕様として明示しているわけではないため、挙動の変化を継続的に検知できる仕組みを入れておくのが安全です。具体的には以下が候補になります。
- 署名付き URL 外形監視
- LB アクセスログでの応答 Location ヘッダ値のモニタリング
- Backend Service / URL Map / Target HTTPS Proxy / PSC NEG の IaC 化とドリフト検出
Google Cloud のサポート契約がある環境であれば、採用前にこれらのデフォルト挙動を仕様として扱ってよいかをサポート経由で確認しておくのも選択肢になるでしょう。
Internet NEG と PSC NEG の定性比較
両構成の定性比較を整理します。
| 観点 |
Internet NEG (Part 2) |
PSC NEG (本記事) |
評価 |
| 署名付き URL 動作 |
✅ 全項目成立 (3 GiB 単一 PUT 含む) |
✅ 成立 (Path Style 必須、3 GiB 単一 PUT は不可) |
Internet NEG 優位 |
| URL 形状 |
https://<domain>/<object>?... |
https://<domain>/<bucket>/<object>?... (バケット名がパスに露出) |
Internet NEG 優位 |
| 長時間タイムアウト |
timeoutSec=3600 まで設定可 |
timeoutSec=30 固定 |
Internet NEG 優位 |
| 署名の実装 |
bucket_bound_hostname |
virtual_hosted_style=False + ホスト置換の二段構え |
Internet NEG 優位 |
| 観測性 |
server: UploadServer、エラーも GCS の XML 応答。LB アクセスログから httpRequest.latency / protocol / statusDetails 等を取得可能 (Part 2 大容量 PUT 検証参照) |
server: envoy、Envoy の実装や書き換えの挙動は非公開。LB アクセスログ自体は Internet NEG と同様に取得可能だが、書き換え挙動の内部表現は得られない |
Internet NEG 優位 |
| 公式ドキュメントの記載 |
c.storage.googleapis.com の endpoint は派生的な利用 |
Backend Bucket の代替手段として公式ドキュメントで案内されている |
PSC NEG 優位 |
| 依存するデフォルト挙動 |
Resumable session URI の組み立て |
Envoy の Host 書き換え・Location 書き戻し |
Internet NEG 優位 |
整理すると、実用上は Internet NEG がほぼ一択、と言って良いのではないかと思います。PSC NEG はバケット名を URL パスに露出させ、timeoutSec=30 のもとで大容量単一 PUT の経路を諦め、署名実装を二段構えにする必要があります。こうまでして PSC NEG を採用したとしても、前述のとおり、デフォルト挙動への依存は Internet NEG よりむしろ重い形で残ります。
また、「経路として PSC を通したい」という動機そのものも、LB → GCS のホップに限って言えば技術的な必然性は薄いと言えます。PSC を使えば「NW 経路が Google Cloud 内に閉じている」という点は仕様上確定していますが、Internet NEG についても Internet network endpoint groups overview が "Deliver traffic to your public endpoint across Google's private backbone" および "connections to internet NEG endpoints use the internet while keeping your traffic on Google's high performance backbone for the longest possible distance" と述べているとおり、Google の private backbone を可能な限り経由する設計であることが示されています。特に、今回のように LB の NEG エンドポイントが Google が管理する FQDN (c.storage.googleapis.com) である場合は、宛先自体が Google の管理範囲にあるため、実経路は恐らく Google backbone 内で完結し、PSC との差分は仕様上の建付けの違いに留まると考えられます。PSC 本来の価値である「VPC → Google API をプライベートに通す」「VPC SC 境界の制御」といった効能は LB のバックエンド用途では発揮されません。
したがって、案 E の採用が意味を持つのは、以下のように技術ではなく「組織・プロセス側の事情」がある場合に限られそうです。
- 組織のポリシーとして「Google API アクセスは全て PSC 経由」と決まっており、例外を通すコストが高い
- 監査・第三者レビューに対して「公式にサポートされた経路です」と説明したい (Internet NEG の
c.storage.googleapis.com 利用は、カスタムドメイン利用時の設定ガイドに掲載されている CNAME の派生的利用であるため、説明にひと手間かかりそうです)
シリーズ全体の整理
最後に、本シリーズで検討した全候補を表形式で整理します。
| 構成 |
GCS 署名付き URL |
カスタムドメイン |
HTTPS |
Cloud CDN |
コンピュート |
構成の複雑性 |
| GCS 直接 CNAME (Part 1 案 A) |
✅ |
✅ |
❌ |
不要 |
不要 |
低 |
| Cloud CDN 署名付き URL + Backend Bucket (別軸・参考) |
— (GCS 署名付き URL とは別の署名方式) |
✅ |
✅ |
✅ (前提) |
不要 |
中 |
| ALB + Backend Bucket (Part 1 案 B) |
❌ (サポート外) |
✅ |
✅ |
不使用 (要件により無効) |
不要 |
低 |
| Cloud Run リバースプロキシ + GCS (Part 1 案 C) |
✅ |
✅ |
✅ |
不要 |
必要 |
高 |
| ALB + Internet NEG + GCS (Part 2 案 D) |
✅ 動作確認 |
✅ (バケット名非露出) |
✅ |
不要 |
不要 |
中 |
| ALB + PSC NEG + GCS (本記事 案 E) |
△ 動作確認 (timeout 固定等の制約あり) |
✅ (バケット名が URL パスに露出) |
✅ |
不要 |
不要 |
中 |
全体のまとめ
3 回の連載記事を通して、「HTTPS 必須 x カスタムドメイン経由で GCS 署名付き URL を配信するには」というテーマを、(コンピュート層を挟まずに) ALB だけで成立させる 2 つのアプローチを実機検証してきました。
Part 2 で検証した External ALB + Internet NEG + GCS の構成では、GET・PUT・Range GET・Resumable Upload・3 GiB 単一 PUT のいずれも期待どおりに動作することを確認しました。こちらは以下の知見が得られました。
- 「LB 経由の署名付き URL はサポート外」という Google 公式の記述は Backend Bucket に対するものであり、Backend Service + Internet NEG 経由のパスには当てはまらないと解釈できそうです
- Resumable Upload の session URI はリクエストの Host ヘッダに基づいて組み立てられるため、カスタムドメイン経由で Init を叩けば session URI もカスタムドメインで返り、chunk PUT も LB 経由で完結します
本記事 (Part 3) では別解として、Internet NEG の代わりに PSC NEG (Google API ターゲット) を用いる構成を実機検証しました。こちらは以下の知見が得られました。
- 経路上に Envoy ベースのプロキシ層が介在し、リクエストの Host を
storage.googleapis.com に書き換える (推定) ため、bucket_bound_hostname が使えず、Path Style 署名 + ホスト置換 が必要
- 往路の Host 書き換えと対称に、Resumable Init の復路 Location も Envoy が書き戻している (推定) ため、session URI はカスタムドメインで返り chunk PUT は LB 経由で完結する
- URL にバケット名がパスとして露出する (
https://<domain>/<bucket>/<object>?...)
- Backend Service の
timeoutSec が 30 秒固定で、長時間の単一 PUT には向かない
- 仕様上の建付けとして「LB → GCS のホップが Google Cloud の内部経路を通る」と説明しやすい (Internet NEG も実経路は Google の backbone を通る可能性が高いが、その確実度は PSC NEG のほうが高い)
PSC NEG は timeoutSec=30 の固定、バケット名の URL パス露出、署名実装の二段構えといった制約があるわりに、Envoy の Host 書き換え・Location 書き戻しといった挙動を見せており、非公開の仕様に依存している点も回避できていません。構成の成否そのものを握る挙動 (Host 書き換え・Location 書き戻し) を抱えている PSC NEG と、限定的なデフォルト挙動依存に留まる Internet NEG とを比べると、本記事の要件では Internet NEG を選ぶのが事実上の一択になるかと思います。
署名付き URL という機能を軸に、Virtual Hosted Style の挙動 → VPC SC 境界との組み合わせ → カスタムドメインでの配信 (Part 1 / Part 2 / 本記事) と、周辺のいろいろな事情を実機検証しながら見てきたことで、GCS と署名付き URL の仕組みをより具体的に把握できたように思います。これらの記事がどなたかの参考になれば幸いです。