システム設計/インフラ — Cloud Run マイクロサービス + Circuit Breaker 設計

2026-05-05 (Day 5) B: システム設計/インフラ ★★★☆☆ GCP / Cloud Run / Redis / Circuit Breaker Strangler Fig パターン

問題

ECサイトの販促イベント時に急増するトラフィックを処理するため、以下の既存構成をリアーキテクチャする。

現状構成

コンポーネント設定問題
Cloud Run(販促API・モノリス)min=0, max=10コールドスタート タイムアウト
BigQuery購買・会員データ
Pub/Subメール配信キュー
Argo Workflowsバッチ処理

問題状況

  • セール開始時(午前10:00)に毎回コールドスタートによるタイムアウトエラー(P99レイテンシ 8秒超)
  • 販促APIがモノリス化:クーポン配信・在庫確認・ポイント計算が1サービスに混在
  • DataDog APMで、在庫確認APIが全体レイテンシの70%を占めることが判明
  • GKE Autopilot への移行を経営から検討するよう指示

要件

要件目標値
P99レイテンシ500ms 以内
SLO99.9% availability(月間ダウンタイム 43分以内)
インフラコスト現状比で増やさない(現在 約15万円/月)
自動回復障害発生時15分以内

ヒント(段階的開示)

ヒント1 — サービス分割の軸
モノリスの分割先として「どこが責務の境界か」を考える。クーポン配信・在庫確認・ポイント計算はそれぞれ異なるスケーリング特性を持つ。また、コールドスタートはサービスの「暖機」という観点で解決できる。
ヒント2 — 各問題のアプローチ
  • サービス分割: クーポン配信(書き込み中心)、在庫確認(読み取り中心・キャッシュ適用可)、ポイント計算(トランザクション必要)の3つ
  • コールドスタート対策: min-instances の設定 + Cloud Scheduler による事前ウォームアップ(セール5分前にダミーリクエスト)
  • 在庫ボトルネック: Memorystore(Redis)でキャッシュ層を追加し、TTLを在庫更新頻度に合わせて設計
  • 信頼性: Cloud Run の concurrency と retry 設定、Circuit Breaker パターンの適用
ヒント3 — アーキテクチャのポイント
[クライアント]
    ↓
[Cloud Load Balancing]
    ↓
[API Gateway / Cloud Endpoints]
    ├─ /coupons    → [Cloud Run: coupon-service]    (min=1)
    ├─ /inventory  → [Cloud Run: inventory-service] (min=2)
    │               ↑ キャッシュ
    │           [Memorystore Redis TTL=30s]
    └─ /points     → [Cloud Run: point-service]     (min=1)

Circuit Breaker: coupon-service → inventory-service
  在庫確認失敗時はフォールバック(在庫不明=楽観的に発行継続)

段階的移行(Strangler Fig):
  Phase 1: inventory-service 切り出し(リスク最小)
  Phase 2: point-service 切り出し
  Phase 3: coupon-service 確立・モノリス廃止

アーキテクチャ図

ユーザー / ECフロント Cloud Load Balancing API Gateway coupon-service min=1, max=20 concurrency=80 Circuit Breaker ↓ inventory-service min=2, max=30(冗長化) concurrency=100 Read-Through Cache ↓ point-service min=1, max=10 Cloud SQL(ACID) Read Replica 設定 gRPC+CB Memorystore Redis TTL=30s / 1GiB Basic Pub/Sub → Argo Workflows メール配信バッチ Cloud Scheduler ウォームアップ セール10分前 09:50

2. コールドスタート対策

# cloud-run-coupon-service.tf (Terraform)
resource "google_cloud_run_v2_service" "coupon_service" {
  name     = "coupon-service"
  location = "us-central1"

  template {
    scaling {
      min_instance_count = 1   # コールドスタート防止
      max_instance_count = 20  # セール時スパイク対応
    }
    containers {
      image = "gcr.io/${var.project_id}/coupon-service:latest"
      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
        cpu_idle = false  # CPU常時割当(min-instances=1と組み合わせ)
      }
    }
    max_instance_request_concurrency = 80
  }
}

# ウォームアップスケジューラ(セール10分前: 09:50)
resource "google_cloud_scheduler_job" "warmup_job" {
  name      = "sale-warmup"
  schedule  = "50 9 * * *"
  time_zone = "Asia/Tokyo"

  http_target {
    uri         = "https://coupon-service-xxx.run.app/health"
    http_method = "GET"
  }
}

3. 在庫確認ボトルネックの解消策

# inventory_service/cache.py
import asyncio
from dataclasses import dataclass

import redis.asyncio as aioredis

INVENTORY_CACHE_TTL_SECONDS = 30
CACHE_KEY_PREFIX = "inventory:item:"


@dataclass(frozen=True, slots=True)
class InventoryStatus:
    item_id: str
    available_count: int
    is_available: bool

    @classmethod
    def from_count(cls, item_id: str, count: int) -> "InventoryStatus":
        return cls(item_id=item_id, available_count=count, is_available=count > 0)


