概要
Cloud Armor でエッジセキュリティを多層防御
Cloud Armor セキュリティポリシーを google_compute_backend_service にアタッチすることで、OWASP CRS(XSS・SQLi)ルールとレート制限をエッジで適用できる。priority 番号が小さいほど先評価されるため、拒否ルールを 1000 番台、レート制限を 2000 番台、デフォルト許可を最大値(2147483647)に配置する。最初は preview: true で False Positive を確認してから本番適用するのが安全。
Cloud CDN TTL と cache_key_policy でオリジン負荷を削減
default_ttl = 3600(エッジキャッシュ1時間)と client_ttl = 300(ブラウザキャッシュ5分)を分けることで、キャンペーン終了後のパージが5分以内にエンドユーザーへ伝播する。request_coalescing = true でキャッシュミス時の同一オリジンへの重複リクエストを集約し、突発スパイク時のオリジン負荷を下げる。
WIF でキーレス認証・最小権限 IAM でリスク最小化
SA キーファイルを廃止し、KSA Annotation → GSA の WIF バインドで ADC が自動的に短命トークンを取得する。CDN キャッシュパージに必要な権限は roles/compute.admin ではなく roles/compute.viewer + roles/compute.networkAdmin の2ロールのみ。プロジェクトレベルではなく最小スコープで付与する。
KEDA Pub/Sub スケーラーで事前スケールアウト
CPU ベースの HPA のみではキャンペーン公開イベントによる突発スパイクへの対応が遅い。KEDA の google-cloud-pubsub スケーラーを追加することで、Pub/Sub サブスクリプションの未処理メッセージ数に応じて事前スケールアウトし、CDN パージの処理遅延を最小化できる。KEDA は内部的に HPA を管理するため、minReplicaCount を HPA と揃えて競合を防ぐ。
問題
ECサイト MOps チームでは、キャンペーン画像・動画コンテンツの配信 CDN バックエンドを GKE Autopilot + Cloud Load Balancing + Cloud CDN で構築している。以下の Terraform コードと Kubernetes マニフェストには 7つの設計上の問題 が潜んでいる。問題点を全て洗い出し、Cloud Armor × Cloud CDN キャッシュポリシー × Workload Identity Federation × GKE Autopilot セキュリティ強化(securityContext / PodDisruptionBudget / HPA / KEDA) を考慮した Bad→Good リファクタリングを行え。
制約・前提条件
- Terraform 1.8+(
googleプロバイダー 5.x) - GKE Autopilot 1.30+ クラスタ
- Cloud Load Balancing(グローバル外部 HTTPS LB)経由で Cloud CDN を使用
- Pub/Sub トリガーでキャンペーン公開イベントを受信し Cloud CDN キャッシュをパージ
- サービスアカウントキーファイルを使った認証(WIF 未導入)
- Cloud CDN パージ用の
roles/compute.admin(過剰権限) - PodDisruptionBudget・securityContext・KEDA 未設定
悪いコード (Before)
# 問題①: Cloud Armor セキュリティポリシーなし
resource "google_compute_backend_service" "cdn_backend" {
name = "mops-cdn-backend"
protocol = "HTTP"
enable_cdn = true
# 問題②: Cloud CDN キャッシュポリシー未設定(デフォルト TTL)
cdn_policy {
cache_mode = "CACHE_ALL_STATIC"
# default_ttl / max_ttl / client_ttl / cache_key_policy なし
}
# security_policy なし → Cloud Armor 無防備
}
resource "google_service_account" "cdn_purge_sa" {
account_id = "mops-cdn-purge"
}
# 問題④: roles/compute.admin(インスタンス削除まで可能な過剰権限)
resource "google_project_iam_member" "cdn_purge_admin" {
project = var.project_id
role = "roles/compute.admin"
member = "serviceAccount:${google_service_account.cdn_purge_sa.email}"
}
# 問題③: WIF なし(SA キーファイル使用)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cdn-purge-worker
namespace: mops
spec:
replicas: 1 # 問題⑤: PDB なし → ドレイン時に全停止
selector:
matchLabels:
app: cdn-purge-worker
template:
spec:
# serviceAccountName なし → default SA 使用
containers:
- name: cdn-purge-worker
image: asia-northeast1-docker.pkg.dev/PROJECT/mops/cdn-purge:latest
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
# 問題③: SA キーファイルをマウント
value: "/secrets/sa-key.json"
# 問題⑥: securityContext なし(root 実行・特権昇格可能)
volumeMounts:
- name: sa-key
mountPath: /secrets
volumes:
- name: sa-key
secret:
secretName: cdn-purge-sa-key
---
# 問題⑦: KEDA なし(HPA の CPU ベースのみ)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cdn-purge-worker-hpa
namespace: mops
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cdn-purge-worker
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
# 問題⑦: CPU 80% 閾値のみ(Pub/Sub キューが溜まっても反応遅い)
averageUtilization: 80
google_compute_security_policy で定義しアタッチdefault_ttl / max_ttl / client_ttl / cache_key_policy を明示設定/secrets/sa-key.json 漏洩リスク。WIF KSA → GSA バインドに変更しキーレス認証へroles/compute.viewer + roles/compute.networkAdmin の2ロールに最小化minAvailable: 2 で保護runAsNonRoot / readOnlyRootFilesystem / allowPrivilegeEscalation: false / seccompProfile: RuntimeDefault を設定google-cloud-pubsub スケーラーで事前スケールアウトヒント(段階的開示)
ヒント1 — 方向性
google_compute_security_policy リソースで定義し、google_compute_backend_service の security_policy 属性でアタッチする。
ヒント2 — アプローチ
- 問題①:
google_compute_security_policyで OWASP CRS(xss-stable, sqli-stable)+ レート制限ルールを定義。google_compute_backend_service.security_policyにアタッチ - 問題②:
cdn_policy { default_ttl = 3600; max_ttl = 86400; client_ttl = 300; cache_key_policy { include_host = true; include_protocol = true; include_query_string = false } request_coalescing = true } - 問題③: KSA に
iam.gke.io/gcp-service-accountAnnotation →google_service_account_iam_memberでroles/iam.workloadIdentityUserをバインド - 問題④:
roles/compute.adminを削除しroles/compute.viewer+roles/compute.networkAdminを付与 - 問題⑤:
PodDisruptionBudget: minAvailable: 2+ HPAminReplicas: 2に合わせる - 問題⑥:
securityContext: { runAsNonRoot: true; runAsUser: 1000; readOnlyRootFilesystem: true; allowPrivilegeEscalation: false; capabilities: { drop: ["ALL"] }; seccompProfile: { type: RuntimeDefault } } - 問題⑦: KEDA
ScaledObjectでtype: google-cloud-pubsub; subscriptionName: campaign-publish-sub; mode: SubscriptionSize; value: "5"を設定
ヒント3 — コードの骨格
# Terraform: Cloud Armor スケルトン
resource "google_compute_security_policy" "cdn_armor" {
rule {
action = "deny(403)"
priority = 1000
match { expr { expression = "evaluatePreconfiguredExpr('xss-stable')" } }
}
rule {
action = "throttle"
priority = 2000
match { versioned_expr = "SRC_IPS_V1"; config { src_ip_ranges = ["*"] } }
rate_limit_options {
conform_action = "allow"; exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold { count = 100; interval_sec = 60 }
}
}
rule { action = "allow"; priority = 2147483647; match { versioned_expr = "SRC_IPS_V1"; config { src_ip_ranges = ["*"] } } }
}
resource "google_compute_backend_service" "cdn_backend" {
security_policy = google_compute_security_policy.cdn_armor.id # 修正①
cdn_policy {
default_ttl = 3600; max_ttl = 86400; client_ttl = 300 # 修正②
request_coalescing = true
cache_key_policy { include_host = true; include_protocol = true }
}
}
# KEDA ScaledObject スケルトン
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: google-cloud-pubsub
metadata:
subscriptionName: campaign-publish-sub
mode: SubscriptionSize
value: "5"
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | Cloud Armor 未設定 | セキュリティ | google_compute_security_policy(OWASP CRS + レート制限)+ security_policy アタッチ |
| 2 | Cloud CDN TTL/キャッシュポリシー未設定 | パフォーマンス | default_ttl=3600 / max_ttl=86400 / client_ttl=300 / request_coalescing=true |
| 3 | SA キーファイル使用 | セキュリティ | WIF KSA → GSA バインド + ADC キーレス認証 |
| 4 | roles/compute.admin(過剰権限) | セキュリティ | roles/compute.viewer + roles/compute.networkAdmin の2ロール |
| 5 | PodDisruptionBudget 未設定 | 信頼性 | minAvailable: 2 + HPA minReplicas: 2 の整合 |
| 6 | securityContext 未設定(root 実行可能) | セキュリティ | runAsNonRoot / readOnlyRootFilesystem / seccompProfile: RuntimeDefault |
| 7 | HPA のみ(Pub/Sub キュー増加に反応遅い) | スケーラビリティ | KEDA google-cloud-pubsub スケーラー追加(SubscriptionSize mode) |
アーキテクチャ図 — Bad vs Good の変換フロー
模範解答
# 問題①: Cloud Armor なし
resource "google_compute_backend_service" "cdn_backend" {
enable_cdn = true
cdn_policy {
# 問題②: TTL / cache_key_policy 未設定
cache_mode = "CACHE_ALL_STATIC"
}
# security_policy なし
}
# 問題④: roles/compute.admin(過剰権限)
resource "google_project_iam_member" "cdn_purge_admin" {
role = "roles/compute.admin"
member = "serviceAccount:${google_service_account.cdn_purge_sa.email}"
}
# 問題③: WIF なし(SA キーファイル使用)
# ── Cloud Armor セキュリティポリシー(修正①)──────────────────
resource "google_compute_security_policy" "cdn_armor" {
name = "cdn-armor-policy"
project = var.project_id
# OWASP CRS: XSS 防御(priority 小 = 先評価)
rule {
action = "deny(403)"
priority = 1000
description = "OWASP XSS ルール"
match { expr { expression = "evaluatePreconfiguredExpr('xss-stable')" } }
}
# OWASP CRS: SQLi 防御
rule {
action = "deny(403)"
priority = 1001
description = "OWASP SQLi ルール"
match { expr { expression = "evaluatePreconfiguredExpr('sqli-stable')" } }
}
# レート制限: IP あたり 100 req/60s
rule {
action = "throttle"
priority = 2000
description = "IP レート制限(100 req/60s → 429)"
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
rate_limit_options {
conform_action = "allow"
exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold { count = 100; interval_sec = 60 }
}
}
# デフォルト: 許可
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
}
}
# ── バックエンドサービス(Cloud Armor + Cloud CDN)(修正①②)──
resource "google_compute_backend_service" "cdn_backend" {
name = "mops-cdn-backend"
project = var.project_id
protocol = "HTTP"
load_balancing_scheme = "EXTERNAL_MANAGED"
timeout_sec = 30
# 修正①: Cloud Armor セキュリティポリシーをアタッチ
security_policy = google_compute_security_policy.cdn_armor.id
# 修正②: Cloud CDN キャッシュポリシーを詳細設定
enable_cdn = true
cdn_policy {
cache_mode = "CACHE_ALL_STATIC"
default_ttl = 3600 # エッジキャッシュ: 1時間
max_ttl = 86400 # 最大 TTL: 24時間
client_ttl = 300 # ブラウザキャッシュ: 5分(パージ後5分で反映)
cache_key_policy {
include_host = true
include_protocol = true
include_query_string = false # QS をキャッシュキーから除外
}
request_coalescing = true # キャッシュミス時の重複リクエスト集約
}
log_config { enable = true; sample_rate = 0.1 }
backend {
group = google_compute_network_endpoint_group.cdn_neg.id
balancing_mode = "RATE"
max_rate_per_endpoint = 100
}
}
# ── Service Account(CDN パージ専用)──────────────────────
resource "google_service_account" "cdn_purge_sa" {
account_id = "mops-cdn-purge"
display_name = "MOps CDN Cache Purge SA"
project = var.project_id
}
# 修正④: roles/compute.admin → 最小権限(2ロールのみ)
resource "google_project_iam_member" "cdn_purge_viewer" {
project = var.project_id
role = "roles/compute.viewer" # バックエンドサービス参照用
member = "serviceAccount:${google_service_account.cdn_purge_sa.email}"
}
resource "google_project_iam_member" "cdn_cache_invalidator" {
project = var.project_id
role = "roles/compute.networkAdmin" # キャッシュ無効化 API
member = "serviceAccount:${google_service_account.cdn_purge_sa.email}"
}
# 修正③: WIF — KSA → GSA バインド
resource "google_service_account_iam_member" "cdn_purge_wif" {
service_account_id = google_service_account.cdn_purge_sa.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project_id}.svc.id.goog[mops/cdn-purge-worker]"
}
# Pub/Sub 読み取り権限
resource "google_pubsub_subscription_iam_member" "cdn_purge_sub" {
project = var.project_id
subscription = "campaign-publish-sub"
role = "roles/pubsub.subscriber"
member = "serviceAccount:${google_service_account.cdn_purge_sa.email}"
}
# ksa.yaml — KSA(WIF Annotation)
apiVersion: v1
kind: ServiceAccount
metadata:
name: cdn-purge-worker
namespace: mops
annotations:
# 修正③: KSA → GSA バインド(SA キーファイル廃止)
iam.gke.io/gcp-service-account: mops-cdn-purge@PROJECT_ID.iam.gserviceaccount.com
---
# deployment.yaml — CDN パージワーカー(改善後)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cdn-purge-worker
namespace: mops
spec:
replicas: 2
selector:
matchLabels:
app: cdn-purge-worker
template:
metadata:
labels:
app: cdn-purge-worker
spec:
# 修正③: 専用 KSA(ADC で自動 GSA 認証)
serviceAccountName: cdn-purge-worker
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: cdn-purge-worker
containers:
- name: cdn-purge-worker
# digest ピン留め(latest タグ禁止)
image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/cdn-purge-worker@sha256:def456
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: GOOGLE_CLOUD_PROJECT
value: "PROJECT_ID"
- name: PUBSUB_SUBSCRIPTION
value: "campaign-publish-sub"
# SA キーファイル環境変数は削除(ADC 使用)
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "512Mi"
# 修正⑥: securityContext(GKE Autopilot Restricted 準拠)
securityContext:
runAsNonRoot: true # root 実行禁止
runAsUser: 1000
readOnlyRootFilesystem: true # ルート FS 読み取り専用
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"] # 全 Linux Capabilities 削除
seccompProfile:
type: RuntimeDefault # デフォルト seccomp プロファイル
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 15
periodSeconds: 30
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
# SA キーファイル Secret マウントを削除
---
# pdb.yaml — PodDisruptionBudget(修正⑤)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: cdn-purge-worker-pdb
namespace: mops
spec:
# ノードドレイン / アップグレード時も最低 2 Pod を維持
minAvailable: 2
selector:
matchLabels:
app: cdn-purge-worker
---
# hpa.yaml — HPA(CPU + Memory、KEDA と共存)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cdn-purge-worker-hpa
namespace: mops
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cdn-purge-worker
# PDB minAvailable と揃える
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60 # 80% → 60% でスパイク余裕確保
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # スケールイン慎重に(5分待機)
---
# keda-scaler.yaml — KEDA ScaledObject: Pub/Sub(修正⑦)
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: cdn-purge-worker-scaler
namespace: mops
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cdn-purge-worker
# HPA minReplicas と揃える(競合防止)
minReplicaCount: 2
maxReplicaCount: 20
pollingInterval: 15 # 15秒ごとにキュー深さチェック
cooldownPeriod: 60
triggers:
- type: google-cloud-pubsub
metadata:
# キャンペーン公開イベントの Pub/Sub サブスクリプション
subscriptionName: campaign-publish-sub
mode: SubscriptionSize # 未処理メッセージ数でスケール
value: "5" # メッセージ 5件/Pod の比率
authenticationRef:
name: keda-gcp-auth
---
# keda-auth.yaml — KEDA GCP WIF 認証
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: keda-gcp-auth
namespace: mops
spec:
podIdentity:
provider: gcp-workload-identity
identityId: mops-cdn-purge@PROJECT_ID.iam.gserviceaccount.com
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① Cloud Armor なし | google_compute_security_policy(OWASP CRS + レート制限) | XSS / SQLi / DDoS をエッジで自動ブロック |
| ② CDN TTL 未設定 | default_ttl=3600 / client_ttl=300 / request_coalescing=true | キャンペーン終了後5分以内に反映 / オリジン負荷削減 |
| ③ SA キーファイル | WIF KSA → GSA バインド + ADC キーレス認証 | キー漏洩リスクゼロ・自動ローテーション |
| ④ compute.admin | compute.viewer + compute.networkAdmin の2ロール | インスタンス削除・設定変更権限を除去 |
| ⑤ PDB なし | minAvailable: 2 + HPA minReplicas: 2 の整合 | ノードドレイン時もサービス継続保証 |
| ⑥ securityContext なし | runAsNonRoot / readOnlyRootFilesystem / seccompProfile: RuntimeDefault | GKE Autopilot Restricted 準拠・特権昇格防止 |
| ⑦ HPA のみ | KEDA Pub/Sub SubscriptionSize スケーラー追加 | キャンペーン大量公開時に CPU 反応前に事前スケールアウト |
ポイント解説
preview: true で False Positive を確認してから deny(403) に切り替えるのが本番安全フロー。client_ttl と default_ttl の使い分け — default_ttl = 3600 はエッジ(CDN PoP)のキャッシュ保持時間。client_ttl = 300 はブラウザ側のキャッシュ保持時間(Cache-Control: max-age=300 として配信)。キャンペーン終了後の CDN パージはエッジキャッシュを即時削除するが、ブラウザ側は client_ttl 経過後に再取得するため、5分以内に全エンドユーザーへ反映される。/var/run/secrets/kubernetes.io/serviceaccount/token に注入。ADC(google.auth.default())はこのトークンを Google Security Token Service に送り、短命な GSA 有効期限付きアクセストークンを取得する。SA キーよりも短命(1時間)で自動ローテーションされるためセキュリティが高い。PDB minAvailable: 2 に対して HPA minReplicas: 1 にすると、HPA がスケールダウンして Pod 数が 1 の状態でノードドレインが発生すると PDB 違反になりドレインがブロックされる(クラスタアップグレードが詰まる)。minReplicas >= minAvailable + 1 を意識するか、少なくとも minReplicas = minAvailable に揃える。runAsNonRoot: true, seccompProfile: RuntimeDefault, capabilities.drop: ["ALL"], allowPrivilegeEscalation: false が必要。readOnlyRootFilesystem: true は一時書き込みが必要なディレクトリに emptyDir を使うことで対応。ScaledObject は内部的に HorizontalPodAutoscaler リソースを作成・管理する。独立した HorizontalPodAutoscaler が同じ Deployment を参照していると競合が発生するため注意。KEDA の minReplicaCount / maxReplicaCount を HPA の設定と揃えて管理する。pollingInterval: 15 で15秒ごとにサブスクリプションサイズをチェックし、CPU より先に反応してスケールアウトできる。実務への応用
Cloud Armor の OWASP CRS を有効にする際は、最初に preview: true で Cloud Logging にルール評価結果を記録し、jsonPayload.enforcedSecurityPolicy.outcome = "DENY" を Logs Explorer でフィルタして False Positive がないか確認する。ECサイトのキャンペーン LP は JavaScript の難読化コードが XSS ルールに引っかかるケースがあるため、ホワイトリストを evaluatePreconfiguredWafRules() で調整する。
KEDA の google-cloud-pubsub スケーラーを使うと、年末セールなどで数千件のキャンペーンが一斉公開されたとき、Pub/Sub に積み上がったメッセージ数に応じて自動的に Pod が 2 → 20 に増え、CDN パージが並列処理される。CPU が低いままでもキューが増えれば即座にスケールアウトするため、処理遅延による「パージ未完了でキャッシュが古いまま」の問題を防ぐ。
Argo Workflows のキャンペーンパイプラインでは、最終ステップで gcloud pubsub topics publish を実行し、KEDA トリガーが CDN パージワーカーをスケールアウトするアーキテクチャが自然。パイプライン完了 → CDN パージ → ユーザーに最新コンテンツが届くまでのレイテンシを DataDog カスタムメトリクス cdn.purge.latency_ms で計測し SLO を設定する。
roles/compute.networkAdmin は本来ネットワーク全般の管理権限で若干スコープが広い。本番では google_project_iam_custom_role で compute.urlMaps.invalidateCache のみ持つカスタムロールを作成して最小権限を徹底するとよい。
今日のまとめ
次のステップ
- 発展問題: Cloud Armor の適応型保護(Adaptive Protection)を有効にして、自動的に DDoS 攻撃パターンを検出するルールを動的に追加する設定を Terraform で実装する。また、Cloud Armor の WAF ルールと Firebase App Check を組み合わせたモバイルアプリ向け Bot 防御設計を考える
- 参考: Cloud Armor ドキュメント(OWASP CRS・適応型保護) / KEDA GCP Pub/Sub スケーラー / GKE Pod Security Standards(Restricted)/ Cloud CDN キャッシュポリシー設定ガイド