A+B 複合 — asyncio バッチ設計 × GKE Gateway API + HPA

2026-05-09 (Day 26) 土曜複合問題 ★★★☆☆ A: コーディング/設計 × B: システム設計/インフラ 「良いコード・悪いコードで学ぶ設計入門」Ch9 × GKE Autopilot

概要

📦

コレクションのカプセル化 (Ch9)

裸のリストをグローバル公開せず、OrderBatch クラスで包み専用メソッド経由でのみ操作させる。

asyncio 並列フェッチ

asyncio.gather + Semaphore で並列度を制御しながら100件を同時フェッチ。

🌐

Gateway API

Ingress の後継。HTTPRoute の weight でカナリアデプロイを宣言的に実現。

🤖

GKE Autopilot

ノード管理不要・Pod課金。キャンペーン時の10→300 req/s バーストに最適。

問題 A: コーディング — 非同期バッチ処理 × コレクションのカプセル化

以下の「悪いコード」は ECサイトの注文データを BigQuery へ一括アップロードするバッチ処理。問題点を列挙し、Ch9(コレクションのカプセル化) および Ch4(不変の活用) を適用してリファクタリングせよ。

期待する回答形式: 問題点の列挙 + コード + 実行例 + 設計ポイント

制約・前提条件

  • Python 3.12+、google-cloud-bigquery ライブラリ使用
  • asyncio.gather + asyncio.Semaphore(並列度: FETCH_CONCURRENCY = 20
  • 注文コレクションを表す OrderBatch クラスを実装すること
  • Google スタイル docstring、実行例、設計パターン名を含めること

悪いコード (Before) — カテゴリ A

このコードには 5つの設計上の問題 が隠れています。見つけてみてください。
import asyncio
from google.cloud import bigquery

orders = []
failed = []

async def fetch_order(order_id):
    await asyncio.sleep(0.01)  # 外部API呼び出しのモック
    if order_id % 7 == 0:
        raise ValueError(f"order {order_id} fetch failed")
    return {"id": order_id, "amount": order_id * 100, "status": "paid"}

async def main():
    for i in range(1, 101):
        try:
            row = await fetch_order(i)
            orders.append(row)
        except Exception as e:
            failed.append((i, str(e)))

    # BigQuery へアップロード
    client = bigquery.Client()
    table = "project.dataset.orders"
    errors = client.insert_rows_json(table, orders)
    if errors:
        print("BQ insert errors:", errors)

    print(f"done. success={len(orders)}, failed={len(failed)}")

asyncio.run(main())

ヒント A(段階的開示)

ヒント1 — 方向性
グローバル変数でリストを管理することの危険性と、コレクションを「オブジェクト」として扱うことで何が改善されるかを考えよ。また、for ループの逐次 await を並列化するには何を使うか。
ヒント2 — アプローチ
  • asyncio.gather + asyncio.Semaphore でスループットを上げながら並列度を制御できる
  • OrderBatch クラスに add_success / add_failure メソッドを持たせ、内部リストをプロパティで読み取り専用にすると Ch4(不変)の考え方に沿う
  • asyncio.gather(return_exceptions=True) で各タスクの結果を安全に集約できる
ヒント3 — 目指す構造
# セマフォで並列度を制限しつつ全タスクを投げる骨格
sem = asyncio.Semaphore(FETCH_CONCURRENCY)

async def fetch_with_limit(order_id: int) -> dict:
    async with sem:
        return await fetch_order(order_id)

results = await asyncio.gather(
    *[fetch_with_limit(i) for i in range(1, 101)],
    return_exceptions=True,
)

# コレクションのカプセル化
@dataclass(slots=True)
class OrderBatch:
    _successes: list[dict] = field(default_factory=list)
    _failures: list[tuple[int, str]] = field(default_factory=list)

    def add_success(self, row: dict) -> None: ...
    def add_failure(self, order_id: int, reason: str) -> None: ...

    @property
    def successes(self) -> tuple[dict, ...]: ...  # 外部にはimmutableで返す

模範解答 A

問題点の列挙

#問題点分類改善方法
1orders / failed がモジュールスコープの裸のリストCh9 カプセル化違反OrderBatch クラスでラップ
2for ループ内で1件ずつ await → 直列実行パフォーマンス問題asyncio.gather で並列化
3except Exception で全例外を握り潰してリストに追記Ch10 例外処理return_exceptions=True で安全集約
4件数 100 とテーブルID がコード内に直書きマジックナンバー名前付き定数に切り出す
5フェッチ・集約・BQアップロードが1関数に混在単一責任違反関数を3つに分離
"""orders_upload.py — ECサイト注文データの BigQuery 一括アップロードバッチ。

良いコード・悪いコードで学ぶ設計入門
  - Ch4: 不変の活用(frozen dataclass, プロパティで読み取り専用)
  - Ch9: コレクションのカプセル化(OrderBatch が内部リストを隠蔽)
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field

from google.cloud import bigquery

# ── 定数 ────────────────────────────────────────────────
FETCH_CONCURRENCY: int = 20
ORDER_ID_RANGE: range = range(1, 101)
BQ_TABLE: str = "project.dataset.orders"

logger = logging.getLogger(__name__)


# ── コレクションの値オブジェクト(Ch9 + Ch4)────
@dataclass(slots=True)
class OrderBatch:
    """成功・失敗をカプセル化した注文バッチコレクション。

    外部から直接リストを操作させず、専用メソッド経由でのみ変更を受け付ける。
    取得時は tuple で返し、内部リストを書き換えられないようにする(Ch4 不変)。
    """

    _successes: list[dict] = field(default_factory=list, repr=False)
    _failures: list[tuple[int, str]] = field(default_factory=list, repr=False)

    def add_success(self, row: dict) -> None:
        self._successes.append(row)

    def add_failure(self, order_id: int, reason: str) -> None:
        self._failures.append((order_id, reason))

    @property
    def successes(self) -> tuple[dict, ...]:
        return tuple(self._successes)

    @property
    def failures(self) -> tuple[tuple[int, str], ...]:
        return tuple(self._failures)

    @property
    def success_count(self) -> int:
        return len(self._successes)

    @property
    def failure_count(self) -> int:
        return len(self._failures)


# ── フェッチ層(単一責任: データ取得のみ)──────────────────────
async def fetch_order(order_id: int) -> dict:
    await asyncio.sleep(0.01)
    if order_id % 7 == 0:
        raise ValueError(f"order {order_id} fetch failed")
    return {"id": order_id, "amount": order_id * 100, "status": "paid"}


async def fetch_all_orders(order_ids: range) -> OrderBatch:
    """指定した注文 ID 群を並列フェッチし OrderBatch にまとめる。"""
    sem = asyncio.Semaphore(FETCH_CONCURRENCY)

    async def _fetch_with_limit(order_id: int) -> dict | BaseException:
        async with sem:
            return await fetch_order(order_id)

    raw_results: list[dict | BaseException] = await asyncio.gather(
        *[_fetch_with_limit(oid) for oid in order_ids],
        return_exceptions=True,
    )

    batch = OrderBatch()
    for order_id, result in zip(order_ids, raw_results):
        if isinstance(result, BaseException):
            logger.warning("fetch failed order_id=%d reason=%s", order_id, result)
            batch.add_failure(order_id, str(result))
        else:
            batch.add_success(result)

    return batch


# ── アップロード層(単一責任: BQ 書き込みのみ)─────────────────
def upload_to_bigquery(batch: OrderBatch) -> None:
    if not batch.successes:
        logger.info("no rows to insert")
        return

    client = bigquery.Client()
    errors = client.insert_rows_json(BQ_TABLE, list(batch.successes))
    if errors:
        raise RuntimeError(f"BigQuery insert errors: {errors}")

    logger.info("inserted %d rows to %s", batch.success_count, BQ_TABLE)


# ── エントリーポイント ───────────────────────────────────────
async def main() -> None:
    batch = await fetch_all_orders(ORDER_ID_RANGE)
    upload_to_bigquery(batch)

    logger.info(
        "batch finished: success=%d, failed=%d",
        batch.success_count,
        batch.failure_count,
    )
    if batch.failures:
        logger.warning("failed order ids: %s", [fid for fid, _ in batch.failures])


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())
INFO:__main__:inserted 86 rows to project.dataset.orders
INFO:__main__:batch finished: success=86, failed=14
WARNING:__main__:failed order ids: [7, 14, 21, 28, 35, 42, 49, 56, 63, 70, 77, 84, 91, 98]
ポイント適用した設計原則対応章
OrderBatch が内部リストを隠蔽コレクションのカプセル化Ch9
successes / failurestuple を返す不変の活用Ch4
fetch_all_orders / upload_to_bigquery を分離単一責任原則Ch6-7
FETCH_CONCURRENCY / BQ_TABLE を名前付き定数化マジックナンバーの排除Ch10
asyncio.Semaphore + gather(return_exceptions=True)並列度制御 + 例外安全

問題 B: システム設計/インフラ — GKE Autopilot × Gateway API × HPA

ECサイトの注文イベント処理 API(Python/FastAPI)を GKE Autopilot 上で稼働させる。以下の要件を満たすインフラ構成を設計し、Terraform + K8s マニフェストの概要と設計根拠を回答せよ。

要件

#要件
1通常時 10 req/s、キャンペーン時 300 req/s(月2回)
2SLO: 可用性 99.9%、P99 < 200ms
3HTTP → HTTPS リダイレクト必須、外部 LB 経由でインターネット公開
4CPU 60% で HPA 自動スケール(min=2, max=30)
5カナリアデプロイを将来的に実施できる構成
6GKE Autopilot を使用(理由も述べること)

ヒント B(段階的開示)

ヒント1 — 方向性
GKE Autopilot では nodes の管理が不要になる代わり、何を代わりに課金・管理することになるか。また K8s 1.30+ での外部公開は Ingress ではなく何を使うか。
ヒント2 — アプローチ
  • Gateway API では GatewayClassGateway(LB相当)→ HTTPRoute(ルーティング)の3層構造
  • HTTP→HTTPS リダイレクトは HTTPRouterequestRedirect フィルターで実装
  • カナリアデプロイは HTTPRoutebackendRefs に weight を持たせることで実現(stable=90%, canary=10%)
  • GKE Autopilot の課金はノードではなく Pod のリクエスト済み vCPU/メモリ 単位
ヒント3 — HTTPRoute と HPA の骨格
HTTPRoute(HTTPS・カナリア対応)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
spec:
  rules:
    - backendRefs:
        - name: order-api-stable
          weight: 90
        - name: order-api-canary
          weight: 10
HPA(CPU 60% トリガー)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

アーキテクチャ図 — GKE Autopilot + Gateway API

Internet Users Gateway (LB) gke-l7-global-external port 80: HTTP redirect port 443: HTTPS TLS term HTTPRoute stable: weight 90% canary: weight 10% GKE Autopilot (asia-northeast1) Deployment: stable min=2 / max=27 pods HPA: CPU 60% scaleUp stabilize=0s Deployment: canary min=0 / max=3 pods 初期は weight=0 Cloud Pub/Sub → Argo Workflows 通常トラフィック カナリアトラフィック Autopilot境界

模範解答 B

# Gateway(外部 LB相当)
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: external-lb-gateway
  namespace: production
spec:
  gatewayClassName: gke-l7-global-external-managed
  listeners:
    - name: http
      port: 80
      protocol: HTTP
    - name: https
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: api-tls-cert  # Google-managed cert
---
# HTTP → HTTPS リダイレクト
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: http-redirect
spec:
  parentRefs:
    - name: external-lb-gateway
      sectionName: http
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301
---
# HTTPS ルーティング(カナリア対応)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: order-event-api
spec:
  parentRefs:
    - name: external-lb-gateway
      sectionName: https
  hostnames: ["api.example.com"]
  rules:
    - backendRefs:
        - name: order-event-api-stable
          port: 8080
          weight: 90
        - name: order-event-api-canary
          port: 8080
          weight: 10
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-event-api-stable-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-event-api-stable
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0    # キャンペーン時に素早くスケールアウト
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300  # 急激なスケールインを防ぐ
観点AutopilotStandard
ノード管理不要(Google が管理)自分でノードプールを設計・管理
課金単位Pod の requested vCPU/メモリノードの vCPU/メモリ(アイドル分も課金)
セキュリティworkload identity 強制、特権コンテナ不可自由だが設定漏れリスク
スケーリングPod さえ増やせばノードは自動追加ノードプールのスケーリングも設計が必要
運用コスト低い(SREリソースを本来業務に集中)高い(ノードアップグレード等が必要)
選定根拠: MOpsチームはインフラ専任ではなくアプリエンジニア比率が高い。ノード管理の煩雑さを排除し「スケーリング設計 = HPAのみ」に集中できる Autopilot が最適。月2回のキャンペーン時バースト(10→300 req/s)では Pod 課金の Autopilot の方がコスト効率も高い。
1 Service を stable / canary で分離
order-event-api-stableorder-event-api-canary の2つの Deployment + Service を用意。バージョンを独立して管理できる。
2 HTTPRoute の weight で段階的移行
初期は stable=100, canary=0(canary の Pod 数=0)で運用し、リリース時に stable=90, canary=10stable=0, canary=100 と変更。HTTPRoute の更新のみで完結し、DNS変更不要。
3 DataDog でカナリアの P99 を独立計測
deployment.name タグを用いて stable/canary の P99レイテンシ・エラーレートを別グラフで監視し、しきい値超過時は weight を即時ロールバック。
4 Terraform での管理
weight 変数を terraform.tfvars で管理し、カナリア昇格は PR + terraform apply のみで完結。変更履歴を Git に残す。

ポイント解説

カテゴリ A

1 コレクションのカプセル化(Ch9)
裸のリストをグローバルに公開すると「誰でも操作できる」状態になりバグの温床になる。OrderBatch のように専用クラスでラップし、メソッド経由でのみ操作させることで不変条件を保証できる。
2 不変の活用(Ch4)
プロパティで tuple を返すことで、外部がコレクションを書き換えようとすると TypeError になり、バグを早期検出できる。
3 asyncio.Semaphore の重要性
並列度を制限しないと外部API側にDDoS状態を引き起こすリスクがある。実務では FETCH_CONCURRENCY を環境変数化してチューニングできるようにするとよい。

カテゴリ B

4 Gateway API は Ingress の後継
GKE 1.24 以降で GA となり、L7 ルーティングの標準は Gateway API に移行している。新規設計では Gateway API を使うべき。
5 HPA の behavior 設定
キャンペーン開始直後の急激な増加に対応するため scaleUp.stabilizationWindowSeconds: 0 にしておくことが重要。

実務への応用

  • カテゴリ A: MOpsの販促バッチでは「外部API(MA/CRMツール)から顧客データを一括取得 → BigQueryへ投入」の処理が頻出する。OrderBatch のようなコレクション値オブジェクトを設計することで、成功/失敗の集計・リトライ対象の特定・監査ログへの記録がシンプルになる。
  • カテゴリ B: ECサイトのセールイベントでは、メール配信トリガーAPIへのリクエストが数十倍に跳ね上がる。Gateway API の weight カナリアを使えば「新バージョンのAPI」をセール本番前に 5% 程度流して品質確認し、問題がなければ 100% に切り替えるという運用がコードの変更なしに実現できる。

今日のまとめ

コレクションは裸のリストで公開せず専用クラスでカプセル化することでバグを防ぎ、非同期並列処理では Semaphore で外部APIへの負荷を制御することが実務の要点。

インフラ側では Gateway API の weight ルーティングがカナリアデプロイの実装コストを大幅に下げ、GKE Autopilot はノード管理コストを排除してアプリエンジニア主体のチームに最適な選択肢である。

自己評価

自分の回答

気づき・メモ