ComputeClass を用いた GKE Standard のノードプール拡張 (Part 2: Autopilot mode workloads)

前回の Part 1 では、autopilot.enabled を使わない素の ComputeClass を Standard クラスタで動かし、priority の fallback やノードプールの自動削除の挙動を確かめました。 そこで起動したノードは、ComputeClass 用の taint こそ付くものの、中身は普通の Standard ノードであり、管理の主体はユーザ側にありました。

Part 2 では、その管理を Google に託してみます。 組み込みの autopilot ComputeClass を使って、Standard クラスタの中に Autopilot 管理のノードを追加します。 「ノードの管理を Google に任せる」場合、運用負担の軽減など、そのメリットについてはイメージしやすいと思いますが、一方で、結果としてクラスタにどのような変化があり、何が制限されるのかは、実機を見ないと想像しにくいところです。 今回は、この「ノードの管理を手放すと何が変わるのか」の答えを探っていきます。

Autopilot mode workloads とは

About Autopilot mode workloads in GKE Standard には、この機能が次のように説明されています。

You can use ComputeClasses to run Google Kubernetes Engine (GKE) Autopilot workloads in your GKE Standard mode clusters.

Google manages any new nodes that GKE provisions for these workloads, similar to how Google manages the nodes in Autopilot clusters.

まとめると、「クラスタ全体は Standard モードのまま運用しつつ、特定の ComputeClass を経由するワークロードだけを Autopilot 管理のノードに乗せられる」という機能です。

課金モデル

同ドキュメントには、課金モデルについて以下の説明があります。

The Pod-based billing model applies when GKE uses a podFamily priority rule to create nodes for a workload.

The node-based billing model applies when GKE uses a ComputeClass priority rule that explicitly request specific hardware, such as a certain machine family or GPUs.

Autopilot pricing applies only to the workloads and nodes that use an Autopilot ComputeClass.

ComputeClass の priority rule に何を書くか (podFamily か、machineFamily / GPU などの具体的なハードウェア指定か) で、Pod 課金 / ノード課金のどちらが適用されるかが決まる、と整理できます。 本記事で動かす組み込み autopilot ComputeClass は前者 (podFamily) にあたります。 machineFamily を指定するカスタム Autopilot ComputeClass は Part 3 で検証します。

検証環境

クラスタは Part 1 で構築したものと同じです (詳細は Part 1 の「検証環境」を参照ください)。 autopilot.enabled まわりのバージョン要件については、Run workloads in Autopilot mode in Standard clusters に次の記載があります。

To use Autopilot in ComputeClasses, the cluster must run 1.34.1-gke.1829001 or later.

To use podFamily priority rules in custom Autopilot ComputeClasses, the cluster must run 1.35.2-gke.1485000 or later.

検証クラスタは 1.36.0-gke.4447000 で、いずれの要件も満たしています。

Part 1 と同様、計測値はあくまで参考程度とお考えください。

組み込み ComputeClass の中身

組み込みの Autopilot ComputeClass は、クラスタ作成直後から kubectl get computeclass で確認できます。

$ kubectl get computeclass
NAME                 AGE
autopilot            2m25s
autopilot-arm        2m25s
autopilot-arm-spot   2m25s
autopilot-spot       2m25s

x86 向けの autopilot / autopilot-spot に加えて、Arm アーキテクチャ向けの autopilot-arm / autopilot-arm-spot も用意されています (Arm 系は Run workloads in Autopilot mode in Standard clusters の Limitations にバージョン要件の記載があります)。 本記事の検証で使うのは autopilot です。

$ kubectl get computeclass autopilot -o yaml
...
spec:
  activeMigration:
    optimizeRulePriority: true
  autopilot:
    enabled: true
  autoscalingPolicy:
    consolidationDelayMinutes: 1
    consolidationThreshold: 85
    gpuConsolidationThreshold: 85
  description: Provides general-purpose Autopilot compute.
  nodePoolAutoCreation:
    enabled: true
  priorities:
  - podFamily: general-purpose
  whenUnsatisfiable: DoNotScaleUp

priority は podFamily: general-purpose の 1 つだけです。 Part 1 で見た priority rule は machineFamilymachineType でハードウェアを指名するものでしたが、podFamily は毛色が違います。 Run workloads in Autopilot mode in Standard clusters では次のように説明されています。

priorities.podFamily : uses the podFamily priority rule to run Pods on the Autopilot container-optimized compute platform.

ハードウェアを指名する代わりに、Pod を Autopilot 側の "container-optimized compute platform" で動かすことを指定する priority rule です。 container-optimized compute platform 自体は Autopilot overview に、動作中に動的にリサイズできる Autopilot ノードからなり、Pod 数や resource consumption に応じて GKE 側がキャパシティを自動調整するプラットフォーム、と説明されています。

値の general-purpose については、次の記載があります。

General-purpose workloads: use one of the built-in Autopilot ComputeClasses, which place Pods on the container-optimized compute platform.

The general-purpose option is recommended for most production workloads that don't require specific hardware to run well. It supports both x86 and Arm architectures.

「特定のハードウェア要件を持たない汎用ワークロード」を意味する値のようです。 podFamily に指定できる値としてはこのほかに、Arm 向けの general-purpose-arm が Limitations に記載されています。 本記事の検証では general-purpose のみを使っています。

whenUnsatisfiableDoNotScaleUp です。

DoNotScaleUp: Leave the Pod in the Pending status until a node that meets the ComputeClass requirements is available.

priority を満たせない場合に fallback のノードを立てるのではなく、Pod を Pending のまま保持する設定で、Part 1 で使った ScaleUpAnyway とは逆の方針が選ばれています。

ちなみに autopilot-spot の spec もほぼ同じで、prioritiesspot: true が追加されているだけでした。

組み込み autopilot ComputeClass で Job を流す

この ComputeClass を nodeSelector に指定して、Job を実行します。 コンテナは python:3.12-slim で 900 秒 sleep するだけのもので、resources は requests: cpu 500m / memory 512Mi (limits も同値)、1 Pod です。

apply 後、Pod は約 114 秒で Pending → Running になりました。 立ち上がったノードを確認します。

$ kubectl get pods -l job-name=batch-builtin-autopilot -o wide
NAME                            ...   NODE
batch-builtin-autopilot-gxbmx   ...   gk3-gke-cc-poc-nap-e2-medium-1kz4sfeo-1f36dc0b-89nw

$ gcloud container node-pools list --cluster=gke-cc-poc --zone=asia-northeast1-a
NAME                             MACHINE_TYPE   ...
default-pool                     e2-medium      ...
nap-e2-medium-1kz4sfeo           e2-medium      ...

ノード VM 名のプレフィックスが、Part 1 で見てきた gke- から gk3- に変わっています。 これは公式にも Autopilot 管理の目印として明記されています。

In this output, the gk3- prefix in the Node column indicates that the node is managed by Autopilot.

一方で、ノードプール名は nap-e2-medium-1kz4sfeo で、プレフィックスは Part 1 の自動作成ノードプールと同じ nap- のままです。 つまり gk3- が付くのはノード VM 名だけで、ノードプール名からは Autopilot 管理かどうかを判別できません。 ノードプール側での判別には、後述の autopilotConfig.enabled フィールドが使えます。

マシンタイプは e2-medium でした。 priority は podFamily: general-purpose だけでマシンファミリーを指定していないため、Part 1 で見た「pending Pod の requests に応じてサイズを選ぶ」動きに従って、今回の要求 (cpu 500m / memory 512Mi × 1 Pod) に見合う小さなマシンが選ばれた、と考えられます。 なお、2026 年 5 月頃に同様の検証した際は、ここで ek-standard-16 という Autopilot 専用のマシンシリーズ (公式には container-optimized compute platform のノードで「表示されることがある」とされる、E2 ベースの専用シリーズ) が引かれていました。 今回の検証では、組み込み autopilot 経由の試行で ek は現れませんでしたが、別の経路で現れた例を Part 3 で取り上げます。

Standard クラスタにおける Autopilot ノードの特徴

立ち上がった Autopilot ノードを、Part 1 の basic-n2 で立った autopilot=false のノードと比べます。 まずラベルです。 代表的なものを抜粋します。

ラベル basic-n2 (autopilot=false / Part 1) autopilot (built-in / Part 2)
cloud.google.com/compute-class basic-n2 autopilot
cloud.google.com/autopilot-managed-node (無し) true
cloud.google.com/machine-family n2 e2
cloud.google.com/gke-max-pods-per-node 110 32
cloud.google.com/gke-image-streaming (無し) true
cloud.google.com/gke-gcfs (無し) true

autopilot-managed-node: true をはじめ、image streaming (gke-image-streaming) や GCFS (gke-gcfs) など、Autopilot 化に伴って有効になる要素が並びます。 gke-max-pods-per-node は 110 から 32 に変わっています。

taint も増えていました。

(autopilot=false / Part 1 basic-n2)
- cloud.google.com/compute-class=basic-n2:NoSchedule    (1 個)

(autopilot=true / built-in autopilot)
- cloud.google.com/autopilot-managed-node=true:NoSchedule
- cloud.google.com/compute-class=autopilot:NoSchedule
                                                         (2 個)

ComputeClass 用の taint に加えて、cloud.google.com/autopilot-managed-node=true:NoSchedule が付与されています。

ノードプール定義の側にも違いがあります。 gcloud container node-pools describe で取得すると、autopilotConfig.enabled: true というフィールドが入っていました。 Part 1 の autopilot=false なノードプールには、このフィールドはありません。

ノードの allocatable は次のとおりでした。

$ kubectl describe node gk3-gke-cc-poc-nap-e2-medium-1kz4sfeo-1f36dc0b-89nw | grep -A 7 'Allocatable:'
Allocatable:
  cpu:                940m
  ephemeral-storage:  47060071478
  memory:             2825844Ki
  pods:               32

pods の上限が、ラベルのとおり 32 になっています (Part 1 の Standard 系ノードは 110)。

まとめると、Autopilot 管理かどうかの判別には、公式に目印として明記されているノード VM 名の gk3- プレフィックスが使えます。 加えて今回の観測の範囲では、ノードのラベルと taint の autopilot-managed-node、ノードプールの autopilotConfig.enabled も一貫して判別に使えました。 ノードプール名の nap- プレフィックスは判別には使えません。

Pod spec への mutation

Autopilot にノード管理を任せると、ノードだけでなく Pod の spec にも手が入ります。 Resource requests in Autopilot には次の記載があります。

If your requests are less than the minimum, Autopilot automatically modifies your workload configuration to bring your requests within the allowed range.

この「最小値」は一律ではなく、Pod bursting に対応するクラスタかどうかで変わります。 general-purpose の組み込み ComputeClass の場合、bursting 対応クラスタで CPU 50m / Memory 52 MiB、非対応クラスタで CPU 250m / Memory 512 MiB です。 非対応クラスタではさらに、Pod の limitsrequests と等しくなるよう調整されます。

では、Standard クラスタ × Autopilot mode workloads という今回の構成は、どちらの扱いになるのでしょうか。 この組み合わせの bursting に言及した記述は、確認した限り見つけられませんでした。 そこで、意図的に最小値以下の requests (cpu: 10m / memory: 16Mi、limits なし) を持つ Pod を、組み込み autopilot ComputeClass でデプロイしてみました。 結果、requests は次のようになりました。

{
  "limits": {
    "ephemeral-storage": "1Gi"
  },
  "requests": {
    "cpu": "50m",
    "ephemeral-storage": "1Gi",
    "memory": "52Mi"
  }
}

cpu / memory は 50m / 52Mi に引き上げられ、cpu / memory の limits は設定されていません。 bursting 対応クラスタ側の値です。 Pod bursting in GKE - Availability in GKE の記載とも整合するため、GKE Standard × Autopilot mode workloads の構成でも bursting 対応クラスタの扱いになると考えて良さそうです。

もうひとつ、上の出力にはマニフェストに書いていない ephemeral-storage: 1Gi が入っています。 これは Default resource requests で「All other containers: Ephemeral storage: 1 GiB」と定義されている default 値の付与で、冒頭の Job (500m / 512Mi、limits あり) でも requests / limits の両方に同じ値が追加されていました。 このように Autopilot 配下では manifest と実機の Pod spec が一致しなくなることがあるため、実機の値を確認したい場合は kubectl get pod ... -o yaml などで取得する運用になります。

DaemonSet の配置

ノードに taint が 2 つ付くとなると、気になるのは DaemonSet の扱いです。 toleration を指定しない通常の DaemonSet (alpine:3requests: cpu 10m / memory 16Mi) を apply して、45 秒後の様子を見ます。

$ kubectl get ds ds-test -o wide
NAME      DESIRED   CURRENT   READY   ...   NODE SELECTOR
ds-test   1         1         1       ...   <none>

このとき、この検証セッションのクラスタには 5 ノード (default-pool の 1 台、Part 1 と同構成の ComputeClass 由来の 3 台、Autopilot 管理の gk3- 1 台) が動いています。 toleration を持たない DaemonSet なら本来「全ノードに 1 個ずつ」になりそうなところ、DESIRED=1 で、default-pool 以外の 4 台がそもそもカウントされていません。

これは Autopilot 特有の挙動ではなく、Kubernetes の DaemonSet コントローラの仕様から説明できます。 DaemonSet コントローラはノードごとに taint を tolerate できるかを判定し、Pod が tolerate しない NoSchedule taint を持つノードを DESIRED の母数から除外します。 Autopilot ノードの 2 つの taint も、Part 1 で見た非 Autopilot な ComputeClass ノードの compute-class taint も、この DaemonSet は tolerate できないため、4 台まとめて DESIRED から外れた、と考えられます。

運用面では、ここが ComputeClass 導入時の注意点になります。 監視、ログ収集、セキュリティなどの目的で「全ノードに 1 個ずつ」を前提に DaemonSet を運用している場合、ComputeClass の利用が広がると (Autopilot の有無を問わず)、それらのカバレッジから ComputeClass 由来のノードが外れることになります。 導入するなら、事前に方針を決めておきましょう。 Autopilot ノードにも配置するなら、少なくとも 2 つの taint を tolerate する必要があります (今回確認したのは toleration なしの場合のみで、toleration を付けた場合に実際に配置されるかまでは検証していません)。 加えて、toleration だけでは、意図しない Autopilot ノードにまで配置が広がりうるため、対象を絞る nodeAffinity や nodeSelector、Autopilot 側のワークロード制約に抵触しないかの確認もあわせて必要です。 逆に配置しないなら、Autopilot ノードは当該 DaemonSet の監視対象外とする、などの運用整理を明文化しておくと良いでしょう。

ちなみに、kube-system 配下の DaemonSet については、Autopilot ノードもカバーされるように設計されています。 検証時に gk3- ノード上の Pod を確認すると、gke-metadata-server / netd / kube-proxy など、kube-system の DaemonSet 群が乗っていました。 ログ収集は fluentbit-gke (default-pool 用) と fluentbit-gke-managed-node-small (Autopilot ノード用) のように DaemonSet 自体が分かれており、「Autopilot ノードを除外する一般 DaemonSet」と「Autopilot ノード専用 DaemonSet」の組み合わせでカバレッジを成立させているようです。

ノードプールの手動削除

最後に、Autopilot 管理のノードプールを gcloud container node-pools delete で直接消そうとするとどうなるかを試します。 対象は、組み込み autopilot で立った nap-e2-medium-1kz4sfeo です (autopilotConfig.enabled: true を持ちます)。

$ gcloud container node-pools delete nap-e2-medium-1kz4sfeo \
    --cluster=gke-cc-poc --zone=asia-northeast1-a --quiet
ERROR: (gcloud.container.node-pools.delete) ResponseError:
code=400, message=Autopilot node pools cannot be accessed or modified.

