概要
コレクションのカプセル化 (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
問題点の列挙
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | orders / failed がモジュールスコープの裸のリスト | Ch9 カプセル化違反 | OrderBatch クラスでラップ |
| 2 | for ループ内で1件ずつ await → 直列実行 | パフォーマンス問題 | asyncio.gather で並列化 |
| 3 | except 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 / failures が tuple を返す | 不変の活用 | 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回) |
| 2 | SLO: 可用性 99.9%、P99 < 200ms |
| 3 | HTTP → HTTPS リダイレクト必須、外部 LB 経由でインターネット公開 |
| 4 | CPU 60% で HPA 自動スケール(min=2, max=30) |
| 5 | カナリアデプロイを将来的に実施できる構成 |
| 6 | GKE Autopilot を使用(理由も述べること) |
ヒント B(段階的開示)
ヒント1 — 方向性
GKE Autopilot では
nodes の管理が不要になる代わり、何を代わりに課金・管理することになるか。また K8s 1.30+ での外部公開は Ingress ではなく何を使うか。
ヒント2 — アプローチ
- Gateway API では
GatewayClass→Gateway(LB相当)→HTTPRoute(ルーティング)の3層構造 - HTTP→HTTPS リダイレクトは
HTTPRouteのrequestRedirectフィルターで実装 - カナリアデプロイは
HTTPRouteのbackendRefsに 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
模範解答 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 # 急激なスケールインを防ぐ
| 観点 | Autopilot | Standard |
|---|---|---|
| ノード管理 | 不要(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-stable と order-event-api-canary の2つの Deployment + Service を用意。バージョンを独立して管理できる。
2
HTTPRoute の weight で段階的移行
初期は
初期は
stable=100, canary=0(canary の Pod 数=0)で運用し、リリース時に stable=90, canary=10 → stable=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
並列度を制限しないと外部API側にDDoS状態を引き起こすリスクがある。実務では
asyncio.Semaphore の重要性並列度を制限しないと外部API側にDDoS状態を引き起こすリスクがある。実務では
FETCH_CONCURRENCY を環境変数化してチューニングできるようにするとよい。
カテゴリ B
4
Gateway API は Ingress の後継
GKE 1.24 以降で GA となり、L7 ルーティングの標準は Gateway API に移行している。新規設計では Gateway API を使うべき。
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% に切り替えるという運用がコードの変更なしに実現できる。
今日のまとめ
コレクションは裸のリストで公開せず専用クラスでカプセル化することでバグを防ぎ、非同期並列処理では
インフラ側では Gateway API の weight ルーティングがカナリアデプロイの実装コストを大幅に下げ、GKE Autopilot はノード管理コストを排除してアプリエンジニア主体のチームに最適な選択肢である。
Semaphore で外部APIへの負荷を制御することが実務の要点。インフラ側では Gateway API の weight ルーティングがカナリアデプロイの実装コストを大幅に下げ、GKE Autopilot はノード管理コストを排除してアプリエンジニア主体のチームに最適な選択肢である。