弱点補強 — GCP Pub/Sub × BigQuery × dbt イベント駆動パイプライン

2026-05-03 (Day 3) 日曜弱点補強 ★★★★☆ B+C 複合: インフラ × データエンジニアリング Cloud Run v2 / Pub/Sub / BigQuery / dbt Core 1.8+

概要

🔒

セキュリティ

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. 設計: 関心の分離・グローバル変数・ハードコード定数
ヒント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項目)

#問題点分類改善方法
1get_campaign_roi でf-string直接埋め込みSQLインジェクションパラメータ化クエリを使用
2Workload Identity 未対応(暗黙的アカウント)セキュリティCloud Run では WI が自動適用
3insert_rows_json は Streaming API(高コスト)コストBigQuery Subscription / Storage Write API
4"ts": "now()" は文字列リテラル(SQL関数ではない)バグdatetime.now(UTC) を Python で生成
5SELECT * でスキャンコスト増大コスト必要カラムのみ指定
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 を適切に返す
10OTel スパン・ログ・メトリクスが一切ない可観測性OpenTelemetry tracer.start_as_current_span

改善後 イベント駆動パイプライン図

ユーザー行動 Webhook Cloud Run v2 Pydantic v2 検証 OTel スパン Workload Identity 202 Accepted Pub/Sub 非同期化 バッファリング ack_deadline=60s BigQuery Storage Write API パーティション + Cluster 低コスト格納 dbt Core 日次集計 incremental ROI レポート イベント駆動 MOps パイプライン(改善後) ✓ validate ✓ decouple ✓ low-cost ✓ incremental

模範解答 — 改善後コード

# 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点セットでスキャン量を大幅削減できる。

自己評価

自分の回答

気づき・メモ