HTTP 400 で、明確に拒否されました。 Autopilot 管理のノードプールは、ユーザからある程度可視ではあるものの、Autopilot クラスタのノードと同じく、あくまで Google の管理下にあるということがわかります。

おわりに

今回の Part 2 では、冒頭の「ノードの管理を手放すと何が変わるのか」という問いに対して、実機検証からその答えを探りました。 調達されるノードは gk3- プレフィックスの Autopilot 管理ノードになり、taint とラベルが増え、Pod の spec には mutation が入り、toleration のない DaemonSet はそのノードをデプロイ対象から除外します。 また、Autopilot ノードは Google の管理下にあるため、ノードプールは自分では消せなくなりました。

今回は、あらかじめ用意されている組み込みの ComputeClass を使って検証しましたが、autopilot.enabled: true はカスタムの (ユーザーが独自に定義した) ComputeClass にも付与できます。 組み込み版と同じ設定を、このユーザー定義の ComputeClass に追加した場合、同じ結果が得られるのでしょうか。 Part 3 では、カスタム Autopilot ComputeClass を実際に適用し、組み込みの ComputeClass との違いを調査します。

ComputeClass を用いた GKE Standard のノードプール拡張 (Part 1: ComputeClass の基本動作)

GKE には ComputeClass という Custom Resource があり、ワークロードに使うマシン構成の優先順位とフォールバック (fallback) を宣言的に定義できます。 ComputeClass は、Pod に選ばせるノード構成のプロファイル (priorities と fallback、オートスケーリング設定の集合) であり、nodePoolAutoCreation.enabled: true を指定したときだけ、条件に合うノードプールの自動作成が有効になります。 この自動作成を使うと、「第一希望は spot の N2、調達できなければ on-demand の N2」といったように希望する構成を並べておくだけで、ノードプールの作成から不要になったあとの削除まで、ワークロードに応じたノードのプロビジョニングを GKE に任せられます。 (なお、手動で作ったノードプールに cloud.google.com/compute-class=<名前> のラベルと taint を付けて紐付ける使い方もありますが、本シリーズでは自動作成 (node pool auto-creation) を使う構成を扱います)

こう聞くと、「宣言した構成のマシンだけが立ち上がるのか」と捉えてしまいそうですが、例えば priority に並べた構成がいずれも調達できなかったとき、クラスタには何が起きるのでしょうか。 また、宣言した構成以外のマシンが起動したことに、運用者は気付けるのでしょうか。 公式リファレンス (About custom ComputeClasses in GKE) を読んだだけでは実機の挙動を想像しにくいところがあったため、検証用クラスタを立ててひととおり確かめてみました。

本シリーズは全 3 部の予定です。 Part 1 となる本記事では、標準的な ComputeClass (autopilot.enabled なし) の挙動を確かめます。 ComputeClass には、Standard クラスタの中で特定のワークロードだけを Autopilot mode で動かす構成 (Autopilot mode workloads) もありますが、こちらについては Part 2 以降で扱います。

ComputeClass とは

ComputeClass は GKE の Custom Resource (apiVersion: cloud.google.com/v1, kind: ComputeClass) です。 priorities フィールドに使いたいマシン構成を優先順に並べ、whenUnsatisfiable フィールドで、どの priority も満たせなかった場合の挙動を指定します。 About custom ComputeClasses in GKE にあるとおり、priority rule のトップレベルのプロパティには machineFamily / machineType / gpu / tpu などがあり、spot / minCores / minMemoryGb といったフィールドを追加で組み合わせて絞り込めます (このほかに Autopilot mode 専用の podFamily もありますが、Part 2 で扱います)。

また、spec には priority とは別に autopilot.enabled というフィールドがあり、これを true にすると、その ComputeClass を指定したワークロード用に Autopilot mode のノードがプロビジョニングされるようになります (Run workloads in Autopilot mode in Standard clusters のとおり、GKE 1.34.1-gke.1829001 以降が必要)。 組み込みの autopilot 系 ComputeClass では、このフィールドがデフォルトで有効になっています。

検証環境

検証用に新規プロジェクトを用意し、zonal Standard クラスタを 1 つ作成しました。

項目
Region / Zone asia-northeast1-a
Cluster Standard, zonal
Release channel rapid
GKE version (実測) 1.36.0-gke.4447000
Default node pool default-pool, e2-medium × 1, taint なし
その他 --enable-ip-alias (VPC-native), Workload Identity 有効

クラスタ作成は gcloud container clusters create の標準的なオプションのみで行い、--enable-autoprovisioning (cluster-wide NAP) は付けていません。 Configure node auto-provisioning には

To enable node pool auto-creation for a ComputeClass in GKE versions earlier than 1.33.3-gke.1136000, you must also enable cluster-level node auto-provisioning.

とあり、検証クラスタの 1.36.0-gke.4447000 はこのバージョン要件を満たすため、ComputeClass 単位の nodePoolAutoCreation.enabled: true だけで自動プロビジョニングを行えます。

なお、本記事に登場する公式ドキュメントの引用は、記事執筆時点 (2026 年 7 月) のものです。 計測値や起動したマシン構成は、検証環境で観測された一例であり、あくまで参考値とお考えください。

Dataplane V2 は必要か

GKE Dataplane V2 にあるとおり、GKE 1.22 以降の Autopilot クラスタでは Dataplane V2 がデフォルトで有効になっています。 そのため、検証前は、Standard クラスタの中で Autopilot mode のワークロードを動かす場合も Dataplane V2 の有効化が必要になるのでは、と考えていました。

しかし、Run workloads in Autopilot mode in Standard clusters の Limitations で Dataplane V2 に触れているのは、次の一文だけでした。

Calico network policy enforcement isn't supported. You must use GKE Dataplane V2 or disable network policy enforcement.

Autopilot mode を使うこと自体の要件として Dataplane V2 が挙げられているわけではなく、NetworkPolicy の enforcement を使いたい場合に Calico が使えないため、Dataplane V2 を使うか enforcement を無効にするか、という選択になる制約です。 本検証は NetworkPolicy を使わないため、Dataplane V2 は指定していません。

基本動作の確認 (basic-n2)

まずはユーザ定義の ComputeClass を 1 つ作って動かします。 priority に machineFamily: n2 を spot / on-demand の 2 段で並べ、whenUnsatisfiable: ScaleUpAnyway を指定した構成です。

apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
  name: basic-n2
spec:
  priorities:
  - machineFamily: n2
    spot: true
  - machineFamily: n2
    spot: false
  nodePoolAutoCreation:
    enabled: true
  whenUnsatisfiable: ScaleUpAnyway
  activeMigration:
    optimizeRulePriority: true

spec の activeMigration.optimizeRulePriority: true は、起動時点で上位の priority の構成が調達できずに下位の priority で起動したノードを、(上位の priority の構成が調達可能になり次第) 能動的に上位の priority の構成に置き換えることを許可する設定です (About custom ComputeClasses in GKE 参照)。

ワークロードは、この ComputeClass を nodeSelector で指定した Deployment (nginx, replicas: 2, requests: cpu 200m / memory 256Mi) です。 apply 後、Pod は約 82 秒で Pending → Running になりました。

立ち上がったノードを確認します。

$ kubectl get pods -l app=cc-basic-app -o wide
NAME                            ...   NODE
cc-basic-app-554867d8dc-4cd8s   ...   gke-gke-cc-poc-nap-n2-standard-2-spot-74d607d0-q586
cc-basic-app-554867d8dc-qtps7   ...   gke-gke-cc-poc-nap-n2-standard-2-spot-74d607d0-q586

$ kubectl get nodes -L cloud.google.com/compute-class,cloud.google.com/machine-family,cloud.google.com/gke-spot
NAME                                                  ...   COMPUTE-CLASS   MACHINE-FAMILY   GKE-SPOT
gke-gke-cc-poc-nap-n2-standard-2-spot-74d607d0-q586   ...   basic-n2        n2               true

$ gcloud container node-pools list --cluster=gke-cc-poc --zone=asia-northeast1-a
NAME                             MACHINE_TYPE   ...
default-pool                     e2-medium      ...
nap-n2-standard-2-spot-quy0yl24  n2-standard-2  ...

priority 1 (machineFamily: n2, spot: true) のとおり、n2-standard-2 の spot ノードプールが自動作成され、そこに 2 Pod が乗りました。 ノードには cloud.google.com/compute-class: basic-n2cloud.google.com/machine-family: n2 の 2 つのラベルが付き、ComputeClass の名前と実際のマシンファミリーが揃っています。

machineFamily だけ指定して machineType を指定しない場合のサイズの選ばれ方については、About custom ComputeClasses in GKE に次の記載があります。

When you use the machineFamily field, GKE provisions nodes from that series with a machine type that is large enough to run your Pods. The autoscaler algorithm determines the most appropriate size based on the aggregate resource requests of all pending Pods.

今回のリクエスト (cpu: 200m / memory: 256Mi × 2 Pod) を満たすサイズとして n2-standard-2 (2 vCPU / 8 GB) が選ばれたことは、この記載と整合していそうです。 ただし、N2 ファミリーには、より小さいメモリ構成の n2-highcpu-2 (2 vCPU / 2 GB) もあるため、厳密に「ファミリー内で最小のマシンが選ばれる」とも限らないようです。

ノードの taint と Pod への toleration

自動作成されたノードプールのノードには、cloud.google.com/compute-class=basic-n2:NoSchedule という taint が付与されていました。 一方で、ワークロードのマニフェストに toleration は書いていません。 それでも Pod がこのノードに乗れているのは、対応する toleration が Pod 側に自動挿入されているからです。 kubectl get pod ... -o jsonpath='{.spec.tolerations}' を確認すると、次の内容が入っていました。

{
  "effect": "NoSchedule",
  "key": "cloud.google.com/compute-class",
  "operator": "Equal",
  "value": "basic-n2"
}

kubectl apply --dry-run=client -o yaml の出力と apply 後の kubectl get pod ... -o yaml を比べると、後者にだけこの toleration が追加されており、GKE が nodeSelector: cloud.google.com/compute-class: <ComputeClass 名> に対応する toleration をサーバ側で自動追加していることがわかります。 切り分けのため、同じ nodeSelector を持つ素の Pod を --dry-run=server で送ってみると、応答にこの toleration は現れませんでした。 mutating admission は server-side dry-run でも実行されるため、admission での注入ではなく、(断定はできませんが) Pod 作成後にコントローラが付与している可能性が高そうです。

逆に、ComputeClass を指定しない通常の Pod を同じクラスタに deploy して spec を見ると、compute-class の toleration は入っていませんでした。 toleration を自分で書かず、注入もされない Pod は、この NoSchedule taint によって自動作成されたノードプールから弾かれます。 よって、ユーザは明示的に toleration を書く必要がなく、nodeSelector で ComputeClass を指定さえすれば、対象の Pod を適したノードプールに乗せることができるようになっています。

priority の中で fallback する場合 (basic-fallback-n2)

basic-n2 では、priority 1 のとおりにノードが立ちました。 では、priority 1 が調達できない構成だった場合、priority 2 への切り替えはどのように起きるのでしょうか。

もうひとつ ComputeClass を用意して試します (便宜的に basic-fallback-n2 と呼びます)。 priority 1 に存在しない machineType (n2-standard-99999) を置き、priority 2 に machineFamily: n2 を置いた構成です。

apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
  name: basic-fallback-n2
spec:
  priorities:
  - machineType: n2-standard-99999
  - machineFamily: n2
  nodePoolAutoCreation:
    enabled: true
  whenUnsatisfiable: ScaleUpAnyway

なお、この ComputeClass には activeMigration を入れていないため、basic-n2 と spec が完全に揃った比較ではありません。

apply 後、Pod は約 67 秒で Pending → Running になりました。 basic-n2 の約 82 秒と同水準で、priority 1 で足止めされた気配がありません。 events を見ます。

$ kubectl describe pod -l app=cc-fallback-app | grep -A 14 'Events:'
Events:
  Normal   TriggeredScaleUp  cluster-autoscaler  Pod triggered scale-up:
    [{...nap-n2-standard-2-1pyhxgx0-temporary-mig-... 0->1 (max: 1000)}]
  Warning  FailedScheduling  default-scheduler   0/3 nodes are available: ...
  Normal   Scheduled         default-scheduler   ... gke-...-nap-n2-standard-2-1pyh-...

priority 1 の n2-standard-99999 に対する TriggeredScaleUp はそもそも記録されておらず、最初から priority 2 (machineFamily: n2) の scale-up が走っています (FailedScheduling はノード起動を待つ間の通常のイベントです)。 priority 1 は「試して失敗した」のではなく、試行の痕跡を残さずスキップされた、と読み取れます。

どの priority も満たせない場合 (basic-m2)

では、priority に並べた構成がすべて調達できなかったら、何が立つのでしょうか。

この状況を意図的に作るために、quota が 0 のマシンファミリーを使います。 検証プロジェクトでは M2 ファミリー (メモリ最適化) の CPU quota が 0 でした。

$ gcloud compute regions describe asia-northeast1 --format=yaml | grep -B1 -A1 'metric: M2_CPUS'
- limit: 0.0
  metric: M2_CPUS
  usage: 0.0

m2 のマシンタイプ自体は asia-northeast1-a で提供されており (gcloud compute machine-types list で確認)、quota が 0 である以上、spot / on-demand のどちらの priority も満たせない状況を意図的に作れることになります。 この M2 を priority に 2 段 (spot / on-demand) で並べた ComputeClass basic-m2 を用意し、basic-n2 と同じ構成のワークロードを apply します。

apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
  name: basic-m2
spec:
  priorities:
  - machineFamily: m2
    spot: true
  - machineFamily: m2
    spot: false
  nodePoolAutoCreation:
    enabled: true
  whenUnsatisfiable: ScaleUpAnyway
  activeMigration:
    optimizeRulePriority: true

Pod は約 89 秒で Pending → Running になりました。 何が立ったのかを確認します。

$ kubectl get pods -l app=cc-m2-app -o wide
NAME                         ...   NODE
cc-m2-app-54df8df9c9-68b5q   ...   gke-gke-cc-poc-nap-e2-standard-2-1bw7-d4923926-z5tf
cc-m2-app-54df8df9c9-vnnxq   ...   gke-gke-cc-poc-nap-e2-standard-2-1bw7-d4923926-z5tf

$ kubectl get nodes -L cloud.google.com/compute-class,cloud.google.com/machine-family
NAME                                                  ...   COMPUTE-CLASS   MACHINE-FAMILY
gke-gke-cc-poc-nap-e2-standard-2-1bw7-d4923926-z5tf   ...   basic-m2        e2

マシンファミリーは m2 ではなく e2 です。 ノードプール名も nap-e2-standard-2-... で、priority に書いたどの構成とも違うマシンが立っています。 basic-n2 で揃っていた compute-classmachine-family の 2 ラベルがここでは食い違っています。

この e2-standard-2 はどこから来たのでしょうか。 これは whenUnsatisfiable: ScaleUpAnyway の挙動によるものです。 About custom ComputeClasses in GKEScaleUpAnyway の説明には、今回の構成 (node pool auto-creation を使う Standard クラスタ) に関する記載があります。

In Standard clusters that use node pool auto-creation, GKE might create a new node pool that uses the default E2 machine series to place the Pod.

このとおり、priority をすべて満たせなかった結果として default の E2 シリーズの新規ノードプールが作られ、サイズは今回のリクエスト (cpu 200m / memory 256Mi × 2 Pod) を満たす e2-standard-2 になったようです。

events に記録されていた scale-up は、最初から fallback 先でした。

$ kubectl get events --sort-by=.lastTimestamp | grep -iE "scale|quota"
... TriggeredScaleUp  pod/cc-m2-app-...  Pod triggered scale-up:
    [{...nap-e2-standard-2-1bw7fz62-temporary-mig-... 0->1 (max: 1000)}]

