概要
HTTPRoute のネイティブ重み付け分割
nginx形式のカナリアアノテーションはGCE Ingressでは解釈されず黙って無視される。Gateway APIのHTTPRouteはbackendRefs[].weightをコントローラー非依存の標準機能として持つ。
HealthCheckPolicy でreadiness実態に合わせる
GCEデフォルトのヘルスチェック(パス/)はアプリの/healthzセマンティクスと一致しない。未readyなPodへのルーティングを構造的に防ぐ。
GCPBackendPolicy でCloud Armor接続
公開APIには常時セキュリティポリシーが必要。GCPBackendPolicyでCloud Armorをバックエンドサービスへ紐付け、リリース中も防御レベルを維持する。
メトリクスベースの自動昇格・自動ロールバック
「Slackで苦情が来たら気づく」から、DataDogのerror_rateを機械的にポーリングし、閾値以下なら自動昇格・超過なら自動ロールバックする仕組みへ切り替える。
問題
ECサイト MOps 販促システムチームが運用するバナー配信API(banner-api)(トップページのタイムセールバナーを返す、GKE Autopilot上の公開APIで社内外から高頻度に呼ばれる)は、これまでGCE Ingress(kubernetes.io/ingress.class: gce)+ nginx形式のカナリアアノテーションでリリースを行ってきた。
先週起きたインシデント: 新バージョンのカナリアリリースを「まず10%のトラフィックだけ流す」つもりでnginx.ingress.kubernetes.io/canary-weight: "10"を設定してデプロイしたところ、実際には新バージョンにトラフィックの100%が即座に流れ、5分間エラー率が急上昇した。原因調査の結果、GCE Ingressコントローラーはnginx-ingress形式のカナリアアノテーションを解釈できず黙って無視すること、そして実際のトラフィック切替はServiceのselectorラベルを新Deploymentへ直接書き換える方式で行われており、これは重み付けではなくアトミックな全量切替だったことが判明した。
問題点を全て洗い出し、GKE Gateway API(HTTPRouteの重み付けトラフィック分割)× HealthCheckPolicy × GCPBackendPolicy(Cloud Armor接続) × 段階的カナリア(10%→50%→100%) × DataDogメトリクスに基づく自動ロールバックを考慮したBad→Goodリファクタリングを行ってください。
制約・前提条件
- GKE Autopilot 1.30+、Terraform 1.8+(
googleプロバイダー 5.x)、リージョンasia-northeast1 banner-apiは公開APIであり、L7攻撃・ボットスクレイピング対策としてCloud Armorセキュリティポリシーの適用が必須要件- readiness probeは
/healthz(起動後の依存先接続確認を含み、起床直後は失敗する想定) - カナリア昇格の判断基準: DataDog上の
error_rate(5xx比率)が閾値(1%)を超えなければ次の重みへ自動昇格、超えた場合は自動的に旧バージョンへ100%戻す - 2026年時点のGKEロードマップでは、classic Ingress(GCE Ingress)は新規機能追加が非推奨とされ、Gateway APIが標準の移行先として位置付けられている
Gateway・HTTPRoute・HealthCheckPolicy・GCPBackendPolicy含む)+ 段階的カナリア昇格・自動ロールバックの運用フロー(設計意図の説明)
悪い構成 (Before)
# 問題①: GCE Ingressはnginx形式のカナリアアノテーションを解釈しない(黙って無視)
# 問題②: 実際の切替はService selector直接書き換え(アトミックな全量切替)
# 問題③: ヘルスチェックがGCEデフォルトのままでアプリの/healthzと不一致
# 問題④: 公開APIにCloud Armorセキュリティポリシー未接続
# 問題⑤: 昇格・ロールバック判断がSlack目視の人間依存
# 問題⑥: GKEロードマップでGCE Ingress新規機能は非推奨なのに新機能を作り込み
# 問題⑦: 重み・監視期間・エラー率閾値が暗黙的でレビュー不可能
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: banner-api-ingress
annotations:
kubernetes.io/ingress.class: "gce"
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10" # 解釈されない
spec:
rules:
- http:
paths:
- path: /banners
pathType: Prefix
backend:
service:
name: banner-api-svc
port:
number: 80
---
# 実際のカナリア切替はこちらのコマンドで実施していた
# kubectl patch service banner-api-svc \
# -p '{"spec":{"selector":{"version":"v2"}}}'
/healthzと不一致ヒント(段階的開示)
ヒント1 — 方向性
canary-weightを書いても静かに無視される。「エラーも警告も出ないが、意図通りに動いていない」設定は最も気づきにくい種類のバグである。(2) 重み付け分割そのものが存在しない — Service selectorの書き換えは「Aを捨ててBにする」というスイッチであって、「Aを90%・Bを10%」という分割ではない。段階的カナリアを実現するには、ロードバランサ自身がトラフィックを比率で振り分ける機能を持つ道具に置き換える必要がある。(3) 昇格・撤退の判断が人間の目視に依存している — 「Slackで苦情が来たら気づく」という運用は、検知が遅れるだけでなく、判断基準が属人化し再現性がない。これら3層を解決する道具として、GCPが2026年時点で標準の移行先と位置付けるGateway APIのHTTPRoute重み付けルーティングと、メトリクスに基づく自動昇格・自動ロールバックの仕組みを考えてみてください。
ヒント2 — アプローチ
- 問題①:
nginx.ingress.kubernetes.io/canary-weightをGCE Ingressに設定 → コントローラー非対応のアノテーションは黙って無視される。Gateway APIのHTTPRouteはbackendRefs[].weightがネイティブ機能として存在し、コントローラーに依存せず重み付け分割が効く - 問題②: Service
selectorの直接書き換えによる切替 → アトミックな全量切替であり「10%だけ試す」が原理的に不可能。HTTPRouteでbanner-api-stableServiceとbanner-api-canaryServiceの2つにweightを割り振り、比率でトラフィックを分割する - 問題③: ヘルスチェックの明示設定がない → GCEデフォルトのヘルスチェック(パス
/、間隔60秒)がアプリの/healthzセマンティクスと一致せず、起動直後の未readyなPodにもトラフィックが流れ得る。HealthCheckPolicyでアプリのreadiness実態に合わせたヘルスチェックを明示する - 問題④: 公開APIにも関わらずCloud Armorセキュリティポリシーが未接続 →
GCPBackendPolicyでCloud Armorセキュリティポリシーをバックエンドサービスへアタッチし、L7攻撃・ボットスクレイピング対策を組み込む - 問題⑤: 昇格・ロールバックが人間の目視・手動kubectlコマンドに依存 → DataDogの
error_rateメトリクスをポーリングし、閾値以下なら次の重み(10%→50%→100%)へ自動昇格、閾値超過なら自動的に0%(旧バージョンへ全戻し)するワークフローをArgo Workflowsで組む - 問題⑥: GCE Ingressは2026年時点でGKEロードマップ上「新規機能は非推奨、Gateway APIへの移行が標準」とされているにも関わらず、新機能(カナリアリリース)をIngressベースで作り込もうとしている → 技術的負債を増やす前に、移行を機会にGateway APIへ切り替える
- 問題⑦: カナリアの検証時間・閾値・昇格ステップが暗黙的 → 重み配列・監視期間・エラー率閾値をTerraform変数/Workflowパラメータとして明示し、リリースごとの調整余地とレビュー可能性を持たせる
ヒント3 — コードの骨格
# Gateway API: Gateway + HTTPRoute のスケルトン
resource "kubernetes_manifest" "banner_gateway" {
manifest = {
apiVersion = "gateway.networking.k8s.io/v1"
kind = "Gateway"
metadata = { name = "banner-gateway", namespace = "mops" }
spec = {
gatewayClassName = "gke-l7-global-external-managed"
listeners = [{ name = "https", port = 443, protocol = "HTTPS", ... }]
}
}
}
resource "kubernetes_manifest" "banner_httproute" {
manifest = {
apiVersion = "gateway.networking.k8s.io/v1"
kind = "HTTPRoute"
metadata = { name = "banner-api-route", namespace = "mops" }
spec = {
parentRefs = [{ name = "banner-gateway" }]
rules = [{
matches = [{ path = { type = "PathPrefix", value = "/banners" } }]
backendRefs = [
{ name = "banner-api-stable", port = 80, weight = 90 },
{ name = "banner-api-canary", port = 80, weight = 10 }, # 段階的に引き上げる
]
}]
}
}
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | nginxカナリアアノテーションが黙って無視される | 道具ミスマッチ層 | Gateway API HTTPRouteのネイティブ重み付け |
| 2 | Service selector書き換え=アトミック全量切替 | 分割メカニズム欠如層 | stable/canary 2 Serviceへweight分割 |
| 3 | ヘルスチェックがGCEデフォルトのまま | 接続制御層 | HealthCheckPolicyで/healthz明示 |
| 4 | Cloud Armor未接続 | 接続制御層 | GCPBackendPolicyでセキュリティポリシー接続 |
| 5 | 昇格・ロールバックが人間の目視依存 | 判断自動化層 | DataDog error_rateポーリング+自動昇格/ロールバック |
| 6 | 非推奨GCE Ingress上に新機能を作り込み | 道具ミスマッチ層 | Gateway APIへ移行を機会に切替 |
| 7 | 重み・監視期間・閾値が暗黙的 | 明文化層 | Terraform変数/Workflowパラメータ化 |
カナリアリリースフロー図 — Bad vs Good の変換
模範解答
# 問題①②③④⑤⑥⑦: 重み付け分割・ヘルスチェック・
# セキュリティ・自動化のいずれもコントローラーが対応していない
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: banner-api-ingress
annotations:
kubernetes.io/ingress.class: "gce"
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
rules:
- http:
paths:
- path: /banners
pathType: Prefix
backend:
service:
name: banner-api-svc
port: { number: 80 }
---
# 実際の切替: kubectl patch service selector書き換え
# kubectl patch service banner-api-svc \
# -p '{"spec":{"selector":{"version":"v2"}}}'
# ── Gateway(GKE Gateway Controller、外部公開)──────
resource "kubernetes_manifest" "banner_gateway" {
manifest = {
apiVersion = "gateway.networking.k8s.io/v1"
kind = "Gateway"
metadata = { name = "banner-gateway", namespace = "mops" }
spec = {
gatewayClassName = "gke-l7-global-external-managed"
listeners = [{
name = "https", port = 443, protocol = "HTTPS"
tls = {
mode = "Terminate"
certificateRefs = [{ name = "banner-api-tls-cert" }]
}
}]
}
}
}
# ── HealthCheckPolicy(修正③: readiness実態に合わせる)──
resource "kubernetes_manifest" "banner_healthcheck_policy" {
manifest = {
apiVersion = "networking.gke.io/v1"
kind = "HealthCheckPolicy"
metadata = { name = "banner-api-healthcheck", namespace = "mops" }
spec = {
targetRef = { group = "", kind = "Service", name = "banner-api-stable" }
default = {
checkIntervalSec = 10, timeoutSec = 5
healthyThreshold = 2, unhealthyThreshold = 3
config = {
type = "HTTP"
httpHealthCheck = { requestPath = "/healthz", port = 8080 }
}
}
}
}
}
# ── GCPBackendPolicy(修正④: Cloud Armor接続)──────
resource "kubernetes_manifest" "banner_backend_policy" {
manifest = {
apiVersion = "networking.gke.io/v1"
kind = "GCPBackendPolicy"
metadata = { name = "banner-api-backend-policy", namespace = "mops" }
spec = {
targetRef = { group = "", kind = "Service", name = "banner-api-stable" }
default = { securityPolicy = google_compute_security_policy.banner_api_armor.name }
}
}
}
resource "google_compute_security_policy" "banner_api_armor" {
name = "banner-api-armor-policy"
rule {
action = "throttle", priority = 1000
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
rate_limit_options {
conform_action = "allow", exceed_action = "deny(429)"
rate_limit_threshold { count = 100, interval_sec = 60 } # ボット対策
}
}
rule {
action = "allow", priority = 2147483647
match { versioned_expr = "SRC_IPS_V1", config { src_ip_ranges = ["*"] } }
}
}
# ── HTTPRoute(修正①②: ネイティブ重み付け分割)──────
resource "kubernetes_manifest" "banner_httproute" {
manifest = {
apiVersion = "gateway.networking.k8s.io/v1"
kind = "HTTPRoute"
metadata = { name = "banner-api-route", namespace = "mops" }
spec = {
parentRefs = [{ name = "banner-gateway" }]
rules = [{
matches = [{ path = { type = "PathPrefix", value = "/banners" } }]
backendRefs = [
{ name = "banner-api-stable", port = 80, weight = var.canary_weight_stable },
{ name = "banner-api-canary", port = 80, weight = var.canary_weight_canary },
]
}]
}
}
}
# 修正⑦: 重みをTerraform変数として明示(暗黙のマジックナンバー排除)
variable "canary_weight_canary" { type = number, default = 10 }
variable "canary_weight_stable" { type = number, default = 90 }
① カナリアデプロイ開始
├─ banner-api-canary Deploymentへ新バージョンをデプロイ
├─ HealthCheckPolicy/readinessProbeがPassするまで待機(修正③)
└─ HTTPRoute weight を stable=90 / canary=10 へ設定(修正①②)
② 監視ウィンドウ(各ステップ10分、修正⑤⑦)
├─ Argo WorkflowsがDataDog APIを10分間ポーリングしerror_rate(5xx比率)を取得
├─ 閾値(1%)以下 → 次のステップへ自動昇格
│ 10% → 50% → 100% の順にweightを書き換え(kubectl patch HTTPRoute)
└─ 閾値(1%)超過 → 自動ロールバック
HTTPRoute weightを stable=100 / canary=0 へ即時書き戻し
Slackへ「自動ロールバック発生」を通知(原因調査は人間が引き継ぐ)
③ 100%昇格・完了
├─ 全ステップでerror_rateが閾値以下だったことを確認後、canary=100%へ到達
├─ banner-api-stable Deploymentのイメージをcanaryと同一へ更新(世代交代)
└─ banner-api-canary Deploymentをスケールダウン(次回リリースまで待機)
④ Cloud Armor・ヘルスチェックは常時有効(修正③④)
→ カナリア中もstable/canary両Serviceに同一のHealthCheckPolicy・GCPBackendPolicyが適用され、
防御レベル・ヘルスチェック基準がリリース中に変化しないことを保証する
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① nginxアノテーションが無視される | Gateway API HTTPRouteのネイティブ重み付け | コントローラー非依存で確実に分割が効く |
| ② selector書き換え=全量切替 | stable/canary 2 Serviceへweight分割 | 段階的な比率での検証が可能に |
| ③ ヘルスチェック不一致 | HealthCheckPolicyで/healthzを明示 | 未readyなPodへのルーティングを防止 |
| ④ Cloud Armor未接続 | GCPBackendPolicyでセキュリティポリシー接続 | L7攻撃・ボットスクレイピング対策 |
| ⑤ 人間の目視依存 | DataDog error_rateポーリング+自動昇格/ロールバック | 検知速度向上・判断基準の再現性確保 |
| ⑥ 非推奨Ingress上に新機能を作り込み | Gateway APIへの移行を機会に切替 | 技術的負債の抑制 |
| ⑦ 重み・閾値が暗黙的 | Terraform変数/Workflowパラメータ化 | リリースごとの調整をレビュー可能に |
ポイント解説
HTTPRoute)を選定する必要がある。実務への応用
banner-apiに限らず、MOps 販促システムチームが持つ他の公開API(クーポンAPI・キャンペーン対象判定API等)も同様にGCE Ingressベースで稼働している場合、同じ「カナリアアノテーションが無視される」落とし穴を抱えている可能性が高い。今回のGateway API移行パターン(Gateway + HTTPRouteの重み付け + HealthCheckPolicy + GCPBackendPolicy)はテンプレート化でき、他APIへの横展開時にTerraformモジュールとして共通化すれば、移行のたびに同じ7つの問題を再発見する手間を省ける。また、DataDogメトリクスに基づく自動昇格・自動ロールバックの仕組みは、Argo Workflows上で「カナリア分析ステップ」として汎用化しておけば、将来的にArgo Rolloutsのようなプログレッシブデリバリー専用ツールへの移行を検討する際の要件定義にもそのまま使える。
HTTPRouteでweightを書き換える運用は、Terraform applyで行うと1ステップごとにCI/CDパイプラインを通す必要がありレイテンシが生じる。10分単位で重みを動かす自動カナリアでは、Terraform stateを介さずKubernetes APIへ直接kubectl patch(もしくはArgo WorkflowsからのKubernetes API呼び出し)で書き換え、Terraformの管理下にある初期値(デプロイのたびの基準値)とは役割を分離するのが実務上の勘所になる。
今日のまとめ
次のステップ
- 発展問題: カナリア昇格の判断基準を
error_rate単一指標ではなく、レイテンシ(p99)・リクエスト数(サンプルサイズ不足による誤判定防止)を組み合わせた複合判定に拡張し、「トラフィックが少ない深夜帯にカナリアを開始した場合、error_rateのサンプル数が少なすぎて偶然1件のエラーで自動ロールバックしてしまう」という誤検知シナリオを防ぐ設計を検討せよ - 参考: Gateway API(
HTTPRouteweighted backendRefs) / GKE Gateway Controller /HealthCheckPolicy/GCPBackendPolicy/ Cloud Armor rate limiting / Argo Workflows によるカナリア分析パターン