概要
Sidecar Container(Envoy) でタイムアウトを一元管理
アプリコードに書かず、GKE 1.29+ stableのSidecar Containerに持たせる。接続1秒・リクエスト2秒を外部APIのSLA(p99 2秒)に合わせて設定し、応答待ちがCloud Runの同時実行枠を埋め尽くすのを防ぐ。
サーキットブレーカー × Outlier Detection
劣化した外部APIを叩き続けて負荷を増幅させる側に回らない。circuit_breakersとoutlier_detection(連続5xxで一時的に切り離す)で追い打ちを構造的に止める。
ビジネス承認済みフォールバックをコードに反映
「外部API障害時は全国一律送料へ切り替えてでもチェックアウトを継続する」という既承認方針が実装されていなかった。DEFAULT_FLAT_RATE_JPYで正常系として扱う。
エラーバジェットをリリース判断に接続
SLO/SLIを定義し、マルチウィンドウ・マルチバーンレートアラートで検知精度を上げ、エラーバジェット残量が閾値未満ならCI/CDが非緊急デプロイを自動でブロックする。
問題
ECサイト MOps 販促システムチームが運用する配送料金見積もりサービス(shipping-rate-estimator)は、チェックアウトフロー内で外部配送会社の料金見積もりAPI(サードパーティ、SLA 99.5%)を同期呼び出しし、購入者に正確な送料を提示している。
先週起きたインシデント: 外部配送会社側で障害が発生し、応答時間が平常時150msから最大8秒まで劣化する状態が40分間続いた。shipping-rate-estimatorはこの外部呼び出しにタイムアウトを設定しておらず、Cloud Runの同時実行枠(concurrency)が応答待ちのリクエストで埋まり尽くした結果、送料計算と無関係な他のチェックアウトリクエストまで一切受け付けられなくなり、40分間チェックアウト機能全体が停止した。実際にはビジネス側は「外部APIが落ちた場合は全国一律送料にフォールバックしてでもチェックアウトは止めない」という方針を既に承認していたが、この方針はコードに反映されていなかった。
問題点を全て洗い出し、GKE Autopilot Sidecar Containers(Envoyによるタイムアウト×サーキットブレーカー×Outlier Detection)× アプリケーション側フォールバック × Cloud Monitoring SLO(request-based SLI) × マルチウィンドウ・マルチバーンレートアラート × エラーバジェットに基づくリリース凍結ゲート × バックオフ+ジッター付きリトライを考慮したBad→Goodリファクタリングを行ってください。
制約・前提条件
- GKE Autopilot 1.30+(Sidecar Containers は stable 機能)、Terraform 1.8+(
googleプロバイダー 5.x)、リージョンasia-northeast1 - 外部配送会社APIのSLA: 稼働率99.5%、p99レイテンシ2秒(達成できない時間帯がある前提で設計すること)
- チェックアウトサービス全体のSLO目標: 可用性99.9%/月(社内合意済み)。送料計算はクリティカルパスの一部だが、外部API障害時は全国一律送料へフォールバックしてでも継続するというビジネス判断が既に下されている
- フォールバック送料の差額は自社が吸収する方針(チェックアウト全停止による機会損失よりはるかに小さい損失として経営判断済み)
- 監視基盤: Cloud Monitoring(SLO機能)+ DataDog Agent v7 併用
悪い構成 (Before)
# shipping-rate-estimator/app/checkout.py
import requests
def get_shipping_rate(cart: Cart) -> ShippingRate:
# 問題①: タイムアウト未設定(デフォルトで無限待機)
# 問題⑦: 即時リトライ、バックオフ・ジッターなし
for attempt in range(3):
try:
resp = requests.post(
"https://carrier-api.example.com/v1/estimate",
json={"items": cart.items, "zip": cart.zip_code},
)
resp.raise_for_status()
return ShippingRate(amount=resp.json()["amount"])
except requests.RequestException:
continue # 問題②: サーキットブレーカーなし、失敗しても叩き続ける
# 問題③: 全リトライ失敗時、フォールバックせずそのまま例外を上げる
raise ShippingRateUnavailableError()
# terraform/checkout.tf(抜粋)
# 問題④: SLO/SLIが未定義、「どこまで悪化したら問題か」の客観基準がない
resource "google_cloud_run_v2_service" "shipping_rate_estimator" { ... }
# 問題⑤: 静的閾値アラートのみ(じわじわ進行する劣化を検知できない)
resource "google_monitoring_alert_policy" "naive_5xx_alert" {
conditions {
condition_threshold {
comparison = "COMPARISON_GT"
threshold_value = 5
duration = "60s"
}
}
}
# 問題⑥: エラーバジェットの消費状況がCI/CDのデプロイ判断と未接続
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①: 外部API呼び出しにタイムアウト未設定 → 外部APIのSLA(p99レイテンシ2秒)を踏まえ、接続タイムアウト1秒・リクエストタイムアウト2秒を明示する。アプリコードに直接書くより、Sidecar ContainerのEnvoyプロキシに持たせるとアプリコード非依存で一元管理できる
- 問題②: サーキットブレーカーが存在しない → Envoyの
circuit_breakers(max_pending_requests/max_requests)とoutlier_detection(連続5xxでホストを一時的に切り離す)を設定し、劣化した外部APIへの追い打ちを止める - 問題③: フォールバックが未実装 → サーキットがオープンした場合、アプリ側は例外をキャッチして
DEFAULT_FLAT_RATE_JPY(全国一律送料の名前付き定数)を返し、フォールバック使用をメトリクス・構造化ログで可視化する - 問題④: SLO/SLIが未定義 →
google_monitoring_sloでチェックアウトのrequest-based SLI(成功 or フォールバック成功を「good」、タイムアウトで失敗したもののみ「bad」)を定義し、月間99.9%のSLO目標を明文化する - 問題⑤: 静的閾値アラートのみ → Google SREワークブックのマルチウィンドウ・マルチバーンレート(短時間窓での急激なバーン + 長時間窓でのslow burn)を組み合わせたアラートポリシーに置き換える
- 問題⑥: エラーバジェットとリリース判断が未接続 → CI/CDパイプライン(Argo Workflows / GitHub Actions)にデプロイ前ゲートを追加し、Cloud MonitoringのSLOバーンレートを問い合わせてエラーバジェット残量が閾値未満なら非緊急リリースをブロックする
- 問題⑦: 即時リトライでバックオフ・ジッターがない →
tenacity等で指数バックオフ+ジッター、最大リトライ回数を制限し、リクエストタイムアウト予算内に収める設計にする
ヒント3 — コードの骨格
# Sidecar Container: Envoyによるタイムアウト・サーキットブレーカー・Outlier Detection
apiVersion: apps/v1
kind: Deployment
metadata:
name: shipping-rate-estimator
spec:
template:
spec:
containers:
- name: app
image: shipping-rate-estimator:latest
# アプリはlocalhost:15001(Envoy sidecar)経由で外部APIを呼ぶ
- name: envoy-sidecar
restartPolicy: Always # GKE 1.29+ stable: Sidecar Containers
image: envoyproxy/envoy:v1.30-latest
# clusters: carrier_api
# connect_timeout: 1s
# circuit_breakers.thresholds: [{max_pending_requests: 20, max_requests: 20}]
# outlier_detection: {consecutive_5xx: 3, base_ejection_time: 30s}
# Cloud Monitoring SLO(request-based SLI)のスケルトン
resource "google_monitoring_slo" "checkout_availability" {
service = google_monitoring_service.checkout.service_id
slo_id = "checkout-availability"
goal = 0.999
rolling_period_days = 30
request_based_sli {
good_total_ratio {
good_service_filter = "... AND (status=\"OK\" OR fallback_used=\"true\")"
total_service_filter = "..."
}
}
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | タイムアウト未設定で他リクエストまで巻き添え | 伝播遮断層 | Envoy sidecarで接続1s/リクエスト2s明示 |
| 2 | サーキットブレーカー不在で外部負荷を増幅 | 伝播遮断層 | circuit_breakers+outlier_detection |
| 3 | フォールバック未実装 | 伝播遮断層 | DEFAULT_FLAT_RATE_JPYへの正常系フォールバック |
| 4 | SLO/SLI未定義で悪化判断が感覚依存 | 定義層 | google_monitoring_slo request-based SLI |
| 5 | 静的閾値アラートのみでslow burn検知不能 | 定義層 | マルチウィンドウ・マルチバーンレート |
| 6 | エラーバジェットとリリース判断が未接続 | 運用自動化層 | CI/CDデプロイ前ゲート |
| 7 | 即時リトライでthundering herd誘発 | 運用自動化層 | tenacity指数バックオフ+ジッター |
障害伝播フロー図 — Bad vs Good の変換
模範解答
# 問題①②: タイムアウトもサーキットブレーカーもなく
# 外部APIへ直接requests.post()するのみ
resp = requests.post(
"https://carrier-api.example.com/v1/estimate",
json={"items": cart.items, "zip": cart.zip_code},
)
# k8s/shipping-rate-estimator-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: shipping-rate-estimator
namespace: mops
spec:
replicas: 3
template:
spec:
containers:
# ── メインアプリ ──
- name: app
image: asia-northeast1-docker.pkg.dev/mops/checkout/shipping-rate-estimator:v2
env:
- name: CARRIER_API_PROXY
value: "http://localhost:15001" # Envoy経由
resources:
requests: { cpu: "250m", memory: "256Mi" }
# ── Envoy Sidecar(修正①②: GKE 1.29+ stable)──
- name: envoy-sidecar
restartPolicy: Always # sidecar指定
image: envoyproxy/envoy:v1.30-latest
volumeMounts:
- name: envoy-config
mountPath: /etc/envoy
resources:
requests: { cpu: "100m", memory: "128Mi" }
volumes:
- name: envoy-config
configMap: { name: envoy-carrier-proxy-config }
---
apiVersion: v1
kind: ConfigMap
metadata:
name: envoy-carrier-proxy-config
namespace: mops
data:
envoy.yaml: |
static_resources:
listeners:
- name: listener_15001
address: { socket_address: { address: 0.0.0.0, port_value: 15001 } }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
route_config:
virtual_hosts:
- name: carrier_api_vh
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: carrier_api
timeout: 2s # 修正①: リクエストタイムアウト
clusters:
- name: carrier_api
connect_timeout: 1s # 修正①: 接続タイムアウト
type: LOGICAL_DNS
load_assignment:
cluster_name: carrier_api
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: carrier-api.example.com, port_value: 443 } } }
circuit_breakers: # 修正②: サーキットブレーカー
thresholds:
- priority: DEFAULT
max_pending_requests: 20
max_requests: 20
max_retries: 2
outlier_detection: # 修正②: Outlier Detection
consecutive_5xx: 3
base_ejection_time: 30s
max_ejection_percent: 100
# shipping-rate-estimator/app/checkout.py
"""チェックアウト時の配送料金見積もりを行うモジュール。
外部配送会社APIの障害時は、ビジネス側が承認済みの
全国一律送料フォールバックへ切り替え、チェックアウトを継続させる。
"""
import logging
import httpx
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type,
)
logger = logging.getLogger(__name__)
DEFAULT_FLAT_RATE_JPY = 660 # 修正③: 障害時フォールバック送料(全国一律・経営承認済み)
CARRIER_API_TIMEOUT_SEC = 2.0 # 修正①: 外部APIのp99 SLAに合わせたアプリ側タイムアウト
MAX_RETRY_ATTEMPTS = 2 # 修正⑦: 過剰リトライによるthundering herdを防ぐ上限
@retry(
stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
wait=wait_exponential_jitter(initial=0.2, max=1.0), # 修正⑦: 指数バックオフ+ジッター
retry=retry_if_exception_type(httpx.HTTPError),
reraise=True,
)
def _call_carrier_api(cart: Cart) -> float:
"""Envoy sidecar(localhost:15001)経由で外部配送会社APIを呼ぶ。
Args:
cart: 見積もり対象のカート。
Returns:
配送料金(円)。
Raises:
httpx.HTTPError: サーキットオープン中・タイムアウト・5xx等の失敗時。
"""
with httpx.Client(base_url="http://localhost:15001", timeout=CARRIER_API_TIMEOUT_SEC) as client:
resp = client.post("/v1/estimate", json={"items": cart.items, "zip": cart.zip_code})
resp.raise_for_status()
return resp.json()["amount"]
def get_shipping_rate(cart: Cart) -> ShippingRate:
"""カートの配送料金を取得する。外部API障害時はフォールバックする。
Args:
cart: 見積もり対象のカート。
Returns:
ShippingRate: 送料と、フォールバックを使用したかどうかのフラグを含む。
"""
try:
amount = _call_carrier_api(cart)
return ShippingRate(amount=amount, fallback_used=False)
except httpx.HTTPError as exc:
# 修正③: チェックアウトを失敗させず、承認済みフォールバックへ切り替える
logger.warning(
"shipping_rate_fallback_used",
extra={"reason": str(exc), "cart_id": cart.id, "fallback_amount": DEFAULT_FLAT_RATE_JPY},
)
SHIPPING_RATE_FALLBACK_COUNTER.inc() # DataDog/Cloud Monitoringへ送出するカスタムメトリクス
return ShippingRate(amount=DEFAULT_FLAT_RATE_JPY, fallback_used=True)
resource "google_monitoring_service" "checkout" {
service_id = "checkout"
display_name = "Checkout (shipping-rate-estimator含む)"
}
# 修正④: request-based SLI(フォールバック成功も「good」に含める=
# ビジネス判断上、チェックアウトが完走すればSLOは満たされているとみなす)
resource "google_monitoring_slo" "checkout_availability" {
service = google_monitoring_service.checkout.service_id
slo_id = "checkout-availability"
display_name = "Checkout Availability (fallback許容)"
goal = 0.999 # 月間99.9%
rolling_period_days = 30
request_based_sli {
good_total_ratio {
good_service_filter = "resource.type=\"cloud_run_revision\" AND (metric.label.response_code_class=\"2xx\" OR jsonPayload.fallback_used=\"true\")"
total_service_filter = "resource.type=\"cloud_run_revision\""
}
}
}
# 修正⑤: マルチウィンドウ・マルチバーンレートアラート(Google SREワークブック準拠)
resource "google_monitoring_alert_policy" "checkout_slo_fast_burn" {
display_name = "checkout SLO fast burn (1h窓, 14.4x)"
conditions {
display_name = "burn_rate > 14.4 over 1h AND 5m"
condition_threshold {
filter = "select_slo_burn_rate(\"${google_monitoring_slo.checkout_availability.id}\", \"3600s\")"
comparison = "COMPARISON_GT"
threshold_value = 14.4
duration = "0s"
}
}
combiner = "OR"
notification_channels = [google_monitoring_notification_channel.pagerduty.id]
}
resource "google_monitoring_alert_policy" "checkout_slo_slow_burn" {
display_name = "checkout SLO slow burn (6h窓, 6x)"
conditions {
display_name = "burn_rate > 6 over 6h"
condition_threshold {
filter = "select_slo_burn_rate(\"${google_monitoring_slo.checkout_availability.id}\", \"21600s\")"
comparison = "COMPARISON_GT"
threshold_value = 6
duration = "0s"
}
}
combiner = "OR"
notification_channels = [google_monitoring_notification_channel.slack.id]
}
# .github/workflows/deploy-shipping-rate-estimator.yaml(抜粋)
jobs:
error-budget-gate:
runs-on: ubuntu-latest
outputs:
budget_remaining_pct: ${{ steps.check.outputs.budget_remaining_pct }}
steps:
- id: check
run: |
# Cloud Monitoring SLO APIからエラーバジェット残量を取得
REMAINING=$(gcloud monitoring slo describe checkout-availability \
--service=checkout --format="value(errorBudget.remainingPercent)")
echo "budget_remaining_pct=$REMAINING" >> "$GITHUB_OUTPUT"
deploy:
needs: error-budget-gate
# 修正⑥: エラーバジェット10%超、またはincident-fixタグ付きのみ通常デプロイを許可
if: needs.error-budget-gate.outputs.budget_remaining_pct > 10 || contains(github.event.head_commit.message, 'incident-fix')
runs-on: ubuntu-latest
steps:
- run: echo "デプロイ実行"
ポイント解説
実務への応用
shipping-rate-estimatorに限らず、MOps 販促システムチームが外部SaaS・外部APIに依存する箇所(決済代行、住所検証API、クーポン外部連携等)は同様に「タイムアウト未設定 × フォールバック未実装」のリスクを抱えている可能性が高い。今回のSidecar Container(Envoy)によるタイムアウト・サーキットブレーカー・Outlier Detectionはアプリコードを一切変更せずインフラ層だけで適用できるため、既存サービスへの後付け導入コストが低く、横展開しやすい。また、エラーバジェットに基づくリリース凍結ゲートは、shipping-rate-estimatorだけでなくチェックアウト全体のSLOに対して汎用化しておけば、将来的に他の依存サービス(在庫確認API・クーポン適用API)を追加する際も同じゲートで守れる。
SHIPPING_RATE_FALLBACK_COUNTERのようなフォールバック使用率メトリクス自体を別途監視し、一定割合を超えたら経営層へエスカレーションする運用も併せて設計しておくべきである。
今日のまとめ
次のステップ
- 発展問題: フォールバック使用率(
SHIPPING_RATE_FALLBACK_COUNTER/ 全リクエスト数)自体に対しても閾値ベースのアラートを追加し、「サーキットブレーカーが正しく機能してチェックアウトは止まっていないが、外部API障害が長時間続いてフォールバック送料による損失が積み上がっている」状態を検知する設計を検討せよ。損失額をリアルタイムで概算し、一定額を超えたらインシデント宣言する仕組みも合わせて考えること - 参考: GKE Autopilot Sidecar Containers / Envoy circuit_breakers・outlier_detection / Cloud Monitoring SLO(request-based SLI)/ Google SRE Workbook(Multiwindow, Multi-Burn-Rate Alerts)/
tenacity(指数バックオフ+ジッター)