m2 系のインスタンスグループへの TriggeredScaleUp は記録されておらず、scale-up の失敗を示す FailedScaleUp もありません。 Cloud Audit Log で compute.instances.insert のエラーを検索しても、結果は空でした。 確認した限り (Pod の events, insert 系のエラー監査ログ) では、m2 を調達しようとした痕跡がなかったため、autoscaler が API を呼び出す前に不適合と判定した可能性が高そうです。 (今回は、リソース枯渇を擬似再現しようとしたため上記のような結果となっていますが、quota が 1 以上の (実在する) machineType が本当にリージョン/ゾーンで枯渇している場合には、また違った挙動になってくるかもしれません)

さて、この fallback の痕跡はどこにも残らないのでしょうか。 kubectl から見える範囲にはありませんでしたが、cluster autoscaler が Cloud Logging に出力する visibility イベント (View cluster autoscaler events。Kubernetes の events とは別系統のログ) には、cc-m2-app の Pod 群を対象にした noScaleUp イベントが記録されていました。 観測したイベントの該当部分 (payload) は次のとおりです。

"napFailureReasons": [
  {
    "messageId": "no.scale.up.nap.pod.zonal.failing.predicates",
    "parameters": [
      "asia-northeast1-a",
      "expansion options filtered out and no longer considered"
    ]
  }
]

napFailureReasons は、その Pod グループのために NAP が新しいノードプールをプロビジョニングできない理由を示すフィールドです。 parameters に具体的な述語名はなく、m2 や quota の記載もありませんが、「NAP の候補が絞り込みの段階で除外された」ことは読み取れます。 これは、前段の「API を呼び出す前に不適合と判定した」という見立てとも整合します。

上記のとおり、ある程度は特定が可能なものの、現状、具体的にどのような fallback が起きたのかを示すログやイベントは出力されないように見えます。 今回は ComputeClass 名がたまたま basic-m2 だったため、compute-class ラベルと machine-family ラベルを見比べるだけで食い違いに気付けましたが、ComputeClass 名とマシンファミリー名は本来照合できる値ではありません (たとえば cost-optimized のような名前では、一致も不一致も定義できません)。

fallback の検知を運用に組み込むなら、ComputeClass の priorities の内容と、実ノードの machine-family / gke-spot などのラベルを照合する必要がありそうです (前述の visibility ログの noScaleUp イベントも手がかりにはなりますが、best-effort かつスロットリングありと明記されているため、これ単独での検知には向きません)。

ノードプールの自動削除

ワークロードを消した場合、自動作成されたノードプールはどれくらいで消えるのでしょうか。 ここまでの検証で残っている Deployment をすべて kubectl delete し、gcloud container node-pools list を 30 秒間隔でポーリングして、各ノードプールが一覧から消えるまでの時間を計測しました。

Pool machineType 由来 Pod 削除 → pool が一覧から消えるまで
nap-e2-standard-2-1bw7fz62 e2-standard-2 basic-m2 の fallback 約 5.6 分
nap-n2-standard-2-1pyhxgx0 n2-standard-2 basic-fallback-n2 約 13.8 分
nap-n2-standard-2-spot-quy0yl24 n2-standard-2 (spot) basic-n2 約 15.3 分

ノード削除のタイミングが autoscaling profile に依存することや、autoscalingPolicy.consolidationDelayMinutes で調整できることは About custom ComputeClasses in GKE に記載がありますが、既定の所要時間やエンドツーエンドの目安の記載は見つけられませんでした。 今回の計測では 5 分台から 15 分台まで、それなりに大きいばらつきが観測されています。

補足: ComputeClass の削除について

今回は「workload を削除し、プールが消えるのを待ってから ComputeClass を削除する」という順序で片付けたところ、すべてのプールが自然に消えました。 一方、別の検証で ComputeClass をプールの削除より先に消したケースでは、プール定義が長時間残り続ける挙動を観測しています (Part 3 で触れます)。 ComputeClass のリソースを削除する際は、workload → プールの削除確認 → ComputeClass の削除、の順が無難そうです。

おわりに

今回の Part 1 では、冒頭に「宣言した構成のマシンだけが立ち上がるのか」という問いを立てました。 priority を満たせる状況では、指定した構成のマシン (spot の N2) の調達から、taint と toleration の自動挿入による排他制御まで、宣言したとおりに機能することが確認できました。 一方、quota が 0 のマシンファミリーをあえて指定した basic-m2 では、ScaleUpAnyway の指定によって default の E2 シリーズ (e2-standard-2) への fallback が起きることを確認できましたが、その詳細がログやイベントからはやや追いにくい現状も明らかになりました。

今回の検証で起動したノードはいずれも、taint こそ付いているものの、Standard クラスタの一般的なノードでした。 ノードプールを自分で消すこともできますし、管理の主体はあくまでユーザ側にあります。 ComputeClass にはもうひとつ、autopilot.enabled というフィールドがありました。 これを有効にすると、立つノードは Google の管理下に移ります。 Standard クラスタであることを維持しながら、ノードの管理を手放すという、一見矛盾したようなこの構成では、具体的に何が変わるのでしょうか。 Part 2 では、組み込みの autopilot ComputeClass を使って、その仕組みと挙動を確認します。

Agent Identity を用いたエージェント毎のきめ細かい権限制御

Agent Identity は、AI エージェントに固有の ID を与え、その ID で他のリソースに対して安全に認証するための仕組みです。 Agent Identity 本体は GA しているものの、関連の gcloud コマンドがまだ alpha / beta であったり、誤解を招くような API があったりと、現時点では利用時に注意しておきたいポイントがいくつか見受けられます。

今回は、エージェントが (1) Google Cloud のリソースに、(2) 外部システムに、それぞれ「誰として・どうやって」アクセスを実現しているのか、という観点で、実機検証の結果を交えて整理してみたいと思います。

Agent Identity とは何を解決するものか

エージェントは自律的にタスクの解決プロセスを考え、Google Cloud のリソース、MCP サーバ、外部 SaaS、そして別のエージェントなど様々な相手にアクセスします。

このとき問題になるのが、そのエージェントは「誰」のつもりで認証するのか、ということです。 従来は 1 つのサービスアカウントを複数のエージェントで共有するケースも少なくなかったかと思います。 しかし、サービスアカウントを共有する場合、実際には「どのエージェントが何をしたか」を追いづらい、個々のエージェントにとって必要のない権限が付与されてしまっている、といった問題がありました。 また、エージェントによるリソースへのアクセスは、エージェント自身の権限で行われる場合と、ユーザーの権限を委任されて行われる場合がありますが、サービスアカウントが用いられる場合は (アプリケーションのログがあれば話は別ですが、少なくとも Cloud Audit Logs のみでは) これらの区別がつけられません。

そうした問題を解決するために登場したのが Agent Identity です。 公式ドキュメント(Agent Identity overview)では、Agent Identity の役割を次のように説明しています。

Agent Identity provides a strongly attested, cryptographic identity for each agent that is based on the SPIFFE standard. With Agent Identity, your agent can securely authenticate to MCP servers, cloud resources, endpoints, and other agents, acting either on its own behalf or on behalf of an end user.

つまり Agent Identity は、ざっくり言うと「エージェントごとに固有の身元情報 (SPIFFE ID + X.509) を与え、その ID でエージェントが Google Cloud / 外部 / 別エージェントに対して、エージェント自身として、あるいはユーザの代理として認証できるようにし、その行動を監査可能にする」ための仕組みです。

(注:厳密には、IAM プリンシパルとして機能する SPIFFE ID を発行する Agent Identity と、エージェントが外部システムにアクセスするための認証情報の保管やトークン取得の仲介を行う IAM Connectors は別の API です (前者は Google Cloud リソースへの認証に用いる一方、後者は外部システムへの認証に用いる) 。が、上記ではいずれもエージェントの認証に関わる機能として広義の Agent Identity に含めて扱っています)

検証の前提

今回の実機検証の記録は、2026 年 6 月時点のものです。 リージョンは asia-northeast1 を利用しています。

現状、Agent Identity 本体、Agent Runtime 連携、Agent Identity auth manager / IAM Connectors などの各機能について、一部は GA しており、一部はプレビュー段階であるなど、ローンチのステージが混在しています。 特に外部リソースの認証に使う Agent Identity auth manager / 2-legged OAuth 周辺は API がプレビュー扱いのため、コマンド体系や挙動は今後変わる可能性があります。

SPIFFE ID で Google Cloud リソースへアクセスする