class InventoryCache:
    """Redisを使った在庫キャッシュ(Read-Through パターン)。"""

    def __init__(self, redis_client: aioredis.Redis, db_fetcher) -> None:
        self._redis = redis_client
        self._db_fetcher = db_fetcher

    async def get(self, item_id: str) -> InventoryStatus:
        cache_key = f"{CACHE_KEY_PREFIX}{item_id}"
        cached = await self._redis.get(cache_key)
        if cached is not None:
            return InventoryStatus.from_count(item_id, int(cached))

        # キャッシュミス → DB取得 → キャッシュ書き込み
        count = await self._db_fetcher(item_id)
        await self._redis.setex(cache_key, INVENTORY_CACHE_TTL_SECONDS, count)
        return InventoryStatus.from_count(item_id, count)
効果の見積もり:
キャッシュヒット率 90% 想定: レイテンシ 800ms → 5ms(Redis レイテンシ)
残り 10%(キャッシュミス): Cloud SQL から取得(10〜50ms)
P99全体への寄与: 在庫確認レイテンシ 8秒 → 50ms 以下へ改善

4. SLO 99.9% を担保する Circuit Breaker

# coupon_service/inventory_client.py
from enum import StrEnum
import httpx
from opentelemetry import trace

INVENTORY_TIMEOUT_SECONDS = 1.0
CIRCUIT_OPEN_THRESHOLD = 5
CIRCUIT_RESET_SECONDS = 30


class CircuitState(StrEnum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class InventoryCircuitBreaker:
    """在庫サービス呼び出しの Circuit Breaker。

    在庫確認が連続失敗した場合、クーポン発行自体はフォールバックで継続。
    (在庫不明=在庫ありとして扱い、最終確定時に厳密チェック)
    """

    def __init__(self) -> None:
        self._state = CircuitState.CLOSED
        self._failure_count = 0
        self._last_failure_time = None
        self._tracer = trace.get_tracer(__name__)

    async def call(self, item_id: str):
        with self._tracer.start_as_current_span("inventory.check") as span:
            span.set_attribute("item_id", item_id)
            span.set_attribute("circuit_state", self._state)

            if self._state == CircuitState.OPEN:
                if self._should_attempt_reset():
                    self._state = CircuitState.HALF_OPEN
                else:
                    span.set_attribute("fallback", True)
                    return None  # フォールバック: クーポン発行は継続

            try:
                async with httpx.AsyncClient(timeout=INVENTORY_TIMEOUT_SECONDS) as c:
                    response = await c.get(
                        f"http://inventory-service/api/v1/inventory/{item_id}"
                    )
                    response.raise_for_status()
                    self._on_success()
                    return response.json()["is_available"]
            except (httpx.TimeoutException, httpx.HTTPStatusError):
                self._on_failure()
                return None  # フォールバック

5. 段階的移行計画(Strangler Fig)

Phase内容期間リスクロールバック
Phase 1 inventory-service 切り出し + Redis キャッシュ追加 2週間 低(読み取り専用) モノリスの在庫ロジックをフラグで切り戻し
Phase 2 point-service 切り出し。Cloud SQL トランザクション検証 3週間 中(トランザクション移行) Strangler Fig でモノリスと並走
Phase 3 coupon-service 確立・旧モノリス廃止・GKE Autopilot 評価 2週間 低(Phase 1,2 完了後) Cloud Run に留まる選択も可

6. コスト試算

リソース設定月額試算
coupon-service (Cloud Run)min=1, max=20, CPU常時割当約 3.0万円
inventory-service (Cloud Run)min=2, max=30, CPU常時割当約 4.0万円
point-service (Cloud Run)min=1, max=10, CPU常時割当約 2.0万円
Memorystore Redis Basic 1GiBus-central1約 0.5万円
Cloud SQL (inventory-db)db-f1-micro, SSD 10GiB約 1.0万円
BigQuery / Pub/Sub / Argo現状維持約 4.5万円
合計約 15.0万円
旧モノリスの Cloud Run (max=10) を廃止することで削減分が発生し、新3サービスの増分と相殺。セール時のピークスパイクは max-instances 上限(オートスケール)で吸収。

ポイント解説

1 単一責任原則によるサービス分割
クーポン・在庫・ポイントは異なるスケーリング特性・トランザクション要件を持つ。モノリス内の「暗黙の境界」を DDD のバウンデッドコンテキストとして明示化することで、自然な形でサービスを分割できる。
2 Read-Through キャッシュと TTL 設計
在庫確認のような「多少の古さが許容される読み取り処理」にキャッシュを適用する際、TTLを在庫更新ビジネス要件(例: 30秒ズレを許容できるか)に基づいて決める。
3 Circuit Breaker とフォールバック設計
「在庫確認不能=クーポン発行停止」ではなく「在庫不明=楽観的に発行継続・最終購入確定時に厳密チェック」のようなビジネスルールに沿ったフォールバック設計が重要。
4 コスト試算の精度
min-instances設定はCPU常時割当コストに直結する。「コールドスタート対策コスト vs 機会損失(障害によるCV損失)」のトレードオフを数値で経営に説明できることが重要。

今日のまとめ

モノリスのマイクロサービス分解は「スケーリング特性とトランザクション境界」を軸に責務を切り、Strangler Fig パターンで段階的に移行するのが現実解。

コールドスタート対策・Circuit Breaker・キャッシュ層の3つを組み合わせることで、コスト増なしに P99 レイテンシとSLO目標を両立できる。

自己評価

自分の回答

気づき・メモ