概要
セキュリティ
Workload Identity / SQLインジェクション対策 / Pydantic v2 バリデーション
コスト最適化
Storage Write API / パーティション + クラスタリング / SELECT * 廃止
可観測性
OpenTelemetry スパン / エラーハンドリング / DataDog APM 連携
設計品質
関心の分離 / 環境変数設定 / cached_property でクライアント管理
悪い実装 (Before)
このコードには 10の問題 が隠れています。全て見つけてみてください。
# bad_pipeline.py — 問題だらけの実装
import json
import requests
from google.cloud import bigquery, pubsub_v1
project_id = "my-project-123"
topic = "user-events"
dataset = "analytics"
table = "events"
client = bigquery.Client()
publisher = pubsub_v1.PublisherClient()
def publish_event(event_type, user_id, amount):
data = {"type": event_type, "user": user_id, "amount": amount}
publisher.publish(f"projects/{project_id}/topics/{topic}",
json.dumps(data).encode())
def insert_to_bq(events):
rows = []
for e in events:
rows.append({"event_type": e["type"], "user_id": e["user"],
"amount": e["amount"], "ts": "now()"})
client.insert_rows_json(f"{dataset}.{table}", rows)
def get_campaign_roi(campaign_id):
query = f"""
SELECT SUM(amount) / COUNT(*) as roi
FROM {dataset}.{table}
WHERE campaign_id = '{campaign_id}'
AND date = '2026-05-03'
"""
return client.query(query).result()
# 毎秒呼ばれる想定
def process_webhook(request):
data = request.get_json()
publish_event(data["type"], data["user"], data["amount"])
insert_to_bq([data])
return "ok"
ヒント(段階的開示)
ヒント1 — 問題の軸
問題点を以下の軸で探す:
1. セキュリティ: 認証方式・SQLインジェクション
2. パフォーマンス: BQ インサートの方式・バッチ vs ストリーミング
3. コスト: BigQuery の課金方式とクエリの書き方
4. 型安全性: 入力バリデーションの欠如
5. 可観測性: エラーハンドリング・トレーシングの欠如
6. 設計: 関心の分離・グローバル変数・ハードコード定数
1. セキュリティ: 認証方式・SQLインジェクション
2. パフォーマンス: BQ インサートの方式・バッチ vs ストリーミング
3. コスト: BigQuery の課金方式とクエリの書き方
4. 型安全性: 入力バリデーションの欠如
5. 可観測性: エラーハンドリング・トレーシングの欠如
6. 設計: 関心の分離・グローバル変数・ハードコード定数
ヒント2 — BigQuery コスト削減
SELECT *より特定カラムのみ- テーブルパーティショニング(
_PARTITIONTIME) - クラスタリングキー(
campaign_idなど) - Materialized Views や BI Engine の活用
insert_rows_json(Streaming API)はコストが高い($0.01/200MB)。BigQuery Subscription(Storage Write API)の方が安い。
ヒント3 — SQLインジェクション対策 & Pydantic v2
✗ SQLインジェクション脆弱
# f-string でクエリ構築
query = f"WHERE campaign_id = '{campaign_id}'"
✓ パラメータ化クエリ
query = "WHERE campaign_id = @campaign_id"
job_config = bigquery.QueryJobConfig(
query_parameters=[
bigquery.ScalarQueryParameter(
"campaign_id", "STRING", campaign_id
)
]
)
Pydantic v2 でのバリデーション:
from pydantic import BaseModel, Field
from enum import StrEnum
class EventType(StrEnum):
CART_ADD = "cart_add"
PURCHASE = "purchase"
CANCEL = "cancel"
class UserEvent(BaseModel):
event_type: EventType
user_id: str = Field(min_length=1, max_length=64)
amount: float = Field(ge=0)
campaign_id: str | None = None
問題点分析(10項目)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | get_campaign_roi でf-string直接埋め込み | SQLインジェクション | パラメータ化クエリを使用 |
| 2 | Workload Identity 未対応(暗黙的アカウント) | セキュリティ | Cloud Run では WI が自動適用 |
| 3 | insert_rows_json は Streaming API(高コスト) | コスト | BigQuery Subscription / Storage Write API |
| 4 | "ts": "now()" は文字列リテラル(SQL関数ではない) | バグ | datetime.now(UTC) を Python で生成 |
| 5 | SELECT * でスキャンコスト増大 | コスト | 必要カラムのみ指定 |
| 6 | 入力バリデーションなし(request.get_json() をそのまま使用) | セキュリティ | Pydantic v2 で model_validate |
| 7 | ハードコード定数(project_id, dataset, table) | 設計 | pydantic_settings.BaseSettings で環境変数化 |
| 8 | グローバルBQクライアント(コールドスタート時の問題) | 設計 | cached_property でインスタンスレベルキャッシュ |
| 9 | エラーハンドリングなし(失敗時に "ok" を返す) | 信頼性 | try/except で 4xx/5xx を適切に返す |
| 10 | OTel スパン・ログ・メトリクスが一切ない | 可観測性 | OpenTelemetry tracer.start_as_current_span |
改善後 イベント駆動パイプライン図
模範解答 — 改善後コード
# good_pipeline.py
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from enum import StrEnum
from functools import cached_property
from google.cloud import bigquery, pubsub_v1
from opentelemetry import trace
from opentelemetry.trace import StatusCode
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
# ---- 設定 (環境変数から読み込み) ----
class Settings(BaseSettings):
gcp_project_id: str
pubsub_topic_id: str
bq_dataset: str
bq_table: str
class Config:
env_file = ".env"
_settings: Settings | None = None
def get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings()
return _settings
# ---- ドメインモデル ----
class EventType(StrEnum):
CART_ADD = "cart_add"
PURCHASE = "purchase"
CANCEL = "cancel"
class UserEvent(BaseModel):
"""購買行動イベント。Pydantic v2 で型・値バリデーション済み"""
event_type: EventType
user_id: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
amount: float = Field(ge=0, le=10_000_000)
campaign_id: str | None = Field(default=None, max_length=64)
occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# ---- インフラ層 (cached_property でシングルトン) ----
class PipelineClients:
"""BQ・Pub/Sub クライアントのシングルトン。Workload Identity で認証"""
@cached_property
def bq(self) -> bigquery.Client:
return bigquery.Client(project=get_settings().gcp_project_id)
@cached_property
def publisher(self) -> pubsub_v1.PublisherClient:
return pubsub_v1.PublisherClient()
_clients = PipelineClients()
# ---- パブリッシュ (Pub/Sub) ----
def publish_event(event: UserEvent) -> None:
settings = get_settings()
topic_path = _clients.publisher.topic_path(
settings.gcp_project_id, settings.pubsub_topic_id
)
with tracer.start_as_current_span("pubsub.publish") as span:
span.set_attribute("event.type", event.event_type)
span.set_attribute("user.id", event.user_id)
try:
payload = event.model_dump_json().encode("utf-8")
future = _clients.publisher.publish(topic_path, payload)
message_id = future.result(timeout=10.0)
span.set_attribute("pubsub.message_id", message_id)
except Exception as exc:
span.set_status(StatusCode.ERROR, str(exc))
raise
# ---- BigQuery: パラメータ化クエリ ----
def get_campaign_roi(campaign_id: str, date: str) -> float | None:
settings = get_settings()
full_table = f"`{settings.gcp_project_id}.{settings.bq_dataset}.{settings.bq_table}`"
query = f"""
SELECT
campaign_id,
SUM(amount) AS total_revenue,
COUNT(*) AS event_count,
SAFE_DIVIDE(SUM(amount), COUNT(*)) AS avg_amount
FROM {full_table}
WHERE DATE(_PARTITIONTIME) = @target_date
AND campaign_id = @campaign_id
AND event_type = 'purchase'
GROUP BY campaign_id
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[
bigquery.ScalarQueryParameter("target_date", "DATE", date),
bigquery.ScalarQueryParameter("campaign_id", "STRING", campaign_id),
]
)
with tracer.start_as_current_span("bigquery.query_campaign_roi") as span:
span.set_attribute("campaign.id", campaign_id)
try:
rows = list(_clients.bq.query(query, job_config=job_config).result())
if not rows:
return None
return rows[0].avg_amount
except Exception as exc:
span.set_status(StatusCode.ERROR, str(exc))
raise
# ---- Cloud Run エントリーポイント ----
def process_webhook(request) -> tuple[str, int]:
with tracer.start_as_current_span("webhook.process") as span:
try:
raw = request.get_json(silent=True)
if raw is None:
return "Bad Request: Invalid JSON", 400
event = UserEvent.model_validate(raw)
span.set_attribute("event.type", event.event_type)
publish_event(event)
return "Accepted", 202
except ValueError as exc:
return f"Unprocessable Entity: {exc}", 422
except Exception as exc:
span.set_status(StatusCode.ERROR, str(exc))
return "Internal Server Error", 500
-- models/marts/mart_campaign_roi.sql
-- dbt Core 1.8+ / BigQuery パーティション + クラスタリング
{{
config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={
"field": "event_date",
"data_type": "date",
"granularity": "day"
},
cluster_by=["campaign_id", "event_type"],
labels={"team": "mops", "cost_center": "analytics"}
)
}}
with
source_events as (
select
DATE(_PARTITIONTIME) as event_date,
event_type,
campaign_id,
user_id,
amount,
occurred_at
from {{ source('raw', 'events') }}
where
_PARTITIONTIME >= timestamp_sub(current_timestamp(), interval 2 day)
{% if is_incremental() %}
and DATE(_PARTITIONTIME) >= date_sub(current_date(), interval 1 day)
{% endif %}
),
purchases as (
select *
from source_events
where event_type = 'purchase'
and campaign_id is not null
),
campaign_summary as (
select
event_date,
campaign_id,
count(distinct user_id) as unique_buyers,
count(*) as purchase_count,
sum(amount) as total_revenue,
avg(amount) as avg_order_value,
lag(sum(amount)) over (
partition by campaign_id
order by event_date
) as prev_day_revenue
from purchases
group by event_date, campaign_id
)
select
event_date,
campaign_id,
unique_buyers,
purchase_count,
total_revenue,
avg_order_value,
safe_divide(
total_revenue - prev_day_revenue,
prev_day_revenue
) * 100 as revenue_growth_pct
from campaign_summary
BigQuery コスト最適化チェックリスト
| 対策 | 効果 | 実装方法 |
|---|---|---|
パーティション _PARTITIONTIME | スキャン量 50〜90% 削減 | テーブル作成時に partition_by 設定 |
クラスタリング (campaign_id) | フィルタ時のスキャン削減 | cluster_by で設定 |
SELECT * を避ける | カラムストア形式の効果を最大化 | 必要カラムのみ指定 |
| Materialized View | 集計クエリのコスト排除 | 頻繁に参照する集計に適用 |
| Streaming vs Storage Write API | ストリーミング1/5のコスト | BigQuery Subscription を使用 |
| dbt incremental | 全量再計算を回避 | insert_overwrite + パーティション |
ポイント解説
1
Workload Identity Federation: Cloud Run では
google.cloud.bigquery.Client() がデフォルトで WI を使用。環境変数 GOOGLE_CLOUD_PROJECT を設定すれば認証は自動。キーファイル不要で最もセキュアな方式。
2
Pydantic v2 の
model_validate: v1 の parse_obj は廃止。v2 では model_validate(dict) を使う。Field(pattern=r"...") でリテラルなバリデーションも設定可能。
3
BigQuery パーティションプルーニング:
WHERE DATE(_PARTITIONTIME) = @target_date と書くことで、指定日のパーティションのみスキャン。WHERE date_column = '...' と書いても同じ効果は出ない(パーティションメタカラムを直接使う必要がある)。
4
OTel スパンとエラー伝播:
span.set_status(StatusCode.ERROR, ...) を except ブロックで必ず呼ぶ。DataDog APM は OTLP ネイティブ取り込みができるので、DataDog Agent を経由してトレースを送信可能。
5
cached_property によるクライアント管理: Cloud Run はコンテナ再利用があるため、グローバル変数としてクライアントを持つより cached_property でインスタンスレベルでキャッシュする方が安全。
実務への応用
ECサイト / MOps での具体的な活用場面
- イベント駆動 MOps パイプライン: ユーザー行動 → Cloud Run(検証)→ Pub/Sub(非同期)→ BQ(低コスト格納)→ dbt(集計)→ ダッシュボード(ROI)
- DataDog + OTel によるコスト可視化:
campaign.idをスパン属性に入れることで「施策ごとのシステムコスト」が DataDog で集計可能 - BigQuery の
bytes_billedを OTel メトリクスとして記録: BQ コストの施策別帰属が可能
今日のまとめ
SQLインジェクション・認証・コスト・型安全性・可観測性の5軸は「悪いコード」を見分ける黄金の観点。
BigQueryではパーティション+クラスタ+カラム選択の3点セットでスキャン量を大幅削減できる。
BigQueryではパーティション+クラスタ+カラム選択の3点セットでスキャン量を大幅削減できる。