Google Cloud リソースへのアクセスを per-agent な権限制御で行うには、エージェントの SPIFFE ID (spiffe://...) から導出される IAM プリンシパル (principal://... 形式、以下「エージェント ID」) を使用します。 エージェント ID は IAM プリンシパルとして機能するため、IAM バインディングでエージェント ID 毎に必要最低限の権限を付与すれば良い、ということになります。

Agent Runtime にデプロイする場合、既定では Google マネージドなサービスエージェントの権限が用いられます (後述の補足を参照) 。 なので、これをエージェント ID に切り替えるには、デプロイ時に identity_type=AGENT_IDENTITY を指定します。

agent_engine = client.agent_engines.create(
    agent=MyAgent(),
    config=types.AgentEngineConfig(
        identity_type=types.IdentityType.AGENT_IDENTITY,  # ← ここ
        # requirements ほかは省略
    ),
)

補足: Agent Runtime のデフォルトの Runtime Identity

Agent Runtime (旧 Agent Engine) を使って、オプションの指定なくエージェントをデプロイすると、既定で割り当てられる ID は per-agent の SPIFFE ID ではなく、Google マネージドのサービスエージェントのメールアドレスになります。

割り当てられた ID は、Agent Registry の RuntimeIdentity 属性から確認することができます。

"agentregistry.googleapis.com/system/RuntimeIdentity": {
  "principal": "sa://service-<PROJECT_NUMBER>@gcp-sa-aiplatform-re.iam.gserviceaccount.com"
}

上記の例では、sa://... の形式の値がこのエージェントの実行 ID となっています。 公式ドキュメント によると、このメールアドレスは AI Platform Reasoning Engine Service Agent という Google マネージドのサービスエージェントのものです (aiplatform の有効化時に自動付与されるサービスエージェント) 。

公式ドキュメントの Agent identity with Agent Runtime によると、後方互換性のためにこのような仕様となっているようですね。

If the identity flag isn't configured, the Agent Runtime instance continues to use service accounts. This ensures backward compatibility if you already implemented Infrastructure-as-Code-based deployments on your agents.

IAM Connectors を用いて外部リソースへアクセスする

サードパーティの SaaS API など、Google Cloud 外のリソースにアクセスする場合は、Google が発行する SPIFFE ID のトラストドメインと関係が無いため、エージェント ID を直接提示しても検証ができません。 そのため、外部リソースにアクセスする場合は IAM Connectors という機能を使います。 エージェントは IAM Connectors へのアクセス時に SPIFFE のエージェント ID で認証を行い (iamconnectors.user 権限が必要)、IAM Connectors が外部リソースに対するアクセストークン取得のリクエストを代行し、得られたアクセストークンをエージェントに渡す、という仕組みになっています。

なお、IAM Connectors から外部リソースへのアクセス時には、connector に保存された OAuth の client_id / client_secret を用いた Client Credentials Grant が行われます。 また、アクセストークン取得後はエージェントが直接外部リソースにアクセスするため実際の API コールなどには IAM Connectors は介在しません。

また、Agent Identity (というか IAM Connectors) は、エージェント自身として外部システムに認証する 2-legged OAuth (クライアントクレデンシャルを用いる。エンドユーザーは介在しない) と、3-legged OAuth (authorization code を用いる。特定のエンドユーザーの代理として外部システムにアクセスするため、ユーザーの同意が必要) の両方に対応しています。

今回は、ユーザーの同意を伴わない 2-legged OAuth を試してみます。

構築の流れ

実際に試した構成は次のとおりです。 手順 1 は本来外部リソース (例えば サードパーティの SaaS API など) の構築に相当する部分で、今回は検証用に自分で立てた OAuth サーバーで代用しています。

1. 外部リソースの用意

Client Credentials Grant でトークンを発行する /token と、Bearer トークンを検証する /protected を持つ適当なサーバーを Cloud Run でデプロイしました。 OAuth の client_id / client_secret は自分で発行したテスト値です。

$ gcloud run deploy <TOKEN_SERVER> \
    --source <DIR> --region asia-northeast1 --project=<PROJECT_ID> \
    --allow-unauthenticated --env-vars-file env.yaml \
    --min-instances 0 --max-instances 1

2. 2-legged OAuth connector の作成

トークンエンドポイントを手順 1 のサーバーに向け、テスト用の client_id / client_secret を IAM Connectors に登録します。

$ gcloud alpha agent-identity connectors create <CONNECTOR_ID> \
    --project=<PROJECT_ID> --location=asia-northeast1 \
    --two-legged-oauth-client-id=... \
    --two-legged-oauth-client-secret=... \
    --two-legged-oauth-token-endpoint=https://<TOKEN_SERVER>/token

3. エージェント ID を使用して Agent Runtime にエージェントをデプロイ

前述のとおり、Agent Runtime へのデプロイ時に identity_type=AGENT_IDENTITY を指定することで、実行 ID がエージェント ID (principal://...) になります。

4. エージェントへの権限付与

エージェント ID に対して roles/iamconnectors.user を付与し、IAM Connectors の利用を許可します。

$ gcloud alpha agent-identity connectors add-iam-policy-binding <CONNECTOR_ID> \
    --project=<PROJECT_ID> --location=asia-northeast1 \
    --role=roles/iamconnectors.user \
    --member="principal://agents.global.org-<ORG_ID>.system.id.goog/resources/aiplatform/projects/<PROJECT_NUMBER>/locations/asia-northeast1/reasoningEngines/<ENGINE_ID>"

なお、今回は Agent Identity の PoC 用途に限るため、エージェントのコードが retrieveCredentials を呼ぶときに connector のフルリソース名を直接指定することで Binding の登録を不要としました。 ちゃんと使う場合は Agent Registry の Binding で エージェント・宛先リソース・connector の対応関係を登録して管理しましょう。

認証の流れ

前述の環境を構築すると、以下のような流れで認証・認可が行われ、エージェントが外部リソースへアクセスできるようになります。

1. エージェント → IAM Connectors

外部リソースへのアクセスを必要とするエージェントが、自身の エージェント ID (SPIFFE ID) で、IAM Connectors の API (iamconnectorcredentials.retrieveCredentials) にアクセストークンを要求します。 このときの認証は、Google Cloud リソースへのアクセスとなるため、エージェント ID による IAM 認証です。 前述のとおり、IAM Connectors へのリクエストには roles/iamconnectors.user が必要です。

2. IAM Connectors → 外部のトークン発行先

IAM Connectors はエージェントからのリクエストを受けて、外部リソース (のトークン発行先) に対して OAuth のクライアントクレデンシャル交換を要求します。 このとき、IAM Connectors の認証 (外部リソースから見て、正しいクライアントからのリクエストであるか) に は、SPIFFE ではなく、connector に保存された client_id / client_secret を使った Basic 認証が使われます。 成功すると、IAM Connectors は取得したアクセストークンをエージェントに返します。

ちなみに、IAM Connector から /token に送られてきた HTTP リクエストを確認してみたところ、User-Agent: Google のカスタムヘッダが付与された HTTP POST となっていました。 中身は標準的な OAuth 2.0 の Client Credentials Grant で、grant_type=client_credentials、クライアント認証は HTTP Basic (Authorization: Basic <base64(client_id:client_secret)>) でした。 トークンサーバー側のログにも via_basic: true (Authorization ヘッダが Basic で始まる) が記録されており、復号すると client_id:client_secret 構造だったことが確認できました。

POST /token  200  User-Agent: Google
     grant_type=client_credentials / client_id=<CLIENT_ID> / HTTP Basic で認証

3. エージェント → OAuth で保護された外部リソース

エージェントは、手順 2 で得たアクセストークンを Authorization: Bearer ... として提示し、外部リソース側はトークンの有効性を確認して認可します。

呼び出しの結果、次のように /protected へのアクセスができました。

{
  "retrieve_ok": true,
  "protected_status": 200,
  "protected_body": "{\"message\":\"2LO-E2E-OK\",\"ok\":true}",
  "header_field": "Authorization: Bearer"
}

なお、エージェント自身は client_secret を保持しません (connector が保管し、describe にも含まれない)。 シークレットを IAM Connectors に隔離することで、エージェントは「エージェント ID (SPIFFE) でトークン取得を依頼するだけ」で良い、という構造になっています。

実機検証を通じて得られた知見

1. agentidentity.googleapis.com は必要か

ちょっと紛らわしいですが、Agent Identity の各機能は Agent Identity API (agentidentity.googleapis.com) を有効化しなくても使えるし、現状 Agent Identity API を有効化する方法はなさそう、という話です。

現在、gcloud alpha には gcloud alpha agent-identity auth-providers というコマンドがあり、一見すると外部リソースへのアクセスに必要そうに見えるのですが、実際は (上記の 2-legged OAuth には) 必要ありません。 実行してみると、この機能を利用するには Agent Identity API (agentidentity.googleapis.com) の有効化が必要、というメッセージが返ってきます。

$ gcloud alpha agent-identity auth-providers list --project=<PROJECT_ID> --location=global
  
ERROR: (gcloud.alpha.agent-identity.auth-providers.list) PERMISSION_DENIED:
Agent Identity API has not been used in project <PROJECT_ID>
before or it is disabled. Enable it by visiting
https://console.developers.google.com/apis/api/agentidentity.googleapis.com/overview?project=...
      service: agentidentity.googleapis.com
      serviceTitle: Agent Identity API
    reason: SERVICE_DISABLED

ならばということで Agent Identity API (agentidentity.googleapis.com) の有効化を試みると、次のように拒否されます。

$ gcloud services enable agentidentity.googleapis.com --project=<PROJECT_ID>

ERROR: (gcloud.services.enable) PERMISSION_DENIED: Permission denied to enable
service [agentidentity.googleapis.com]
Help Token: ...
- '@type': type.googleapis.com/google.rpc.PreconditionFailure
  violations:
  - subject: '110002'
    type: googleapis.com
- '@type': type.googleapis.com/google.rpc.ErrorInfo
  domain: serviceusage.googleapis.com
  reason: AUTH_PERMISSION_DENIED

gcloud services list --available のリストに出てこないため、この検証用プロジェクトで有効化できないことはわかりますが、上記の reason を見るに有効化を拒否している (NOT_FOUND などではなく AUTH_PERMISSION_DENIED) ため、API 自体は存在していそうな感じがあります。

しかし、例えば前述のように 2-legged OAuth を構成するとして、上記の API やコマンドが使えないことで困ることは特にありません。 エージェント ID を用いた外部リソースへのアクセスを解説している一連の 公式ドキュメント では、この Agent Identity API (agentidentity.googleapis.com) の有効化に関する記述は見受けられませんでした。 実際、この API を有効化せずとも SPIFFE のエージェント ID は発行できていますし、IAM プリンシパルとして機能しています。

少なくとも今回検証した 2-legged OAuth による外部リソースへのアクセスでは、公式ドキュメントに記載のとおり gcloud alpha agent-identity connectors ... を使えばよく、gcloud alpha agent-identity auth-providers ... や agentidentity.googleapis.com の明示的な有効化は不要でした。

ちなみに、トークン取得のための API iamconnectorcredentials.retrieveCredentials については、以下の様子からアルファ版といった位置付けと考えられるため、今後仕様が変更されるかもしれません。

  • API バージョンが v1alpha となっている
  • クライアントライブラリ google-cloud-iamconnectorcredentials0.1.0 となっている

2. (現状の) トークン取得 API (iamconnectorcredentials) への通信プロトコルは REST のみ

注: このセクションで扱う API は、connector の管理 (iamconnectors.googleapis.com / gcloud alpha agent-identity connectors) ではなく、実行時にトークンを取得する iamconnectorcredentials.googleapis.com (クライアントライブラリ google-cloud-iamconnectorcredentialsretrieve_credentials) のほうです。

Google Cloud のクライアントライブラリは、API への通信に gRPC と REST を選べることが多いですが、このトークン取得 API は現状 REST のみが有効となっています。 公式の ADK の内部実装でも、特に選択の余地なく REST が使われています。

ちなみに、このトークン取得 API を gRPC で呼ぶと、connector が存在し IAM で適切な権限が付与されていたとしても NOT_FOUND が返ってきます。

3. (現状の) トークン取得 API (iamconnectorcredentials) は LRO 未対応

Google Cloud の API は、時間のかかるリクエストに対してオペレーション ID を素早く返却し、クライアントがポーリングして完了を待つことがあります。 その場合、ライブラリの .result() がポーリングを実行して完了待ちを行います。 今回紹介したトークン取得 API もオペレーション ID を返すことができますが、実際には LRO をサポートしていないため、.result() (内部で GetOperation を呼ぶ) を使うとそのオペレーションを取得しようとして NOT_FOUND になりました。

この件については ADK の内部実装にコメントがあり、「現状このサービスは LRO 未対応なので、.done()/.result() が使えず、自前で再試行する」という対処になっていることがわかりました。

# 1. The service does not yet support LROs, so even though the
#    retrieve_credentials method returns an Operation object, the methods like
#    operation.done() and operation.result() will not work yet.

ADK を使う場合は、GcpAuthProviderScheme で connector を指定するだけで、プロトコルの設定も LRO ポーリングも ADK が処理してくれます。 公式ドキュメントでも API を直接呼ぶような使い方は紹介されていませんし、ADK を使っておけば意識する必要がない話ではあるのですが、PoC などで API を直接呼ぶ際は注意です。

4. セキュリティ強度の違い

Agent Identity は mTLS / DPoP / cert-binding によって「トークンを盗まれても再利用できない」ことを売りにしています (参考) が、これは Google Cloud リソースに対するアクセストークンの話に限るという点は注意しておきたいところです。 2-legged OAuth の検証で見たように、エージェントが IAM Connectors から受け取って外部リソースに直接提示する OAuth トークンは、通常の bearer トークンに過ぎません。 そのため、「エージェント自身が client_secret を持たない」のはそのとおりではあるものの、結果としてエージェントが受け取り、外部リソースに提示するトークン自体は cert-binding の保護外になります。

どう使っていくか

Google Cloud リソースの per-agent な認証・認可という選択肢が増えたのは良いことで、その要件がある場合の実装コストが低く、セキュアな手段として Agent Identity は便利に使えそうです。 一方で、全てのエージェントに対して per-agent な IAM バインディングをきめ細かく行っていくとすると、エージェントの数が数百や数千になったときの監査・管理コストが大きな課題になりそうです。 機微情報を扱うエージェントや、監査要件の厳しいエージェントには Agent Identity で per-agent なきめ細かい権限制御や監査・追跡容易性を確保しておき、開発用途のちょっとしたエージェントや、開発したばかりでソーク中のエージェントなどは、ある程度まとまった権限を付与したサービスアカウントを共用するようなアプローチも有効かもしれません。

また、エージェントから外部リソースへのアクセスについては、IAM Connectors が有力な選択肢にはなったものの、これ以外に方法がないということではありません。 「個々のエージェントから隔離されたシークレットで外部リソースにアクセスする」という目的であれば、公式ドキュメントにもあるとおり、IAM Connectors を使わずに Secret Manager にクライアントシークレットを置き、エージェント ID に secretAccessor を付与して ADC で取得するパターンも依然として選択肢になり得ます。 このあたりはアクセス先の外部リソースの制約にも依存することも意識しておくと良さそうです。 外部リソースが例えばアカウント単位で課金するルールの場合、per-agent で大量のアカウントを調達しようとするとコスト影響が大きくなるかもしれませんし、あるいは AI エージェント間でのアカウント共用を禁止するような制約があるかもしれません。

まとめ

Agent Identity は per-agent で権限制御を行うための仕組みで、SPIFFE ID と X.509 証明書を持ち、発行された ID は IAM プリンシパルとして機能します。 エージェントが外部リソースにアクセスする際は、IAM Connectors を介して相手のアクセストークンを取得します。 今回は 2-legged OAuth での検証の様子をご紹介しました。

公式ドキュメントでは IAM Connectors を auth provider や auth manager とも表記しているため紛らわしいですが、現状は gcloud alpha agent-identity auth-providers や Agent Identity API (agentidentity.googleapis.com) は必要なく、gcloud alpha agent-identity connectors を利用します。 今回試した API がアルファ扱いということもあり、今後挙動が変わる可能性はありますが、同じようなところで詰まっている人の参考になれば幸いです。

Cloud Run Jobs を実行したとき、アプリが動き出すまでに起きていること

Cloud Run Jobs は Google Cloud のサーバレスなジョブ実行基盤です。 コンテナを起動して任意の処理を 1 回以上実行し、終わったら自動でインスタンスを破棄してくれるという、バッチや単発タスク向けのサービスになっています。

本記事では、「Cloud Run Jobs にジョブの実行をリクエストしてから、アプリケーションコンテナのコードが実際に動き出すまでの過程で、どこにどれだけの時間がかかっているのか」の内訳を見ていきたいと思います。 今回は gcloud run jobs executions describe で見える区分と、そこに書かれている message の内容を頼りに整理してみました。 また、上記のインフラ側のイベント記録と、アプリケーションログのタイムスタンプとの間にある時間差についてもちょっとした実験を行って確認しています。

TL;DR

  • execution の createTime からアプリの ENTRYPOINT が exec されるまでに、合計 ~8.0 秒程度 (N=10 中央値、Cloud Run gen2、asia-northeast1) を要した
  • Cloud Run Jobs 実行時のインフラ側のイベントは image import / provision / schedule wait / sandbox start / Started が識別でき、最も時間がかかるのは sandbox start で ~5.0 秒程度
  • Cloud Run Jobs のインフラの準備完了 (Started condition の時点) から、実際にアプリケーションコンテナの ENTRYPOINT のコマンドが実行開始されるまでのラグは、今回の検証では中央値 113 ms 程度 (異なるクロック間の比較なので精度は何とも言えないが、おおむね 100 ms 前後の何かが乗っていそう)

describe で見えるイベント (アプリ exec の手前まで)

gcloud run jobs executions describe を打つと、conditions というフィールドに何件かのコンディションが並びます。 それぞれに lastTransitionTimemessage が付いていて、Cloud Run 側が「いつ何が起きたか」を記録していることがわかります。

$ gcloud run jobs executions describe <EXECUTION_NAME> \
    --region=asia-northeast1 --project=<PROJECT_ID> --format=yaml

主要部分の抜粋がこちらです。

createTime: 2026-05-29T02:54:28.268653Z
startTime: 2026-05-29T02:54:32.294535Z
completionTime: 2026-05-29T02:54:43.909070Z
conditions:
- type: ContainerReady
  lastTransitionTime: 2026-05-29T02:54:30.168169Z
  message: Imported container image in 1.9s.
  state: CONDITION_SUCCEEDED
- type: ResourcesAvailable
  lastTransitionTime: 2026-05-29T02:54:30.523283Z
  message: Provisioned imported containers.
  state: CONDITION_SUCCEEDED
- type: Started
  lastTransitionTime: 2026-05-29T02:54:37.851445Z
  message: Started deployed execution in 7.32s.
  state: CONDITION_SUCCEEDED
- type: Completed
  lastTransitionTime: 2026-05-29T02:54:43.909070Z
  message: Execution completed successfully in 11.61s.
  state: CONDITION_SUCCEEDED

これを見ると、createTime の時点から ContainerReadyResourcesAvailableStartedCompleted の順に時刻が並んでいて、それぞれの message が「何が完了したのか」を自己説明的に書いてくれています。

これらの type の値 (ContainerReady / ResourcesAvailable / Started / Completed) の正確な定義については、私が当たった範囲の v2 REST リファレンスでは明確なものを見つけられませんでした。 本記事では、各 condition の message が示す内容を、そのままその区分の意味として扱います。

実測した中央値 (N=10、後述) を、yaml に見える主要なタイムスタンプの差分として並べると、次のようになりました (以下の表で ContainerReady などの condition 名は、その condition の lastTransitionTime を指します) 。

区分 区間 中央値
image import createTime → ContainerReady ~703 ms
provision ContainerReady → ResourcesAvailable ~350 ms
schedule wait ResourcesAvailable → startTime ~1,835 ms
sandbox start startTime → Started ~5,030 ms

schedule waitsandbox start は、対応する message の無い区間に対して便宜上著者が付けたラベルです。その区間に実際に何が走っているかは公式の message には書かれていないため、推測を含んだ呼称として読んでください (例えば sandbox start の区間は、gen2 の microVM provision とコンテナランタイム起動が走っているあたりだろう、というあくまで推測です) 。

合計すると、createTime から Started までで ~7.9 秒。 これだけでも、Cloud Run Jobs を実行してからアプリのコードが動き出す手前まで、いくつかの段階があることが見て取れます。

describe には出てこない「Started のあと」

Started の messageStarted deployed execution in 7.32s. で、これを見る限り execution が動き出した時刻と思われます。 ただ、これがそのまま「コンテナの ENTRYPOINT が exec された瞬間」と一致するかどうかまでは、message や公式ドキュメントの該当箇所で触れられていません。 例えば Node.js のようにランタイムの初期化にそれなりの時間がかかりうるようなアプリケーションの場合は、アプリケーションの最初のログ出力のタイミングを頼りにしようとすると、実際の ENTRYPOINT が exec された瞬間とはかなり乖離してしまう可能性があります。

今回は簡易な検証として、ENTRYPOINT の先頭にシェルを挟み、本来のコマンドの直前に時刻を出力するようにしてみました。

ENTRYPOINT ["/bin/sh","-c","echo PHASE container_exec epoch_ns=$(date +%s%N); exec node dist/main.js"]

/bin/sh の起動コストは数 ms 程度と想定されるので、今回のざっくりとした検証であれば、ここで出力した時刻を「コンテナ exec の瞬間」と見なしても概ね問題ないと考えました。 時刻取得に使用している date +%s%N では Unix epoch をナノ秒精度で取れます (node:20-slim のような coreutils が入っているベースイメージならそのまま使えます)。 exec node dist/main.jsexec で、シェルが node プロセスに置き換わります。PID 1 を node が引き継ぐので、余計なシェルプロセスがぶら下がらないようにしています。

なお、このログ出力は普通の stdout なので Cloud Logging に流れます。 あとは Started condition の lastTransitionTime と、このログの epoch を比較すれば、Started → ENTRYPOINT exec のラグが (おおよそ) 見えてくるのではと考えました。

計測結果: Started → ENTRYPOINT 開始

Cloud Run Jobs のジョブを 1 件用意し、N=10 回 をコールドスタートで実行し、それぞれの execution について「Started condition の lastTransitionTime」と「container_exec ログの epoch」の差を取りました。

結果(ms):

指標
中央値 113
min – max 52 – 195
mean 119

Started のあと ENTRYPOINT が動き出すまでに、おおむね 100 ms 前後のラグが乗っていそうな感触です。

ただし、この値はそのまま「ENTRYPOINT が動き出すまでに絶対的なラグがある」ものとして扱うのには慎重になりたいところです。 比較しているのは、Cloud Run のコントロールプレーン側が打った Started の時刻と、コンテナを実行しているインスタンス側の時計が打った container_exec の時刻です。 両者は別々のクロックなので、計測値には両者の時計のずれ (clock skew) が含まれます。

アプリが動き出すまでの全体内訳

ここまでの区分を、createTime から ENTRYPOINT exec までの時系列で並べてみると、以下のようになります。 いずれも N=10 中央値です。

区分 中央値 (ms) 取り方
image import 703 createTime → ContainerReady
provision 350 ContainerReady → ResourcesAvailable
schedule wait 1,835 ResourcesAvailable → startTime
sandbox start 5,030 startTime → Started
Started → ENTRYPOINT 開始 113 Started → ログの container_exec
アプリ exec まで合計 ~8,031

※ 「アプリ exec まで合計」は各行の中央値を単純加算した参考値です。end-to-end (createTime → ENTRYPOINT exec) の計測で中央値を取った場合とは一致しない可能性があります。

このうち、上の 4 段は describe から読み取れる区分です。 最後の Started → ENTRYPOINT のラストワンマイルが、ENTRYPOINT に手を入れたことで見えてきた区間です (ただし前述のとおり clock skew の懸念は残る) 。

支配的なのは sandbox start で ~5 秒、一番短い区間が ENTRYPOINT exec までの 113 ms でした。 なお、最初の image import の区間の所要時間は、イメージサイズやキャッシュの有無によって大きく変動する可能性があります (今回の計測値は全てキャッシュヒット状態のもの) 。

注意点

数値の前提と、留保しておきたい点をいくつか書いておきます。

  • 全部 N=10 中央値の値です。インフラ側のフェーズのなかでも sandbox start は特に試行ごとの分散が大きく、レンジで見ると数秒単位で振れています
  • Cloud Run gen2、asia-northeast1、cpu=1 / memory=512Minode:20-slim ベースのコンテナという単一構成での観測値です。条件が違えば値は当然変わります
  • Started → ENTRYPOINT 開始のラグは、コントロールプレーンとインスタンスの時計のずれ (clock skew) を含む値です。精度の信頼できる値として扱わず、「100 ms 前後の何かが乗っている」という見立てに留めるのが安全でしょう
  • condition の type の正確な定義は、私が当たった範囲では明確な記載を見つけられませんでした。本記事では message を区分の意味の根拠として扱っています

補足: container/startup_latencies メトリクスについて

Cloud Monitoring には run.googleapis.com/container/startup_latencies という「インスタンス起動の所要時間」を distribution で返すメトリクスがあります。 descriptor を見ると monitoredResourceTypescloud_run_job も入っているので、createTime → Started (~7.9 秒) と突き合わせれば対応関係がわかるはず、と思って実測してみたのですが、結果はそうなりませんでした。

  • 本検証時点 (2026-05-30 / asia-northeast1) で、今回の検証を通じて計 80 回超の Job 実行を行いましたが、メトリクスは bucketOptions (ヒストグラムの bin スキーマ) だけが返り、count / mean / bucketCounts がすべて空でした。つまり実データが 1 点も投入されていない状態でした。launchStage が BETA なので、Jobs に対しては実装が追いついていない時期に当たったのかもしれません
  • 比較として、同プロジェクトに一時的に立てていた Cloud Run (Services) の同じメトリクスは値が記録されており、観測できた 2 サンプルでは mean ~72 ms / ~128 ms といったところでした (サンプル数が小さいので参考値)
  • メトリクス名は「container startup latencies」となっていますが、descriptor の説明文も "Distribution of time spent starting a new container instance in milliseconds." とシンプルで、ここでの「starting a new container instance」が image pull / schedule / sandbox 起動まで含むのか、それともコンテナプロセスを起こす狭い区間だけなのかは明示されていません。前述の観測値を見る限り、Conditions に記録されている各区間の全てを合わせたものではなく、もっと狭い区間を捕捉しているように見えます

以上から、startup_latencies メトリクスは本記事で扱った createTime → Started の代替にはならず、Jobs では現時点でそもそも値が出ないようでした。

まとめ

Cloud Run Jobs を実行してから、自分のアプリのコードが動き出すまでに何が起きているのか、について describe コマンドから得られる情報を中心に整理してみました。 Job の実行をリクエストしてから、アプリケーションのログが出力され始めるまでにそれなりの時間差がある場合は、今回ご紹介したような方法でインフラの準備作業にかかる各区間の所要時間を整理してみると、どこで時間がかかっているのかを特定する手がかりになるかもしれません。

  • gcloud run jobs executions describe の conditions と message を読めば、image import / provision / schedule wait / sandbox start / Started までの主要な区分と所要時間がわかる
  • describe には出てこない「Started のあと、ENTRYPOINT が exec されるまで」の区間だけは、ENTRYPOINT を /bin/sh -c "echo ...; exec ..." で包む形で計装した
  • 結果は N=10 中央値 113 ms、レンジ 52–195 ms。異なるクロック間の比較で時計のずれを含んだ値なので精度は保証できないものの、ms 100 オーダーの何かが乗っていそうな感触

カスタムドメインで GCS 署名付き URL を配信する構成を考える (Part 3)

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_urlbucket_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")

# 1. Path Style (host=storage.googleapis.com, path=/<bucket>/<object>) で署名
url = bucket.blob("test-small.txt").generate_signed_url(
    version="v4",
    expiration=timedelta(minutes=15),
    method="GET",
    virtual_hosted_style=False,   # Path Style を明示
)
# 例: https://storage.googleapis.com/gcs-psc-poc.example.com/test-small.txt?X-Goog-...

# 2. 配信時だけホスト部をカスタムドメインに文字列置換
delivery_url = url.replace(
    "https://storage.googleapis.com",
    "https://gcs-psc-poc.example.com",
)
# 例: https://gcs-psc-poc.example.com/gcs-psc-poc.example.com/test-small.txt?X-Goog-...

Internet NEG で使えた bucket_bound_hostname は PSC NEG 接続では使えません (検証 1 より、使うと 404 になる)。代わりに virtual_hosted_style=False で Path Style の署名付き URL を生成し、クライアントに渡す直前にホスト部だけを置換する、というプロセスになります。

この署名付き URL をクライアントが叩くと、次の 4 ステップでリクエストが処理されるはずです。

  1. クライアントが Host: gcs-psc-poc.example.com、path /gcs-psc-poc.example.com/test-small.txt で LB に到着 (URL のホスト部から Host ヘッダが自動生成される)
  2. LB → PSC NEG に転送、Envoy が Host を storage.googleapis.com に書き換え (推定)
  3. GCS フロントエンドで受信: Host: storage.googleapis.com、path /gcs-psc-poc.example.com/test-small.txt (Path Style としてバケット解決)
  4. 署名は 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 点に収斂します。

  1. PSC NEG (Google API ターゲット) は LB → upstream 区間で Host をどう扱うか
  2. 往路で Host が書き換えられるなら、復路の Location はどう扱われるか
  3. 上の 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_NAMEhttps://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.comstorage.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 と共通する前提 (バケット名 = ドメイン名の制約など) に加えて、以下の運用上の手当てが必要になります。

  1. Path Style 署名 + ホスト置換: virtual_hosted_style=Falsegenerate_signed_url を呼び、クライアントに渡す直前にホスト部を置換する。bucket_bound_hostname は使えない (404 になる)。発行側の実装ミスを防ぐため、この置換をライブラリ関数で包んでおくのが無難です。
  2. URL にバケット名がパスとして露出することを許容: バケット名を URL から隠蔽したい要件がある場合、PSC NEG は不適です。バケット名を UUID など任意の名前にしてもパスには必ず出てきます。
  3. タイムアウト 30 秒による上限を前提にする: 上り帯域が細い・レイテンシが大きいクライアントから数 GiB を単一 PUT する用途は現実的ではありません。大容量アップロードが想定される場合は、各 chunk を 30 秒以内に収まるよう刻んだ Resumable Upload を使うか、Internet NEG を選択します。
  4. デフォルト挙動の継続性を外形監視で担保する: 考察で触れた「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 の仕組みをより具体的に把握できたように思います。これらの記事がどなたかの参考になれば幸いです。

カスタムドメインで GCS 署名付き URL を配信する構成を考える (Part 2)

前回の記事 (Part 1)では、エンドユーザとのファイル授受を GCS 署名付き URL で行う構成について、エンタープライズで想定される要件 (カスタムドメイン対応・HTTPS 必須・CDN キャッシュ禁止・コンピュートリソースの自前管理の回避) を整理し、採用可能な構成候補を洗い出しました。結論として、先に挙げた案 A〜C はいずれも今回の要件を十分に満たせず、候補となりうるのは「案 D: External Application Load Balancer (以降 ALB) + Internet NEG + GCS」と「案 E: External ALB + PSC NEG + GCS」の 2 つに絞られました。

Part 1 を読んでいない方向けに補足しておくと、本構成の着想は「Internet NEG を使って GCS を外部 FQDN として扱えば、LB → GCS 間の通信はクライアントの署名付き URL を壊さずに素通しで流せるのではないか」という仮説に基づいています。公式ドキュメントには、Internet NEG で GCS に中継する「構成全体としての可否」について明確な記述がありません (個別挙動の仕様、たとえば LB → upstream 区間での Host 透過や SNI 置換などについては記載があり、後述の考察で確認します)。

そこで、本記事 (Part 2) では、案 D を実機で検証し、GCS 署名付き URL が期待どおりに動作するかを確認します。検証項目は GET / PUT / Range GET / Resumable Upload / 大容量ファイル (3 GiB) 単一 PUT の 5 点で、いずれも実運用で想定される操作です。また、検証結果をふまえてこの構成の特徴を考察し、採用する際の注意点をまとめていきます。

本シリーズが扱う要件を再掲しておきます。

要件 要求水準
カスタムドメイン 必須
HTTPS 必須
Cloud CDN などエッジへのキャッシュ 禁止
自己管理のコンピュートリソース できれば持ちたくない

また、これまでの関連記事は以下から参照できます。

なお、本記事に掲載するコマンド実行結果や参照する公式ドキュメントの記載は Google Cloud のアップデートに応じて変更される可能性があり、あくまで検証時点 (2026-04) のスナップショットです。現行仕様を確認する際は各引用元の最新版をあわせて参照してください。

LB 構成における Internet NEG の位置づけ

検証に入る前に、Internet NEG が LB の構成上どのような位置に入るのかを整理しておきます。

Google Cloud の ALB (Global External Application Load Balancer) は、以下のリソース群から組み立てられます。括弧内は検証時の設定を補記しています。

Forwarding Rule (Global static IP + :443)
   └── Target HTTPS Proxy
        ├── SSL Certificate (Google-managed)
        └── URL Map
             └── Backend Service (HTTPS, CDN 無効, timeoutSec=3600)
                  └── Internet NEG (network-endpoint-type=internet-fqdn-port)
                       └── endpoint: c.storage.googleapis.com:443

Backend Service の配下に NEG を登録することで、LB はその NEG のエンドポイントに対してアップストリーム方向の HTTPS 接続を確立します。

Internet NEG はこのエンドポイントをパブリックインターネット上の FQDN / IP アドレスにできる NEG タイプで、FQDN を指定する INTERNET_FQDN_PORT と IP アドレスを指定する INTERNET_IP_PORT の 2 種類があります (Network endpoint groups overview)。今回は FQDN 指定です。

なお、Internet NEG を Backend Service にアタッチするには、Backend Service のプロトコルを HTTP、HTTPS、または HTTP/2 にしておく必要があります (Internet network endpoint groups overview)。HTTPS にすることで LB → NEG 区間も TLS になります。このとき LB が送る SNI の値は、後述の考察で重要なポイントになります。

検証環境

構成図

各コンポーネントと値

  • ドメイン: gcs-poc.example.com (実際には個人の技術検証用ドメインを使用)
  • バケット: gs://gcs-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=3600)
  • 証明書: Google-managed (ACTIVE)
  • NEG タイプ: Internet NEG (network-endpoint-type=internet-fqdn-port)
  • NEG エンドポイント: c.storage.googleapis.com:443

環境構築に関する補記

バケット名とドメイン名の一致

LB → GCS 間で GCS はどのバケット宛のリクエストかを Host ヘッダで判定する (Virtual hosted-style requests) ため、Host: gcs-poc.example.com を受け取った GCS は同名のバケットを探します。したがってバケット名とドメイン名は一致している必要があります。

このようにドメイン名をバケット名として使うには、GCS 側で事前にドメインの所有権確認を済ませておく必要があります (Domain-named bucket verification | Cloud Storage)。Search Console を介した所有権確認の仕組みを利用しており、手順は割愛しますが、画面の案内に従って確認を通せば gcs-poc.example.com という名前のバケットを作成できます。

Cloudflare は DNS only で運用

本記事の検証環境では Cloudflare を DNS ホスティングとして使っていますが、Cloudflare のプロキシ機能は OFF (DNS only) にしておく必要があります。これは以下の理由によります。

  • プロキシを有効化すると、クライアント → Cloudflare → LB の経路で Cloudflare が TLS を再終端し、Transform Rules・キャッシュ挙動・Bot 対策などの機能と干渉する。Host ヘッダやクエリ、SNI が Cloudflare 側の設定や内部処理に依存して書き換わり得る
  • Cloudflare が LB と TLS を確立するためには Cloudflare 側から LB 側の証明書を信頼する必要があり、Google-managed 証明書の発行手順と合わせて管理が複雑化する

Cloudflare は CDN であり、間に挟まるとキャッシュ・ヘッダ・TLS のすべてに干渉するため、今回の要件 (キャッシュさせたくない・ヘッダを素通しさせたい) とは合致しません。DNS のみの利用であれば Cloudflare は名前解決だけ担当し、TLS は LB が直接終端します。

Backend Service の timeout を長めに設定

Backend Service のデフォルトタイムアウトは 30 秒 (Backend services overview | Cloud Load Balancing) ですが、本検証では単一リクエストで大きめのファイル (3 GiB 程度) のアップロードも対象とするため、3600 秒に延長しています。想定する最大ファイルサイズのアップロード所要時間 (数分〜十数分) に対して十分な余裕を持たせる意図です。

$ gcloud compute backend-services update <backend-service> \
    --timeout=3600 --global

実機検証

構築した環境に対して署名付き URL を発行し、ユースケースごとに実機で動作を確認していきます。

署名付き URL の発行

Python の google-cloud-storage で V4 署名付き URL を生成します。ホスト部を gcs-poc.example.com にするには、generate_signed_urlbucket_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.json")
client = storage.Client(credentials=creds, project=creds.project_id)
bucket = client.bucket("gcs-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-poc.example.com",
)

生成された URL は次のような形です。

https://gcs-poc.example.com/test-small.txt
  ?X-Goog-Algorithm=GOOG4-RSA-SHA256
  &X-Goog-Credential=gcs-poc-signer%40<PROJECT_ID>.iam.gserviceaccount.com%2F20260417%2Fauto%2Fstorage%2Fgoog4_request
  &X-Goog-Date=20260417T024446Z
  &X-Goog-Expires=900
  &X-Goog-SignedHeaders=host
  &X-Goog-Signature=...

ホスト部がカスタムドメインに差し替わっている点を除けば、前回までの署名付き URL と同じ構造です。署名のカノニカル文字列に含まれる hostgcs-poc.example.com で計算されているため、この URL に対するリクエストは Host ヘッダが gcs-poc.example.com のまま GCS に届く必要があります。ここが崩れると署名検証が 403 になるため、以降の各検証は LB の透過性 (ヘッダ・クエリを改変しないこと) を暗黙のうちに確認していることにもなります。

1. 単発 GET

最小の動作確認として、900 B 程度のテキストに対する GET を投げます。

$ curl -sI "https://gcs-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: 900
server: UploadServer
via: 1.1 google

$ curl -s "https://gcs-poc.example.com/test-small.txt?X-Goog-Signature=..." | head -n 2
hello from GCS PoC via Internet NEG
hello from GCS PoC via Internet NEG

LB 経由で HTTP/2 200 が返り、content-length も 900 B と期待どおりで、実際の body も hello from GCS PoC via Internet NEG\n の繰り返しであることが確認できました。LB が署名クエリを改変していれば署名検証で 403 になるはずなので、少なくともクエリパラメータは透過的に GCS へ届いていると言えます。

なお、server: UploadServervia: 1.1 google から、応答が本物の GCS から返っていることも確認できます。

2. 単発 PUT (10 MiB / 100 MiB)

書き込み系も同じ経路で動くかを、10 MiB と 100 MiB の 2 サイズで確認します。

$ 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-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-poc.example.com/uploaded-100mib.bin?X-Goog-Signature=..."
200

10 MiB・100 MiB ともに PUT が 200 で完走し、バケット側にもサイズ一致でオブジェクトが格納されていることを確認できました (gsutil ls -L でサイズを確認)。

3. Range GET

Range リクエストが通るかも確認しておきます。LB が Range ヘッダを剥がしたり書き換えたりしていると、部分取得ができず全体が返ってきたり 200 になってしまったりします。

$ curl -sD - -o /dev/null -H "Range: bytes=0-1048575" \
    -w "\n%{http_code} %{size_download}\n" \
    "https://gcs-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 (Partial Content) で 1 MiB ちょうどが返ってきました。レスポンスヘッダに content-range: bytes 0-1048575/10485760 が含まれており、Range ヘッダが LB を透過して GCS まで届いていることがわかります。大きいファイルの分割ダウンロードや、中断からの再開といったユースケースでも問題なく使えそうです。

4. Resumable Upload

Resumable Upload は大容量ファイルのアップロードに使われる 2 段階プロトコルで、次のような流れになっています (Perform resumable uploads | Cloud Storage)。

  1. Init POST: 署名付き URL に対して x-goog-resumable: start を付けて POST し、レスポンスの Location ヘッダで session URI を受け取る
  2. Chunk PUT: session URI に対して Content-Range を付けた PUT を繰り返す

まずは Init POST です。Init 用の署名付き URL は、上の generate_signed_url (GET 用) のコードとは別に method="POST" + headers={"x-goog-resumable": "start"} + Init 用の有効期限を指定して発行しています。SignedHeaders には x-goog-resumable を含め、クライアント側の POST でも同じ x-goog-resumable: start を送ります。

$ curl -i -X POST \
    -H "x-goog-resumable: start" \
    -H "Content-Length: 0" \
    "https://gcs-poc.example.com/resumable-target.bin?X-Goog-Signature=..."
HTTP/2 201
location: https://gcs-poc.example.com/resumable-target.bin
          ?X-Goog-Algorithm=GOOG4-RSA-SHA256
          &X-Goog-Credential=gcs-poc-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=b0add5f6...
          &upload_id=AMNfjG1ahYXZlOozHPNHFC2R8tUSopSfxokxww0...
content-length: 0

(※ 上の location: ヘッダは可読性のため ? 以降を改行して整形しています。実際の HTTP レスポンスでは 1 行に収まります。)

ここで興味深いのは、返ってきた session URI のホスト名が gcs-poc.example.com、すなわちカスタムドメインのままになっていることです。公式ドキュメントのサンプルは storage.googleapis.com の Path Style で書かれていますが、GCS はリクエストの Host ヘッダを見て session URI のホスト名を組み立てているようですね。カスタムドメインで Init を叩けば、session URI もカスタムドメインで返ってきます。

これはドキュメントからだけでは特定できていなかった挙動でした。もし session URI が storage.googleapis.com で返ってきていた場合、後続の chunk PUT が LB を経由しなくなる (もしくは接続自体が成立しない) 恐れがありましたが、実機で観測した範囲ではカスタムドメインで返ってきたため、その懸念は生じませんでした。詳しくは考察セクションで触れたいと思います。

続けて、session URI に対して 8 MiB ずつ 4 回に分けて chunk PUT をかけます。

$ SESSION_URI="https://gcs-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 を送る必要がある、という意味)、最後の chunk で 200 が返り、33,554,432 バイト (32 MiB) のオブジェクトがバケットに格納されました。Resumable Upload の全工程がカスタムドメイン経由で完結することが確認できます。

5. 大容量 PUT (3 GiB 単一ストリーム)

大容量ファイルの単一 PUT が完走するかも確認しておきます。3 GiB の乱数ファイルを生成し、curl の --upload-file を使って単一ストリームで流し込みます。

$ dd if=/dev/urandom of=/tmp/test-3gib.bin bs=1M count=3072 2>/dev/null
$ time curl -X PUT --upload-file /tmp/test-3gib.bin -w "%{http_code}\n" \
    "https://gcs-poc.example.com/uploaded-3gib.bin?X-Goog-Signature=..."
200

real    4m55.577s

所要 約 5 分で HTTP 200 が返り、単一 HTTP/2 ストリームでのアップロードが完走しました。

整合性の確認として、ローカルとバケット側の MD5 を突き合わせます。

$ openssl dgst -md5 -binary /tmp/test-3gib.bin | base64
9tqzehIQAlByjPfZHTyPYw==

$ gsutil stat gs://gcs-poc.example.com/uploaded-3gib.bin | grep md5
    Hash (md5):             9tqzehIQAlByjPfZHTyPYw==

MD5 が完全に一致しています。LB の中継を挟んでも、問題なくファイルが送信できていることが確認できました。

なお、転送レートは 3 GiB / 約 296 秒 ≒ 10.38 MiB/s ですが、これは検証に使ったクライアント環境 (自宅ネットワーク) の上り帯域が律速した数字であり、LB・GCS 側の性能とは無関係です。LB のアクセスログ (httpRequest.latency) は 295.577905 秒、protocolh2statusDetailsresponse_sent_by_backend と記録されており、エラーや再接続なく単一ストリームで完走していることが確認できます。

検証結果のまとめ

# 検証項目 結果 HTTP 備考
1 単発 GET (900 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: bytes 0-1048575/10485760 を確認
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 大容量 PUT (3 GiB 単一ストリーム) 200 およそ 296 秒。MD5 の完全一致を確認

GET・PUT・Range・Resumable・大容量 PUT のいずれも期待どおりに動作することが確認できました。少なくともデフォルトの LB 挙動の範囲では、Internet NEG 経由で GCS 署名付き URL が透過的に通ることが確認できた、と言って良さそうです。

考察: なぜこの構成が動作するのか

ここまでの検証で、Internet NEG 経由でも GCS 署名付き URL が期待どおりに動作することが確認できました。続けて「なぜ動作するのか」を整理します。

そもそも、この構成が動くかどうかは実質的に次の 2 点に収斂します。

  1. ALB + Internet NEG は LB → upstream 区間で Host ヘッダをそのまま素通しするか
  2. 同区間で SNI を何に書き換えるか

いずれも公式ドキュメントに明示されており、ここまでの検証はこの 2 点の仕様が実機で期待どおりに働いていることの確認でもあります。以下はこの観点からの整理です。

GCS 側が要求している前提

SNI と Host のそれぞれについて、GCS の仕様を整理しておきます。

  • TLS 層の都合: GCS のフロントエンドが提示する TLS 証明書は *.storage.googleapis.com を含むワイルドカード証明書で、任意のカスタムドメイン (gcs-poc.example.com など) はカバーしていません。もし SNI が gcs-poc.example.com のままだったらサーバ証明書が一致せず、TLS ハンドシェイクが unknown name で失敗します。したがって SNI は *.storage.googleapis.com の配下に揃える必要があり、これが Internet NEG の endpoint に c.storage.googleapis.com を使う理由そのものです
  • HTTP 層の都合: 一方で、GCS はバケット解決を Host ヘッダで行い (Virtual hosted-style requests)、さらに V4 署名検証でも host を canonical headers に含めて署名を計算します (Canonical requests)。Host が書き換わると (a) どのバケットかわからなくなる / (b) 署名検証で host の値が canonical string と一致せず 403、のどちらかで必ず失敗します。したがって Host はクライアントが送ってきた gcs-poc.example.com のままで GCS に届ける必要があります

ALB + Internet NEG のデフォルト挙動が GCS の要求と噛み合っている

ALB + Internet NEG を HTTPS で組んだ場合の LB → upstream 区間の挙動は、Internet network endpoint groups overview に明示されています。まず、LB が NEG endpoint へ張る upstream の TLS コネクションでは、ClientHello の SNI に NEG endpoint の FQDN (c.storage.googleapis.com) がセットされます (同ドキュメントの SSL Server Name Indication (SNI) extension handling セクション: "The configured FQDN is sent as SNI in the client hello during the SSL handshake between the load balancer and the external endpoint.")。一方、LB が NEG に転送する HTTP リクエストの Host ヘッダ (HTTP/2 であれば :authority 疑似ヘッダ) には、クライアントが送ってきた値 (gcs-poc.example.com) がそのまま素通しされます (同ドキュメントの Authenticate requests セクション: "the load balancer preserves the headers that the client used to connect to the load balancer and includes the same header in its response. However, note that modifying the Host header is not supported in the URL map.")。つまり Internet NEG バックエンドでは、URL Map で Host を書き換えることが公式に非サポートと明示されており (modifying the Host header is not supported in the URL map)、Host 透過がデフォルトかつ唯一の挙動となります。検証結果はこの公式記載のとおりの振る舞いでした (詳細は後節で整理します)。

ホップごとに整理すると次のとおりです。

ホップ TLS SNI HTTP Host 決定主体
(1) Client → LB gcs-poc.example.com gcs-poc.example.com クライアント (URL のホスト部から自動生成)
(2) LB 内部 (TLS 終端・URL Map ルーティング) gcs-poc.example.com を読取 (改変なし) LB
(3) LB → Internet NEG (アップストリーム) c.storage.googleapis.com gcs-poc.example.com (素通し) LB (SNI は NEG endpoint に置換、Host はデフォルトで透過)
(4) GCS 側で受信 c.storage.googleapis.com として TLS 確立 gcs-poc.example.com で vhost-style バケット解決 GCS

ポイントはホップ (3) で、LB は SNI だけを NEG endpoint の FQDN に置換し、Host ヘッダにはまったく触れていません。もし Host も NEG endpoint に揃えて書き換えられてしまえば、後述するように GCS 側のバケット解決と V4 署名検証の両方が破綻しますし、逆に SNI を Host のまま後段に渡してしまえば GCS フロントエンドが証明書を持たず TLS ハンドシェイクからして成立しません。ALB + Internet NEG の挙動は、この 2 つの要求をどちらも満たす状態となっており、ゆえに本構成が成立しています。

Resumable の session URI がカスタムドメインで返ることの意味

検証 4 で触れたとおり、Resumable Upload の Init POST に対するレスポンスでは、session URI のホスト名がカスタムドメインで返ってきました (location: https://gcs-poc.example.com/...)。これは公式ドキュメントからは特定できず、実機で Init POST を叩いて初めてわかった挙動です。

もし session URI のホスト名がカスタムドメインで返ってこなかった場合、次のいずれかの問題が起きるはずです。

  • Path Style (storage.googleapis.com/<bucket>/<object>) で返ってきた場合: クライアントは LB を迂回して GCS に直接接続することになり、「エンドユーザから見えるホスト名は常にカスタムドメイン」という設計原則が崩れる
  • Virtual Hosted Style (<bucket>.storage.googleapis.com/<object>) で返ってきた場合: gcs-poc.example.com.storage.googleapis.com は DNS 解決できないため接続不可。クライアント側で URL 書き換えを自前で実装する必要が生じる

Internet NEG 経由の Resumable Upload では session URI もカスタムドメインで返ってくるため、どちらのシナリオにも陥らず、クライアントは追加の細工なしにそのまま chunk PUT を続けられました。観測からの推定ではありますが、GCS は受け取った Host ヘッダをそのまま利用して session URI を組み立てているようです。

このデフォルト挙動に依存してよいか

ここまでで見てきた挙動のうち、「LB → NEG 区間で Host は素通し、SNI は NEG endpoint の FQDN」は Internet network endpoint groups overview に仕様として明示されています。一方、「Resumable の session URI は Host 由来のホスト名で組み立てられる」「c.storage.googleapis.com を Internet NEG の endpoint として使ってよい」という 2 点は、公式ドキュメントが仕様として明文化しているわけではなく、実機で観測されたデフォルトの動作および派生的な利用です。ただし、これらはいずれも公開情報から演繹的に妥当性を裏付けられる範囲に収まっており、検証結果に基づいてこの方式を採用することに大きなリスクがあるものではないと考えます。

  • LB → NEG の Host 透過: Internet network endpoint groups overview の Authenticate requests セクションに "the load balancer preserves the headers that the client used to connect to the load balancer" と明記されており、クライアントから受け取った Host ヘッダをそのまま upstream に渡すのは Internet NEG バックエンドの仕様です。また、同セクションは併せて "modifying the Host header is not supported in the URL map" とも述べており、URL Map 側で Host を書き換えるインタフェース自体が Internet NEG には用意されていないことも示されています
  • LB → NEG の SNI: 同ドキュメントの SSL Server Name Indication (SNI) extension handling セクションに "The configured FQDN is sent as SNI in the client hello during the SSL handshake between the load balancer and the external endpoint." と明記されており、NEG endpoint として指定した FQDN (c.storage.googleapis.com) が SNI に入るのも仕様です。Encryption from the load balancer to the backends も「internet NEG バックエンドを持つ HTTPS ロードバランサは SNI を使う (他の LB は原則使わない)」と明示しており、Internet NEG 経路が例外的に SNI を使う構成であることを裏付けています
  • Resumable session URI のホスト名: GCS は V4 署名検証で host を canonical headers に含めて Host ヘッダを権威値として扱っている (Canonical requests)。その Host を Location の組み立てにも再利用するのはプロキシ等を考慮した際の透過性の観点で自然な実装であり、実機の観察とも整合します
  • Internet NEG endpoint としての c.storage.googleapis.com の利用: Request endpoints は CNAME 用の案内としてこのホスト名を提示しており、Internet NEG の endpoint として使ってよいとは明示されていない。本構成はこの案内を派生的に利用している位置づけになります

そもそも、クラウドサービスを利用するのであれば、仕様として明示されている挙動のみで構成を組めるときばかりとは限りません。ドキュメントに仕様として明示されてはいないが、実機検証で問題なく利用できることが確認できればデフォルト挙動を許容 (採用) する、といった判断は十分に現実的なはずです。とはいえ、明確な仕様ではなく、観測ベースであることは事実ですので、長期運用を視野に入れる場合は、後述の「本構成の採用に向けた注意点」で触れる外形監視などを設けておくとよいでしょう。

余談: c.storage.googleapis.comc とは

Internet NEG endpoint として指定している c.storage.googleapis.comc の由来が気になっていくつか試してみたのですが、結論から言うと、ラベル自体に技術的な差異はなさそうでした。

$ for h in a b c d e; do
>   echo "--- $h.storage.googleapis.com ---"
>   dig +short "$h.storage.googleapis.com" | sort
> done
--- a.storage.googleapis.com ---
142.250.21.207
142.250.23.207
142.251.169.207
142.251.23.207
142.251.24.207
--- b.storage.googleapis.com ---
142.250.21.207
142.250.23.207
142.251.23.207
142.251.24.207
--- c.storage.googleapis.com ---
142.250.21.207
142.250.23.207
142.251.23.207
142.251.24.207
--- d.storage.googleapis.com ---
142.250.21.207
142.250.23.207
142.251.23.207
142.251.24.207
--- e.storage.googleapis.com ---
142.250.21.207
142.250.23.207
142.251.23.207
142.251.24.207

いずれのラベルも同じ IP プールに解決され (A レコードの並び順は要求のたびに入れ替わります)、同じ *.storage.googleapis.com ワイルドカード証明書で TLS を受け、HTTP 層の挙動も同一です。上記キャプチャで a にのみ 5 件目の A レコードが見えているのは Google 側の DNS 応答の揺らぎの範囲であり、別のタイミングで引き直すと各ラベルに出る IP 群は変わります。要するに、単なる同一バックエンドに向いた、CNAME 参照できる複数のエイリアスのようです。

それでもなぜ本記事で c.storage.googleapis.com を採用したかというと、Request endpoints で明示的に案内されているホスト名はこれだけだからです。

When using CNAME records, the hostname portion of your CNAME record must be set to the following: c.storage.googleapis.com..

他のラベルを使った場合、将来 Google が挙動を整理したとき (例えば「案内していない ab は廃止」) に、予期せず対応を迫られるリスクがあります。ゆえに、公式で明示されている c.storage.googleapis.com を使っておくのが無難と考えました。

なお、この案内はあくまで一般的なカスタムドメインの設定方法の文脈で書かれたものであり、「LB の Internet NEG endpoint として使って良い」と明示されているわけではない点には留意が必要です。

本構成の採用に向けた注意点

検証結果として、External ALB + Internet NEG 経由の GCS 署名付き URL は動作する構成であることが確認できました。ただし、本構成を実際に採用する際には、検証を通して見えてきた次の 3 点を前提として理解しておく必要があります。

1. TLS は E2E ではない

クライアント → LB と LB → GCS は別の TLS セッションであり、LB は平文でペイロードを見ています。Google 内部の信頼境界で許容できる前提は必要です。E2E の暗号化が要件に含まれるなどの場合は別途検討が必要かもしれません。

2. バケット名 = ドメイン名の制約

LB → GCS 間で Virtual Hosted Style を使うため、バケット名とホスト名は一致している必要があります。これには以下の制約があります。

  • バケット作成時に Search Console 経由のドメイン所有権確認 (Search Console で所有権確認) が必要
  • 1 つのバケットを複数の異なるドメインから呼びたい、というニーズには応えられない (ドメインごとにバケットを分ける必要がある)
  • バケット名に任意の命名規則 (プロジェクト名プレフィックスなど) を使えない

3. LB および GCS のデフォルト挙動への依存

考察で述べたとおり、本構成は「Internet NEG endpoint として c.storage.googleapis.com を使う (CNAME 用の案内を派生的に利用)」「Resumable session URI がリクエスト Host から組み立てられる」という 2 点で、公式ドキュメントに仕様として明示されていないデフォルト挙動に乗っています (LB の Host 透過・SNI 置換自体は Internet network endpoint groups overview に明記された仕様で、この節の対象外です)。残る 2 点は公開情報から演繹的に裏付けられる範囲に収まっていますが、仕様として保証されているものではないため、挙動の変更を懸念する場合は下記のような運用上の備えを組み込んでおくと安心です。Google Cloud のサポート契約がある環境であれば、採用前にこれらの挙動を仕様として扱ってよいかをサポート経由で確認しておくのもよい手でしょう。

  • 署名付き URL 外形監視 (短命の署名付き URL を発行して定期的に 200 が返ることを確認)
  • LB アクセスログでの Host 値・クエリパラメータのモニタリング
  • Backend Service / URL Map / Target HTTPS Proxy 構成の IaC 化とドリフト検出

次回予告

Internet NEG を使ったアプローチで、カスタムドメインの GCS 署名付き URL が GET / PUT / Range / Resumable / 大容量 PUT のいずれも期待どおりに動作することを確認しました。LB が Host を素通しして SNI だけを c.storage.googleapis.com に書き換えること自体は Internet network endpoint groups overview に仕様として明示されていることが確認できた一方、この構成は「c.storage.googleapis.com を Internet NEG の endpoint として使ってよいか (公式案内は CNAME 用)」「Resumable の session URI がリクエスト Host から組み立てられるか」という 2 点で、(公開情報からある程度推測できる範囲にはあるものの) 公式ドキュメントで仕様として明示されていないデフォルト挙動の上に成り立っています。

そうなると、Internet NEG ではなく、Google 公式に「マネージドサービスへの正式な接続経路」として案内されている仕組みに置き換えれば、このデフォルト挙動依存を一部でも軽減できるのではないか、という期待が出てきます。Part 1 の構成候補の洗い出しで挙げた 案 E: External ALB + PSC NEG + GCS がまさにその候補に該当します。

次回 (Part 3) では、PSC NEG (Google API ターゲット) 経由の構成を実機で試し、Internet NEG 経由のアプローチと同じ項目を検証します。結論だけ先に書くと「動きはするが、Envoy が Host を書き換える」「バケット名が URL パスに露出する」「Backend Service の timeout が 30 秒固定」といった別種の制約が出てきます。検証結果の紹介と併せて、Internet NEG と PSC NEG のどちらを採用すべきかのトレードオフ比較と、シリーズ全体のまとめを行います。

カスタムドメインで GCS 署名付き URL を配信する構成を考える (Part 1)

予期せずシリーズ化してしまった GCS 署名付き URL に関する技術検証の続編です。

前々回の記事では Virtual Hosted Style で発行する GCS 署名付き URL の挙動を、前回の記事では VPC Service Controls 境界内のバケットに対する署名付き URL からのアクセスを検証しました。いずれも「クライアントに直接 GCS を叩かせる」という用途で署名付き URL を扱っていますが、URL の FQDN は <bucket>.storage.googleapis.com あるいは storage.googleapis.com のまま、つまり Google Cloud Storage 側が提供する FQDN がそのままエンドユーザに見える状態でした。

今回は、この署名付き URL の FQDN をカスタムドメイン (例えば files.example.com) に差し替える構成を検討していきます。

内容がそこそこの分量になったため、3 本の記事に分けてお届けします。

  • Part 1 (本記事): 署名付き URL まわりの一般論と仕様の整理、エンタープライズに見られる要件をふまえたアーキテクチャ候補の洗い出し
  • Part 2 (次回): ALB + Internet NEG + GCS の構成を実機検証
  • Part 3 (次々回): 別解として ALB + PSC NEG + GCS の構成を実機検証し、両構成を比較

過去 2 本の記事と直接の依存関係はありませんが、「署名付き URL というトピックの中で、エンタープライズでの採用時に出やすい要件とどう折り合いをつけるか」という観点で緩やかに続編の位置付けです。本シリーズだけでも独立して読めるはずです。

署名付き URL を使ったファイル送受信の一般的な姿

よくあるユースケース

GCS 署名付き URL は、エンドユーザとアプリケーションの間で直接ファイルをやりとりさせたい場面でよく使われます。具体例をざっと挙げると次のとおりです:

  • 利用者によるプロフィール画像・アバター画像のアップロード
  • 契約書、本人確認書類、医療画像、経理証票などの電子的な提出
  • 動画・写真・音声などの大容量メディアのダウンロード配信
  • データエクスポート結果 (CSV・Parquet 等) のダウンロード
  • バックアップファイルや CI/CD のビルド成果物の配布
  • 企業間でのログ・データ連携

署名付き URL を使うと、アップロード・ダウンロードのトラフィック自体はバックエンドを経由せず、クライアントと GCS の間で直接やりとりされる形になります。

なぜ直接 GCS に送受信させるのか

独自の API をバックエンドに設け、クライアントからはその API にファイルを送り、バックエンドが内部で GCS にアップロードするといった実装方式と比べると、署名付き URL 経由で直接 GCS に送受信させる方式には次のような利点があります。

  • バックエンドの帯域を消費しない: 数百 MiB、あるいは数 GiB を超えるサイズのファイル転送は、ネットワーク・CPU・メモリのいずれに対しても非常にコストの高い処理です。これをクライアント ↔ GCS の直接通信に外出しできれば、バックエンドはファイル本体を一切触らずに済みます。
  • コンピュート層の制約に縛られない: コンピュートサービスにはリクエストサイズや実行時間の上限が設けられていることが多く、大容量ファイルをコンピュート層で受け切ること自体が設計として成立しないケースもあります。GCS に直接転送すれば、こうしたプラットフォーム固有の制約を回避しやすくなります。
  • リソースの占有時間を短くできる: 一般的な API リクエストと比べ、ファイル転送ではクライアント側の回線品質次第でコネクションが数分〜数十分維持されることがあります。スケールアウトを前提としたコンピュート環境ではこの長時間のスロット占有がコスト増・同時実行数の枯渇に直結します。
  • 実装量が小さい: GCS には単純な GET / PUT に加え、Resumable Upload や Range リクエストといった大容量ファイルの送受信に適した機能が HTTP API として用意されており、バックエンドで再実装せずとも利用できます。
  • 権限管理を時限化できる: 署名付き URL は「この特定のオブジェクトに対して、この HTTP メソッドで、この期限まで」という最小権限を時限的に渡す仕組みです。バックエンド API でファイル転送を仲介する方式だと、都度のファイル転送リクエストごとにクライアント認証・認可・セッション管理を成立させ続ける必要がありますが、署名付き URL 方式ではこれらは URL 発行時点の 1 回に集約され、実転送経路からは切り離すことができます。

特に扱うファイルのサイズが大きい、あるいはアップロード・ダウンロードのリクエストが同時に多数走るようなユースケースでは、バックエンド経由の方式はコンピュートレイヤのボトルネックになりがちです。エンドユーザと GCS を直接つなぐアーキテクチャが採用されるのには、こうした事情が背景にあります。

発行フローと URL の見た目

GCS 署名付き URL の一般的な使い方は、次のような 3 ステップになります。

  1. クライアントはアプリケーション API に対して「このオブジェクトに対する URL を発行してほしい」と依頼する
  2. API (バックエンド) は、署名用のサービスアカウントを用いて、対象オブジェクト・メソッド・有効期限を指定した V4 署名付き URL を発行して返す
  3. クライアントは返ってきた URL に対して直接 GET / PUT を叩く (以降 GCS と直接通信)

発行された URL は次のような形式をとります。デフォルトは Path Style、オプションで Virtual Hosted Style に切り替えられます。

# Path Style (default)
https://storage.googleapis.com/<bucket>/<object>?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Signature=...

# Virtual Hosted Style (オプション)
https://<bucket>.storage.googleapis.com/<object>?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Signature=...

(Request endpoints および前々回の記事を参照)

いずれの場合も FQDN は storage.googleapis.com の配下であり、クラウド事業者名および使用しているストレージサービス名が URL からそのまま読み取れる状態になります。個人向けのちょっとしたアプリであればこれで何の問題もありませんが、エンタープライズの用途となると話が変わってくることがあります。

エンタープライズでありそうな要件

カスタムドメインを必須としたい

配信 URL の FQDN を自社のサービスドメイン (files.example.com 等) にしたい、という要件はエンタープライズでは非常によく出てきます。動機は大きく以下の 3 つに整理できそうです。

  • ブランディング: storage.googleapis.com はクラウド事業者名をそのまま露出します。そのため、画面遷移の一環としてユーザに見せたくない、サービスの世界観を揃えたい、という要望は一定存在するでしょう。
  • ネットワーク制御: クライアント側のセキュリティポリシーで egress allowlist が *.example.com 単位で運用されている場合、storage.googleapis.com をリストに追加するのが容易ではない (許可範囲が広すぎるので通りにくい) ケースがあります。(ただし、バケット単位の FQDN を allowlist に書ける運用であれば、カスタムドメインを使わずとも Virtual Hosted Style で .storage.googleapis.com を直接許可すれば解決できることもあります)
  • 実装の差し替え容易性: 将来ストレージを別サービスに乗り換えたとしても、クライアント側が保持する URL の FQDN はサービスドメインのままで変わらないようにしておきたい、といった将来の構成変更を見越した要件も考えられそうです。

HTTPS は必須

署名付き URL は X-Goog-Signature などのクエリパラメータ自体を認証情報として使う仕組みであり、URL そのものが有効期限付きの認可チケットとして扱われます。これを HTTP で配信すると経路上で URL が平文で露出し、有効期限内であれば第三者がそのまま再送しても GCS 側は正規のリクエストとして処理してしまいます。しかも、前回の検証で見たとおり署名付き URL によるアクセスはエンドユーザを直接特定できず、VPC SC の境界でバケットを保護したとしてもこの性質は変わりません (VPC SC の Ingress Policies は署名した SA の identity に基づいて操作の可否を判定するため)。ゆえに一度 URL が漏洩すると、誰が使ったかの事後追跡すら困難な不可逆的な事故になりえます。

加えて、現代のブラウザには HTTPS を前提に動く仕組み (Mixed Content 検知、HSTS、Cookie の Secure 属性など) が多数あることを踏まえると、HTTP での配信を選ぶ積極的な理由は見当たりません。本記事でも HTTPS を必須要件として扱います。

Cloud CDN などのエッジキャッシュに載せたくない

取り扱うファイルによっては、キャッシュ層への残留を避けたい・エッジに露出させたくない、というケースがあります。代表的な例が、いわゆるデータレジデンシーの要件です。

データレジデンシー (data residency) は、データの物理的な保管地を特定の国・地域に限定する要件。単なるコンプライアンス上の形式的な区分ではなく、法的枠組み (例: GDPR や各国の個人情報保護法制) や業界固有のガイドラインに裏付けられた要件として扱われることが多い。

CDN は本来コンテンツを世界中の PoP (Point of Presence) に分散させるための仕組みですから、オリジンが単一リージョンのバケットであっても、ひとたびエッジに載ってしまえばグローバルに分散した PoP からの配信になります。データレジデンシーの要件を厳格に解釈すると、この挙動がそのままリスクとして扱われかねません。

また、たとえデータレジデンシーが直接の要件でなかったとしても、署名付き URL の性質上、「有効期限を過ぎた後もエッジにキャッシュが残る可能性」「想定外のクライアントに同じ署名済みレスポンスが誤配信される可能性」を設計に持ち込みたくない、という要求は十分にありえます。

今回検討するシナリオ

以上をまとめると、本記事が想定するシナリオは次のようになります。

要件 要求水準
カスタムドメイン 必須
HTTPS 必須
Cloud CDN などエッジへのキャッシュ 禁止
自己管理のコンピュートリソース できれば持ちたくない

最後の 1 つはあくまで「できれば」の要件になりますが、単純なファイル中継のために自己管理のコンピュートリソースを抱え込むのは、運用・コスト・スケーリングなどの観点から可能な限り避けたいところです。まずはサーバレスなかたちで上記 3 要件を成立させられないか、という方針で候補構成を検討していきます。

採用可能な構成の検討

案 A: GCS に直接 CNAME を張る

バケット名をドメイン名と揃え (例えば files.example.com で配信するなら、バケット名も files.example.com にする)、ドメインから c.storage.googleapis.com に CNAME を張る方式です。GCS の静的サイトホスティング機能として以前から公式ドキュメントで案内されています。

Request endpoints では、次のように記載されています。

When using CNAME records, the hostname portion of your CNAME record must be set to the following: c.storage.googleapis.com..

また、Hosting a static website using HTTP | Cloud Storage では、www.example.comc.storage.googleapis.com を例として CNAME の設定例が示されています。

カスタムドメインという観点では要件を満たしますが、この CNAME 方式は「サポートされるプロトコルが HTTP のみ」という大きな制約があります。Hosting a static website | Cloud Storage には次のように書かれています。

Because Cloud Storage doesn't support custom domains with HTTPS on its own, ...

理由は単純で、GCS が TLS 終端に利用している Google Front End (GFE) は、GCS バケット向けにユーザ個別のカスタムドメインの TLS 証明書を搭載する仕組みを持っていないからです。GCS は *.storage.googleapis.com のワイルドカード証明書で TLS を受けていますから、カスタムドメイン (files.example.com など) で TLS ハンドシェイクを成立させようとすれば SNI が files.example.com・しかしサーバ証明書は *.storage.googleapis.com、という噛み合わない組み合わせになり、ブラウザは証明書エラーで接続を拒否します。

したがって、HTTPS を必須とする今回の要件では案 A は選択肢にはなりません。

案 B: External ALB + Backend Bucket

本記事で「ALB」と書いたときは Google Cloud の Global External Application Load Balancer (EXTERNAL_MANAGED) を指すこととし、以降この略称を用います。ALB には、バケットを直接バックエンドにできる Backend Bucket という機能があります (Backend buckets overview | Cloud Load Balancing)。LB 側で Google-managed 証明書を使って TLS 終端できるため、カスタムドメイン + HTTPS という組み合わせを満たす手段として一見良さそうです。

ところが、この方式は GCS 署名付き URL と併用できないことが公式に明記されています。Set up a global external Application Load Balancer with Cloud Storage buckets の "Limitations" セクションに次の記述があります。

The load balancer doesn't support the use of signed URLs unless Cloud CDN is enabled. Note: Signed URLs used in Cloud Storage are different from signed URLs used with Cloud CDN. The global external Application Load Balancer supports only Cloud CDN signed URLs. Cloud Storage signed URLs are not supported through the load balancer, regardless of whether Cloud CDN is enabled.

この引用は前段と後段で異なる主張を含んでいる点に注意が必要です。前段「The load balancer doesn't support the use of signed URLs unless Cloud CDN is enabled」は、「Cloud CDN を有効化した場合に LB 経由で利用できるのは Cloud CDN 署名付き URL のみ」という意味です。一方、後段「Cloud Storage signed URLs are not supported through the load balancer, regardless of whether Cloud CDN is enabled」は、「GCS 署名付き URL は Cloud CDN の有無によらず Backend Bucket 経由では利用できない」という別の主張です。両者の間にある Note: で「Cloud Storage の署名付き URL と Cloud CDN の署名付き URL は別物」と明示されているのも、この読み分けを補強しています。ここでの論点 (GCS 署名付き URL が Backend Bucket 経由では使えない) の直接的な根拠は後段にあり、Cloud CDN を無効化したとしても GCS 署名付き URL が使えるようになるわけではなく、Backend Bucket を採用した時点で GCS 署名付き URL は使えない、と読み取ることができます。

補足: 「CDN を有効化してキャッシュさせない」は NG

「Cloud CDN を有効化したうえで、TTL を 0 にしたり Cache-Control: no-cache を返したりすれば、キャッシュを永続化させないまま Backend Bucket で署名付き URL を利用できるのではないか」という発想についても補記しておきます。特にデータレジデンシー要件を「キャッシュとしてオブジェクトが保持されなければ許容可能」と解釈する場合、この抜け道を探したくなる動機は十分にありそうです。しかしこの発想は、以下のとおり複数の層で成立しません。

  • そもそも Backend Bucket + GCS 署名付き URL は Cloud CDN の有無によらず非対応: 前述の引用「regardless of whether Cloud CDN is enabled」が示すとおり、Cloud CDN 側でどう抑止工夫をしようが、Backend Bucket 経由の GCS 署名付き URL は成立しません
  • キャッシュ永続化の有無にかかわらず、Cloud CDN 有効化の時点で世界中の PoP をデータが通過する: Cloud CDN を有効化すると、リクエストはクライアント最寄りの PoP (Point of Presence) に到達し、そこで TLS 終端・ヘッダ検査・オリジンへの再送などの処理が行われます。Cache-Control: no-cache や TTL=0 はあくまで「キャッシュに永続化しない / 毎回 revalidation させる」という挙動を Cloud CDN に要請する意味であって、「PoP を経由させない」ではありません。データレジデンシー要件を「物理的にデータが特定の国・地域のインフラ以外を経由しない」と厳格に解釈する場合、PoP を一度でも経由する時点で要件違反と判断されうるため、キャッシュ抑止の工夫自体がそもそも的外れになります
  • 仮に PoP 経由を許容するとしても、Cache-Control での抑止は保証されない: Cloud CDN には USE_ORIGIN_HEADERS / CACHE_ALL_STATIC / FORCE_CACHE_ALL の 3 つの cache mode があり (Cache modes | Cloud CDN)、FORCE_CACHE_ALL モードはオリジンの Cache-Control を無視してキャッシュを保存します。設定ミス一つで「キャッシュされない想定」が崩れうるため、Cache-Control による抑止は運用上の保証として信頼できません

補強として、Cloud CDN の公式ドキュメント自身も、署名付き URL 経由のコンテンツとキャッシュの関係について次のように注意喚起しています。

Don't sign URLs that provide access to private information. When content is accessed by using a signed URL, it is potentially eligible for caching regardless of any Cache-Control directives in the response.

(Prevent caching | Cloud CDN)

この記述の "signed URL" は、文脈的には Cloud CDN 署名付き URL 機能を指すもので、そのまま GCS 署名付き URL の話として読み替えることはできません (Backend Bucket が非対応である以上、GCS 署名付き URL と Cloud CDN を直接組み合わせる構成は事実上存在しないので)。ただし根拠となっている「Cache-Control に関わらずキャッシュされうる」という挙動は上記 cache mode 設計に起因する Cloud CDN 側の性質であり、通過する URL の種別には依存しません。

したがって Backend Bucket 経由は、GCS 署名付き URL サポート外・Cloud CDN を有効化する時点でのデータレジデンシーリスク・Cache-Control による抑止の不確実性のいずれの側面からも、今回の要件では採用できません。

補足: Cloud CDN 署名付き URL

参考までに補足しておくと、Cloud CDN には独自の「署名付き URL」機能が存在します (Using signed URLs | Cloud CDN)。これは GCS 署名付き URL とは全く別の仕組みで、次のような違いがあります。

項目 GCS 署名付き URL Cloud CDN 署名付き URL
署名を検証する主体 GCS (バケット側) 原則オリジン側 (Cloud CDN は malformed なパラメータのみ拒否)
署名鍵の種類 署名 SA の RSA 秘密鍵 Cloud CDN の key-name に紐づく共通鍵 (HMAC)
クエリパラメータ X-Goog-Algorithm / X-Goog-Signature Signature / KeyName / Expires
アルゴリズム GOOG4-RSA-SHA256 (V4) HMAC-SHA1 (base64url)
キャッシュ CDN 統合は前提としない CDN のエッジキャッシュが前提

Cloud CDN 署名付き URL は「キャッシュされたオブジェクトへのアクセスにも署名クエリを要求することで、エッジキャッシュと認可制御を両立させる」機能と位置付けられています (署名検証自体はオリジン側の責任で行われます)。いずれにせよエッジにキャッシュされることが前提の設計であり、本記事の「エッジにキャッシュしたくない」という要件と真正面から衝突するため、今回の目的には合致しません。

案 C: コンピュート層を挟む

ALB のバックエンドに Cloud Run や GCE を置く構成には、さらに 2 つのサブパターンがあります。一つは (α) コンピュート層を GCS へのリバースプロキシとして使い、署名付き URL をそのまま通過させる 方式、もう一つは (β) 署名付き URL を使わず、コンピュート層が独自の /upload / /download API を実装して内部で IAM 経由で GCS にアクセスする 方式です。前者なら GCS の署名付き URL の仕組みはそのまま活用でき、後者はクライアントからの認可機構から再実装することになります。

ただし、ファイル授受の経路を単に作りたいだけの目的でどちらの方式を採っても、コンピュート層を挟むこと自体のコストは避けられません。

  • 大容量転送とスケーリング: 大容量ファイルの転送は単一リクエストが数分〜数十分張り付くため、1 インスタンスあたりの同時実行数が詰まりやすくなります。Cloud Run のようなある程度マネージドなインフラを使うにしても、並列度や最大インスタンス数の調整、リクエストサイズ / ストリーミングの上限、タイムアウトなどを丁寧に設計する必要があります
  • プロトコル挙動の透過性: multipart upload・resumable upload・Range リクエスト・HTTP/2・Keep-Alive などを透過的に扱う必要があります。特に β 型ではこれらのセマンティクスを自前で実装することになります
  • 運用コスト: 常時稼働するコンピュート層が追加されるため、継続的なパッチ適用、稼働コストが発生します

「ただ GCS にファイルを預けたい・取り出したい」だけの目的でここまでのコストを払うのは、オーバーエンジニアリングとなってしまうケースがほとんどでしょう。

余談: それでもコンピュート層が必要になる場面

もっとも、コンピュート層を挟む構成に必然性があるケースも存在します。代表的には次のようなものです。

  • アップロード前検疫: ウィルススキャン、マルウェア検出、機密情報 (PII) の自動マスキングをアップロード経路上で実施したい場合。非同期でバケットにトリガをかけて後処理する方式 (GCS → Pub/Sub → Cloud Run Functions など) もありますが、検疫通過前のファイルを一時バケットにすら置かせたくない、というポリシーだと同期的な検疫が必要になります
  • MIME タイプ・ファイル拡張子の厳格な検証: 署名発行時にクライアントから申告された Content-Type と、アップロード時の実際のバイト列が一致しているか (例: JPEG と称して実行ファイルが送られてこないか) を静的に検証したい場合
  • アップロード・ダウンロードを跨いだ監査ログの強化: バケット側の Cloud Audit Log だけでは足りず、クライアント識別子・リクエスト ID・トレースなどを独自ログとして残したい場合

これらの要件が強くあるなら、コンピュート層を挟む構成は妥当な選択肢です。ただし本記事の要件 (カスタムドメイン + HTTPS + CDN へのキャッシュを禁止したい) には必ずしもコンピュート層は要らない、と考えられます。

以上を踏まえると、案 A〜C はいずれも今回の要件をそのままでは満たせない、という整理になります。残る候補として、ALB のバックエンドを Internet NEG または PSC NEG にする 2 つの案を順に検討していきます。

案 D: External ALB + Internet NEG + GCS

Internet Network Endpoint Group (Internet NEG) を ALB の Backend Service の Backend として設定し、NEG の endpoint に c.storage.googleapis.com:443 を指定する方式です。

Internet NEG は、Google Cloud の Backend Service のアップストリームとして「外部の FQDN または IP アドレス」を指定できる特殊な NEG です (Internet network endpoint groups overview | Cloud Load Balancing)。公式は次のように説明しています。

Cloud Load Balancing supports proxying traffic to external backends outside Google Cloud. To define an external backend for a load balancer, you use a resource called an internet network endpoint group (NEG).

本来はオンプレミスや別クラウドのエンドポイントを Google Cloud の ALB のバックエンドに指定したい場合 (ハイブリッドクラウド / マルチクラウド) の機能ですが、これを「GCS を外部 FQDN として扱う」ために転用できないか、というのがこの案の着想です。

この構成は Backend Bucket とは根本的に異なり、LB が Internet NEG の通常の手順どおりに TLS を終端し、別セッションで endpoint (c.storage.googleapis.com) に HTTPS を張り直す、というシンプルな構造です。Backend Bucket のように Google Cloud 内部の GCS と統合されたパスを経由するわけではありません (実際の経路が Google の private backbone を通るかインターネット経路かは別の論点で、Part 3 で改めて整理します)。

それゆえ、クライアントが署名した URL のクエリと Host が改変されずに GCS に届くのではないか、と仮説を立てられます。そうであれば、GCS 側の署名検証が通り、カスタムドメイン x GCS 署名付き URL の構成が動作するはずです。

この構造で気になるのは、LB が GCS と張り直す「別セッション」側で、どのドメインを使って TLS を張り、どんな Host を送るのか、という挙動です。これが署名検証を通すための条件と噛み合うかどうかが、この案の成立可否を決めます。

ただし、この組み合わせについては公式ドキュメントに明示的な言及がありません。Internet NEG のドキュメントは想定ユースケースとして「オンプレ/別クラウドへの中継」を強調しており、「GCS への中継に使える」とは書かれていません。また Backend Bucket の非対応記述は Backend Bucket 固有の話で、Internet NEG 経由には直接は適用されないと考えられます。

次回 Part 2 の記事では、この案 D を実機で検証していきます。

案 E: External ALB + PSC NEG + GCS

もうひとつの候補が、Private Service Connect NEG (PSC NEG) を ALB の Backend Service のメンバとして設定し、Google Cloud の内部的な経路で Cloud Storage API (storage.googleapis.com 系) に転送する方式です。この構成は Backend Bucket の代替として、公式ドキュメントでも明示的に案内されています (ただし、代替として案内されているのは PSC NEG という構成の位置づけまでであり、経路上の挙動の細部までが仕様として案内されているわけではない点は、Part 3 で改めて取り上げます)。

Backend buckets overview | Cloud Load Balancing には次のように書かれています。

Backend buckets offer a seamless, fully-integrated experience so that your load balancer can serve content directly from your Cloud Storage bucket. However, Private Service Connect offers an alternative deployment type where you create a Private Service Connect NEG that points to Cloud Storage API endpoints, and then configure this NEG as a backend for a load balancer.

つまり「Backend Bucket ではなく PSC NEG 経由で Cloud Storage API エンドポイントに向ける」という選択肢を Google 側も代替手段として提示しているわけです。案 D (Internet NEG) がインターネット経由でパブリックな c.storage.googleapis.com に向けるのに対し、案 E (PSC NEG) では Google Cloud 内に閉じた PSC 接続によって GCS に到達する、という違いがあります。

ただし、同ドキュメントには次の但し書きもあります。

However, note that this method doesn't inherently grant access to private buckets. Additionally, you are responsible for making sure that the client application can authenticate itself to Cloud Storage.

「PSC NEG を置いただけで非公開バケットへのアクセス権が付与されるわけではないので、クライアント側で認証の責任を持ってください」という内容です。今回の文脈では「署名付き URL のクエリパラメータによる認証がそのまま PSC NEG 経由でも通るのか」という問いに読み替えられます。

案 D は Internet NEG + GCS という構成自体が公式に触れられていないのに対し、案 E は構成そのものは Backend Bucket の代替として Google が公式に案内している方式です。一方で「署名付き URL のクエリパラメータがそのまま転送されるのか」という点は、この引用からは明示的には読み取れません。こちらも実機で確認する必要があります。

案 E の検証は Part 3 で行う予定です。

候補の整理

ここまでを表にまとめます。

候補 HTTPS カスタムドメイン GCS 署名付き URL コンピュート Cloud CDN 併用可否 (参考) 備考
A. GCS 直接 CNAME 不要 — (ALB 経由でないため対象外) HTTPS 非対応
B. ALB + Backend Bucket 不要 併用可能 (今回の要件では無効化前提) GCS 署名付き URL はサポート外
C. ALB + コンピュート + GCS 任意 運用コスト・実装量が多い
D. ALB + Internet NEG + GCS 要検証 不要 — (今回の要件上は無効化前提) Part 2 で検証
E. ALB + PSC NEG + GCS 要検証 不要 — (今回の要件上は無効化前提) Part 3 で検証

「Cloud CDN 併用可否」列は参考情報で、本記事の要件 (Cloud CDN へのキャッシュ禁止) のもとでは案 D / E とも Cloud CDN を無効化する前提で扱います。

次回予告

これで要件と構成候補の整理は一段落です。公式ドキュメントで紹介されている一般的なアプローチ (A〜C) はいずれも本記事の要件 (カスタムドメイン + HTTPS + CDN キャッシュ禁止 + コンピュート不要) を満たせず、残された候補は D (Internet NEG) と E (PSC NEG) の 2 つ、という状況になりました。いずれも今回の用途 (カスタムドメイン経由の GCS 署名付き URL の配信) としては、すなわち「LB のバックエンドに NEG を立てて GCS 署名付き URL を通す組み合わせ」としては、公式ドキュメントに明示的な言及がないため、まずは実機で成立するかどうかを確かめる必要があります。

次回 (Part 2) は 案 D (Internet NEG) を実機検証し、GET / PUT / Range GET / Resumable Upload などの各シナリオで期待どおりに動作するか、そしてなぜ動作するのかを考察します。案 E (PSC NEG) については Part 3 で同様の観点から検証し、最後に両者を比較したいと思います。