概要
INGRESS_TRAFFIC_INTERNAL_ONLY で内部 API を保護
INGRESS_TRAFFIC_ALL は Cloud Run をインターネットに公開する。内部バッチ API には INGRESS_TRAFFIC_INTERNAL_ONLY を設定し、VPC 内(+ Cloud LB)からのみアクセスを受け付けることで不正アクセスのリスクを排除する。
allUsers 禁止 — invoker は特定 SA のみ
allUsers への roles/run.invoker は認証なし公開 API になる。Argo Workflows GSA のサービスアカウントのみに Invoker 権限を付与し、Pod は OIDC トークンで認証する。
Secret Manager Volume Mount でシークレット漏洩防止
env.value の平文は Terraform state / Cloud Console で露出する。volumes + volume_mounts でファイルとしてマウントすると環境変数に展開されず、default_mode = 292(0444)で読み取り専用を強制できる。
min_instance_count = 1 でコールドスタート排除
min_instance_count = 0 はアイドル後にインスタンスがゼロになり、Argo Workflows が呼び出した際に 2〜10 秒のコールドスタートが発生する。min_instance_count = 1 で常時1台をウォーム状態に保つ。
問題
ECサイトの MOps チームでは、Argo Workflows バッチ処理から呼び出す内部 API を Cloud Run v2 でホストしている。現在の Terraform / Cloud Run 設定には 6つの設計上の問題 が潜んでいる。問題点を全て洗い出し、Cloud Run v2 × Terraform 1.8+ × Workload Identity Federation × VPC Connector × Secret Manager × 最小権限 IAM の最新プラクティスに従って修正せよ。
制約・前提条件
- Cloud Run v2(
google_cloud_run_v2_service)を Terraform 1.8+ で管理 - バッチワーカー(GKE Autopilot Pod)→ Cloud Run API の通信は VPC 内折返し(Private Ingress)
- シークレット(DB接続文字列・API キー)は Secret Manager で管理し、Cloud Run にマウント
- Workload Identity Federation(WIF)でサービスアカウントキー不要
- Cloud Run のサービスアカウントには最小権限のみ付与
- リビジョントラフィック制御(ブルーグリーン / カナリアデプロイ対応)
悪い Terraform (Before)
resource "google_cloud_run_v2_service" "mops_api" {
name = "mops-internal-api"
location = "asia-northeast1"
project = var.project_id
# 問題①: INGRESS_TRAFFIC_ALL はインターネット公開
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.cloud_run_sa.email
containers {
image = "asia-northeast1-docker.pkg.dev/my-project/mops/api:latest"
# 問題③: 平文シークレット(Terraform state に露出)
env {
name = "DB_CONNECTION_STRING"
value = "postgresql://user:password@host:5432/db"
}
env {
name = "EXTERNAL_API_KEY"
value = "sk-supersecret-api-key-12345"
}
}
# 問題⑤: min_instance_count=0 でコールドスタート発生
scaling {
min_instance_count = 0
max_instance_count = 10
}
}
# 問題⑥: traffic ブロックなし(カナリアデプロイ不可)
}
# 問題②: allUsers に invoker 権限(認証なし公開)
resource "google_cloud_run_v2_service_iam_member" "invoker_public" {
project = var.project_id
location = google_cloud_run_v2_service.mops_api.location
name = google_cloud_run_v2_service.mops_api.name
role = "roles/run.invoker"
member = "allUsers"
}
# 問題④: roles/editor は過大権限
resource "google_project_iam_member" "cloud_run_editor" {
project = var.project_id
role = "roles/editor"
member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}
ingress = "INGRESS_TRAFFIC_ALL" — 内部 API をインターネットに公開。INGRESS_TRAFFIC_INTERNAL_ONLY に変更し VPC 内に閉じるallUsers への roles/run.invoker — 認証なし公開 API になる。Argo Workflows GSA のみに IAM Binding を設定するenv.value) — Terraform state / Cloud Console / gcloud run services describe で露出。Secret Manager Volume Mount に変更するroles/editor は過大権限 — GCP プロジェクト全体への書き込み権限。必要最小権限(BQ dataEditor + pubsub.publisher + secretmanager.secretAccessor)に絞るmin_instance_count = 0(コールドスタート) — アイドル後に 2〜10秒の起動待ちが発生。min_instance_count = 1 + cpu_idle = false でウォーム状態を維持するtraffic ブロックなし — カナリアデプロイ / ブルーグリーン切り替えが不可。traffic ブロックを追加してリビジョン管理を可能にするヒント(段階的開示)
ヒント1 — 方向性
ingress = "INGRESS_TRAFFIC_ALL" はインターネットからもアクセスを許可する。VPC 内専用 API には INGRESS_TRAFFIC_INTERNAL_ONLY が適切。allUsers への roles/run.invoker 付与は認証なし公開になる。Terraform で Cloud Run サービスアカウントに roles/editor を付与するのは過大権限。Secret の env.value 平文は Terraform state ファイルに記録されるため漏洩リスクが高い。
ヒント2 — アプローチ
- 問題①:
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"に変更し、GKE Pod → Cloud Run の通信を VPC 内に閉じる - 問題②:
allUsersを削除し、Argo Workflows GSA の SA メールのみにroles/run.invokerを付与 - 問題③:
volumes+volume_mountsで Secret Manager からファイルとしてマウント。default_mode = 292(0444)で読み取り専用を強制 - 問題④:
roles/editorを削除しroles/bigquery.dataEditor+roles/pubsub.publisher+roles/secretmanager.secretAccessorの3権限を個別付与 - 問題⑤:
min_instance_count = 1+cpu_idle = falseでコールドスタートを排除 - 問題⑥:
trafficブロックでTRAFFIC_TARGET_ALLOCATION_TYPE_LATESTを指定し、カナリアデプロイ時はリビジョン名で分割する
ヒント3 — コードの骨格
# Cloud Run v2 の正しい ingress 制御と Secret Volume Mount
resource "google_cloud_run_v2_service" "mops_api" {
name = "mops-internal-api"
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY" # 修正①
template {
# Secret Manager ボリュームマウント 修正③
volumes {
name = "db-secret"
secret {
secret = google_secret_manager_secret.db_conn.secret_id
default_mode = 292 # 0444 読み取り専用
items {
version = "latest"
path = "db_connection_string"
mode = 292
}
}
}
containers {
image = "...@sha256:${var.api_image_digest}"
volume_mounts {
name = "db-secret"
mount_path = "/secrets/db"
}
resources {
limits = { cpu = "1", memory = "512Mi" }
cpu_idle = false # 修正⑤
}
}
scaling {
min_instance_count = 1 # 修正⑤
max_instance_count = 10
}
}
# 修正⑥: traffic ブロック
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
}
# 修正②: Argo Workflows SA のみ invoker
resource "google_cloud_run_v2_service_iam_member" "invoker_argo" {
name = google_cloud_run_v2_service.mops_api.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.argo_workflows_sa.email}"
}
問題点分析(6点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | ingress = "INGRESS_TRAFFIC_ALL"(インターネット公開) | セキュリティ | INGRESS_TRAFFIC_INTERNAL_ONLY に変更 |
| 2 | allUsers への roles/run.invoker(認証なし) | セキュリティ | Argo Workflows GSA のみに IAM Binding |
| 3 | 平文シークレット env.value(Terraform state 露出) | セキュリティ | Secret Manager Volume Mount + default_mode = 292 |
| 4 | roles/editor は過大権限(プロジェクト全体書き込み) | 最小権限 | BQ dataEditor + pubsub.publisher + secretAccessor の3権限 |
| 5 | min_instance_count = 0(コールドスタート 2〜10秒) | 可用性/性能 | min_instance_count = 1 + cpu_idle = false |
| 6 | traffic ブロックなし(カナリアデプロイ不可) | 信頼性 | traffic ブロックでリビジョン管理を追加 |
アーキテクチャ図 — Bad vs Good の変換フロー
模範解答
resource "google_cloud_run_v2_service" "mops_api" {
name = "mops-internal-api"
location = "asia-northeast1"
project = var.project_id
# 修正①: VPC 内専用 Ingress(インターネットからアクセス不可)
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"
template {
service_account = google_service_account.cloud_run_sa.email
# GKE Pod → Cloud Run を VPC 内で通信(VPC アクセスコネクタ経由)
vpc_access {
connector = google_vpc_access_connector.mops_connector.id
egress = "PRIVATE_RANGES_ONLY"
}
# 修正③: DB 接続文字列を Secret Manager からボリュームマウント(平文排除)
volumes {
name = "db-secret"
secret {
secret = google_secret_manager_secret.db_conn.secret_id
default_mode = 292 # 0444: 読み取り専用(8進数 444 = 10進数 292)
items {
version = "latest"
path = "db_connection_string"
mode = 292
}
}
}
volumes {
name = "api-key-secret"
secret {
secret = google_secret_manager_secret.external_api_key.secret_id
default_mode = 292
items {
version = "latest"
path = "api_key"
mode = 292
}
}
}
containers {
# digest ピン留め(:latest タグ禁止)
image = "asia-northeast1-docker.pkg.dev/${var.project_id}/mops/api@sha256:${var.api_image_digest}"
# 修正③: Secret をファイルとしてマウント(環境変数への平文展開を避ける)
volume_mounts {
name = "db-secret"
mount_path = "/secrets/db"
}
volume_mounts {
name = "api-key-secret"
mount_path = "/secrets/api"
}
# 非機密の設定のみ環境変数として渡す
env {
name = "GCP_PROJECT_ID"
value = var.project_id
}
env {
name = "ENV"
value = var.environment
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
# 修正⑤: cpu_idle = false でリクエスト処理中も CPU を確保(レイテンシ改善)
cpu_idle = false
}
startup_probe {
http_get {
path = "/healthz"
port = 8080
}
initial_delay_seconds = 10
period_seconds = 5
failure_threshold = 6 # 起動最大 40 秒待機
}
liveness_probe {
http_get {
path = "/healthz"
port = 8080
}
period_seconds = 15
failure_threshold = 3
}
}
# 修正⑤: min_instance_count = 1 でコールドスタートを排除
scaling {
min_instance_count = 1
max_instance_count = 10
}
session_affinity = false
}
# 修正⑥: traffic ブロックでカナリアデプロイ対応
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
depends_on = [
google_secret_manager_secret_iam_member.cloud_run_sa_db,
google_secret_manager_secret_iam_member.cloud_run_sa_api_key,
]
}
# Cloud Run サービスアカウント
resource "google_service_account" "cloud_run_sa" {
account_id = "mops-cloud-run-sa"
display_name = "MOps Cloud Run Service Account"
project = var.project_id
}
# 修正④: roles/editor を削除し、必要最小権限のみ付与
# BigQuery: mops_* データセットのみ dataEditor(IAM Conditions で制限)
resource "google_project_iam_member" "cloud_run_bq_editor" {
project = var.project_id
role = "roles/bigquery.dataEditor"
member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
condition {
title = "mops_dataset_only"
description = "MOps データセットへのアクセスのみ許可"
expression = "resource.name.startsWith(\"projects/${var.project_id}/datasets/mops_\")"
}
}
# Pub/Sub: メッセージ publish のみ(topic レベルで制限)
resource "google_project_iam_member" "cloud_run_pubsub_publisher" {
project = var.project_id
role = "roles/pubsub.publisher"
member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}
# Secret Manager: 対象シークレットのみ読み取り(プロジェクトレベルは不要)
resource "google_secret_manager_secret_iam_member" "cloud_run_sa_db" {
project = var.project_id
secret_id = google_secret_manager_secret.db_conn.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}
resource "google_secret_manager_secret_iam_member" "cloud_run_sa_api_key" {
project = var.project_id
secret_id = google_secret_manager_secret.external_api_key.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run_sa.email}"
}
# 修正②: allUsers 削除 → Argo Workflows SA のみ invoker
resource "google_cloud_run_v2_service_iam_member" "invoker_argo" {
project = var.project_id
location = google_cloud_run_v2_service.mops_api.location
name = google_cloud_run_v2_service.mops_api.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.argo_workflows_sa.email}"
}
# Cloud Load Balancer のヘルスチェック用(内部ロードバランサー経由の場合)
resource "google_cloud_run_v2_service_iam_member" "invoker_lb" {
project = var.project_id
location = google_cloud_run_v2_service.mops_api.location
name = google_cloud_run_v2_service.mops_api.name
role = "roles/run.invoker"
member = "serviceAccount:service-${var.project_number}@serverless-robot-prod.iam.gserviceaccount.com"
}
# VPC アクセスコネクタ(GKE Pod → Cloud Run VPC 内通信)
resource "google_vpc_access_connector" "mops_connector" {
name = "mops-vpc-connector"
region = "asia-northeast1"
project = var.project_id
network = google_compute_network.mops_vpc.name
ip_cidr_range = "10.8.0.0/28" # /28 = 14 IP(コネクタ専用サブネット)
# スループット設定(Cloud Run の最大インスタンス数に合わせて調整)
min_throughput = 200 # Mbps
max_throughput = 1000 # Mbps(max_instance_count = 10 に対応)
}
# ---- Direct VPC Egress の代替構成(コネクタ不要・推奨: 2024年以降)----
# VPC アクセスコネクタの代わりに Direct VPC Egress を使う場合:
#
# vpc_access {
# network_interfaces {
# network = google_compute_network.mops_vpc.name
# subnetwork = google_compute_subnetwork.cloud_run_subnet.name
# }
# egress = "PRIVATE_RANGES_ONLY"
# }
#
# Direct VPC Egress は Cloud Run v2 専用で、コネクタより低レイテンシ・
# 高スループット(最大 100 Gbps)を実現。コネクタ管理の手間も不要。
# 2024年以降の新規構成では Direct VPC Egress を推奨。
カナリアデプロイ手順(Terraform + CI/CD)
# ── 通常デプロイ(LATEST に 100%)──────────────────────────────────────────
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
# ── カナリアデプロイ(新リビジョン 10%、旧リビジョン 90%)──────────────────
# CI/CD パイプラインから terraform apply -var canary_percent=10 で適用
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION"
revision = var.stable_revision_name # 例: "mops-internal-api-00010-abc"
percent = 90 # 旧リビジョンに 90%
}
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 10 # 新リビジョンに 10%(カナリア)
}
# ── 完全切り替え(新リビジョンに 100%)──────────────────────────────────────
# エラーレートが閾値以下なら terraform apply -var canary_percent=100 で完全移行
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
GitHub Actions カナリアデプロイワークフロー
# .github/workflows/deploy-canary.yml(概略)
jobs:
canary:
steps:
- name: Deploy canary (10%)
run: terraform apply -var canary_percent=10 -var api_image_digest=${{ env.IMAGE_DIGEST }}
- name: Monitor error rate (5 min)
run: |
ERROR_RATE=$(gcloud monitoring read --filter='...')
if [ "$ERROR_RATE" -gt "1" ]; then exit 1; fi
- name: Promote to 100%
run: terraform apply -var canary_percent=100
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① INGRESS_TRAFFIC_ALL | INGRESS_TRAFFIC_INTERNAL_ONLY | インターネットからのアクセスを完全遮断 |
| ② allUsers invoker | Argo Workflows GSA のみ invoker | OIDC 認証必須・不正呼び出し防止 |
| ③ 平文シークレット | Secret Manager Volume Mount(0444) | Terraform state / Console に平文なし・ローテーション対応 |
| ④ roles/editor | 3権限 + IAM Conditions | SA 侵害時の影響範囲を最小化 |
| ⑤ min=0 コールドスタート | min=1 + cpu_idle=false | Argo Workflows からの呼び出し遅延をゼロに |
| ⑥ traffic ブロックなし | traffic ブロック + カナリア戦略 | 段階的ロールアウトで障害の影響範囲を限定 |
ポイント解説
Cloud Run v2 の
ingress には INGRESS_TRAFFIC_ALL(全トラフィック)/ INGRESS_TRAFFIC_INTERNAL_ONLY(VPC 内 + Cloud LB のみ)/ INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER(Cloud LB 経由のみ)の3種類がある。内部バッチ API には INTERNAL_ONLY を設定し、VPC 内の GKE Pod や Cloud Run 同士の呼び出しのみを許可する。インターネットから直接アクセスする必要がある場合は INTERNAL_LOAD_BALANCER でロードバランサー経由に限定する。
allUsers は未認証アクセスを含む全ての主体を指す。roles/run.invoker を付与すると URL を知っていれば誰でも HTTP リクエストを送れる。Argo Workflows Pod は Workload Identity Federation v2 で GSA になりすまし、OIDC トークン(Authorization: Bearer <token>)を HTTP ヘッダーに付与して Cloud Run を呼び出す。Token は gcloud auth print-identity-token または GKE 内では /var/run/secrets/token から取得する。
env.value に平文を書くと Terraform state ファイル(tfstate)に平文が記録される。tfstate を GCS に保存していても、IAM で tfstate バケットにアクセスできる全ての人員に漏洩する。volumes.secret + volume_mounts でファイルとしてマウントすることで、シークレット値が Terraform state に記録されない。default_mode = 292 は8進数の 0444(所有者・グループ・その他全員に読み取り専用)を10進数で表した値。Cloud Run コンテナ内でのパーミッション制御に使う。
roles/editor は GCP プロジェクト内の殆どのリソースへの書き込み権限を含む。Cloud Run SA が侵害された場合、攻撃者はプロジェクト内のデータベース・ストレージ・Pub/Sub トピックを自由に操作できる。IAM Conditions の resource.name.startsWith(...) でリソースレベルまで絞り込むことで、仮に SA が侵害されても mops_* データセット以外の BigQuery テーブルには触れない。Secret Manager のアクセス権はシークレットレベルで個別に付与する(プロジェクトレベルは不要)。
Cloud Run は最後のリクエストから数分後にインスタンスをゼロにスケールダウンする。次のリクエスト時にコンテナ起動(コールドスタート)が発生し、Python コンテナでは 2〜10 秒の遅延が生じる。Argo Workflows のステップタイムアウト(デフォルト 10〜30 秒)と重なるとタイムアウトエラーになる。
min_instance_count = 1 で常時1台を維持し、cpu_idle = false(CPU Always-on)でコンテナが待機中も CPU を確保することでコールドスタートを排除する(月 ~$6 のコスト増)。
traffic ブロックがない場合、Terraform apply のたびに最新リビジョンに 100% のトラフィックがルーティングされる。新リビジョンにバグがあると即座に全バッチ処理に影響する。TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION で特定リビジョン名を指定し、percent を分割することでカナリアデプロイを実現できる。CI/CD パイプラインで terraform apply -var canary_percent=10 → Cloud Monitoring でエラーレート監視 → percent=100 に移行という段階的ロールアウトを自動化する。
実務への応用
- Argo Workflows → Cloud Run 呼び出し: Argo の
httpテンプレートステップでAuthorization: Bearer $(gcloud auth print-identity-token --audiences=https://mops-internal-api-xxxx-an.a.run.app)ヘッダーを付与。Workload Identity v2 を使うと GKE Pod から gcloud なしに直接 OIDC トークンを取得できる(/var/run/secrets/tokenをマウント)。 - VPC Connector vs Direct VPC Egress: Cloud Run v2 の新規構成では Direct VPC Egress(
network_interfacesブロック)を推奨。コネクタ管理が不要で最大 100 Gbps のスループットを実現。既存の VPC Connector ベースの構成は Direct VPC Egress への移行を検討する。 - Secret のローテーション: Secret Manager の新バージョンが追加されると、Cloud Run は
version = "latest"の設定により次のデプロイ時に自動で最新バージョンを参照する。即時反映が必要な場合は Secret Manager のPub/Sub 通知→ Cloud Run のrevision_nameを強制更新するパイプラインを構築する。 - DataDog APM との連携: Cloud Run v2 でも Unified Service Tagging(
DD_SERVICE/DD_ENV/DD_VERSION)を環境変数で設定し、DataDog Agent へ OTLP トレースを送信できる。cpu_idle = falseはリクエスト外のバックグラウンド処理(DataDog Agent の定期送信)も正常に動作させるために有効。
今日のまとめ
INGRESS_TRAFFIC_INTERNAL_ONLY ② invoker は特定 SA のみ ③ Secret は Volume Mount(default_mode = 292)④ SA には最小権限 + IAM Conditions ⑤ min_instance_count = 1 でコールドスタート排除 ⑥ traffic ブロックでカナリアデプロイ対応」の6点であり、roles/editor + allUsers invoker + 平文シークレットの組み合わせは即座にセキュリティインシデントに直結する。Terraform で IAM Conditions(
resource.name.startsWith(...))を活用することで SA レベルの粗い権限制御をリソースレベルまで細粒度化でき、侵害時の爆発半径を最小化できる。