概要
refで繋げばリネージが追跡できる
BigQueryの完全修飾テーブル名を直書きすると、dbtのDAGから外れて依存関係が見えなくなる。dbt Meshのクロスプロジェクトrefで、他プロジェクトのモデル変更をCIで検知できる状態を保つ。
公開は「意図して」狭く設計する
全モデルがデフォルトprotectedのままでは公開意図が曖昧になる。本当に他プロジェクトへ渡す1モデルだけをpublicにし、PIIは公開契約から外す。
Model Contractで暗黙の約束を検証可能にする
列名リネームのような変更は契約が無ければ実行時に初めて発覚する。contract.enforcedでCRM側のビルド時点に破壊的変更を押し戻す。
権限もモデルの公開範囲に合わせて絞る
データセット丸ごとのdataViewerは1テーブルのための過剰権限になりやすい。Authorized Viewで公開モデル相当の1点にBigQuery側の権限も一致させる。
問題
ECサイトでは、CRMチームが管理する dbt プロジェクト(crm)で顧客マスタのマート dim_customer(customer_id, email, phone, customer_segment, ltv_yen, updated_at 等)を日次で構築している。MOps チームは別の dbt プロジェクト(mops)で、クーポンのターゲティング配信リスト mart_coupon_targeting を作るために、この dim_customer を参照したい。
現状、MOps チームのエンジニアは以下のような実装で dim_customer を利用しており、7つの設計・データガバナンス上の問題が潜んでいる。
問題点を全て洗い出し、dbt Mesh のクロスプロジェクト ref()(プロジェクト名を明示した参照)× モデルの access 修飾子(public/protected/private)による公開範囲の限定 × group によるモデル所有権の明確化 × 公開モデルへの Model Contract 適用(列・型を固定しPIIを含めない)× BigQuery Authorized View によるデータセット越しアクセスの最小権限化 × 公開モデルの鮮度監視を活用したBad→Goodリファクタリングを行ってください。
制約・前提条件
- dbt Core 1.8+(dbt Mesh のクロスプロジェクト
ref()、access修飾子、group、Model Contract がいずれも利用可能) - BigQuery Standard SQL、Terraform 1.8+(
googleプロバイダー 5.x) - CRM プロジェクトの dbt モデルは GCPプロジェクト
crm-projectのデータセットcrm_martsに、MOps プロジェクトのモデルはmops-projectのデータセットmops_martsにそれぞれ配置される(プロジェクト分離構成) dim_customerにはemail,phoneなどのPIIカラムが含まれる。MOpsチームがクーポンターゲティングに必要なのはcustomer_id,customer_segment,ltv_yenのみで、PIIは不要- 現状、CRMチームの
dim_customerにaccess修飾子・groupは一切設定されておらず、CRMプロジェクト配下の中間モデル(staging/intermediate)を含む全モデルがデフォルトの protectedのまま。MOps側は dbt のref()機構を使わず、BigQueryの完全修飾テーブル名を直書きしている - MOpsのサービスアカウント(
mops-sa@mops-project.iam.gserviceaccount.com)には、crm_martsデータセット全体に対するroles/bigquery.dataViewerがTerraformで付与されている(dim_customer以外の全テーブル・全中間モデルも閲覧可能な状態) - CRMチームは1ヶ月前、
dim_customerの列名をltv→ltv_yenにリネームしたが、MOps側への事前連絡は無く、MOpsのmart_coupon_targetingが数日間サイレントに壊れていた(ltv参照でNULL)という事故が過去に発生している - CRMチームの日次dbt実行が失敗した日でも、MOps側は前日分の古い
dim_customerをそのまま使い続けてしまい、鮮度劣化に気づけない
dbt_project.yml・schema.ymlの access/group/contract、MOps側: クロスプロジェクトref)+ 改善後 Terraform コード(Authorized View)+ 実行例(input→output)+ 設計意図の説明
悪いコード (Before)
-- 問題①: dbtのref()を使わずBigQuery完全修飾テーブル名を直書き
-- → CRM側の変更がdbtのDAG/CIで検知できない
-- 問題③: email/phoneまで丸ごとSELECTしておりPIIが越境
SELECT customer_id, email, phone, customer_segment, ltv_yen
FROM `crm-project.crm_marts.dim_customer`
WHERE customer_segment IN ('vip', 'regular')
-- 問題②: access修飾子が未設定(デフォルトprotected)
-- 公開意図がコード上に無い
-- 問題④: Model Contractが無く列名リネームが無警告で伝播
-- (1ヶ月前 ltv→ltv_yen でMOps側が数日間サイレント障害)
-- 問題⑥: groupが未設定で所有権が不明確
SELECT customer_id, email, phone, customer_segment,
ltv_yen, updated_at
FROM {{ ref('int_customer_enriched') }}
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①: MOps側が
crm-project.crm_marts.dim_customerを完全修飾名で直書き → dbt Meshのクロスプロジェクトref('crm', 'dim_customer')に置き換え、dbtのDAGにCRM側モデルを乗せる - 問題②: CRMプロジェクトの全モデルが
access未設定(デフォルトprotected)で公開意図が明示されていない →dim_customerのみaccess: publicを明示し、それ以外(staging/intermediate)はaccess: privateに格下げして誤参照を物理的に防ぐ - 問題③: publicにする
dim_customerに email/phone などPIIカラムがそのまま含まれる → 公開用モデルからPII列を除外した契約を定義する(PIIが必要な処理はCRMプロジェクト内で完結させる) - 問題④:
dim_customerにModel Contractが無く、列名リネーム(ltv→ltv_yen)が無警告でMOps側を壊した →config.contract.enforced: trueとcolumns定義でスキーマを固定し、破壊的変更時はdbtのビルドがCRM側CIで即座に失敗するようにする - 問題⑤: MOpsのSAが
crm_martsデータセット全体にdataViewerを持ち、dim_customer以外の中間モデルも直接読める → データセット丸ごとのIAM付与を撤去し、BigQuery Authorized Viewでdim_customer相当の1ビューのみをMOps側から参照可能にする - 問題⑥:
dim_customerにgroup(所有権)が設定されておらず、誰が変更を承認すべきか不明確 →dbt_project.ymlにgroupsを定義し、group: crm_teamを付与してモデルの所有権をコードで明示する - 問題⑦: CRM側のdbt実行が失敗しても、MOps側が古いデータに気づけない → MOps側で
dim_customerのupdated_atを検証する鮮度チェック(singular test)を追加し、閾値超過でアラートする
ヒント3 — コードの骨格
# crm/models/marts/_marts.yml の骨格
models:
- name: dim_customer
access: public # 意図して公開する唯一のモデル
group: crm_team
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: string
constraints: [{ type: not_null }, { type: primary_key }]
- name: customer_segment
data_type: string
- name: ltv_yen
data_type: numeric
# email/phoneは公開契約に含めない(PII非公開)
-- mops/models/marts/mart_coupon_targeting.sql の骨格
SELECT
customer_id,
customer_segment,
ltv_yen
FROM {{ ref('crm', 'dim_customer') }} -- クロスプロジェクトref
WHERE customer_segment IN ('vip', 'regular')
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 完全修飾テーブル名の直書き | 参照方法層 | クロスプロジェクトref('crm','dim_customer') |
| 2 | 全モデルprotectedのまま公開範囲未設計 | 公開範囲層 | dim_customerのみaccess:publicを明示 |
| 3 | 公開モデルにPII(email/phone)が混入 | 公開範囲層 | 公開契約からPII列を除外 |
| 4 | Model Contract無しで破壊的変更が無警告伝播 | 契約層 | contract.enforced:trueでCI検知 |
| 5 | データセット丸ごとdataViewer付与 | 権限層 | Authorized Viewで1ビューに限定 |
| 6 | group(所有権)未設定 | 公開範囲層 | dbt_project.ymlのgroupsで明示 |
| 7 | 鮮度監視が無い | 契約層 | updated_at検証のsingular test |
プロジェクト境界図 — Bad vs Good の比較
模範解答
groups:
- name: crm_team
owner:
name: CRM Team
email: crm-team@example.com
models:
crm:
staging:
+group: crm_team
+access: private # 修正②: 中間モデルは明示的に非公開化
intermediate:
+group: crm_team
+access: private # 修正②: 中間モデルは明示的に非公開化
marts:
+group: crm_team
# dim_customerのみ個別にpublicへ上書き(下記schema.yml)
version: 2
models:
- name: dim_customer
access: public # 修正②: 意図して公開する唯一のモデル
group: crm_team # 修正⑥: 所有権をコードで明示
description: >
クーポンターゲティング等、他プロジェクトからの参照を想定した
顧客マスタの公開ビュー。PII(email/phone)は含まない契約。
config:
contract:
enforced: true # 修正④: 破壊的な列変更をビルド時に検知
columns:
- name: customer_id
data_type: string
constraints: [{ type: not_null }, { type: primary_key }]
- name: customer_segment
data_type: string
constraints: [{ type: not_null }]
- name: ltv_yen
data_type: numeric
- name: updated_at
data_type: timestamp
constraints: [{ type: not_null }]
# 修正③: email/phoneは公開契約の列定義に含めない
-- 修正③: PIIカラムを公開モデルの出力から除外
-- (email/phoneが必要な処理はCRMプロジェクト内の
-- private模型: int_customer_with_pii 等で完結させる)
SELECT
customer_id,
customer_segment,
ltv_yen,
updated_at
FROM {{ ref('int_customer_enriched') }} -- private中間モデル(PII含む)
SELECT customer_id, email, phone, customer_segment, ltv_yen
FROM `crm-project.crm_marts.dim_customer`
WHERE customer_segment IN ('vip', 'regular')
-- 修正①: 完全修飾テーブル名の直書きをやめ、
-- dbt Meshのクロスプロジェクトrefでリネージを繋ぐ
SELECT
customer_id,
customer_segment,
ltv_yen
FROM {{ ref('crm', 'dim_customer') }}
WHERE customer_segment IN ('vip', 'regular')
-- 修正⑦: CRM側の失敗・遅延に気づくための鮮度チェック(singular test)
-- 全件が24時間以上前のデータ = CRM側の更新が丸1日以上停止している状態
SELECT customer_id, updated_at
FROM {{ ref('crm', 'dim_customer') }}
WHERE updated_at < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR)
HAVING COUNT(*) = (SELECT COUNT(*) FROM {{ ref('crm', 'dim_customer') }})
# infra/authorized_view.tf
# 修正⑤-1: MOps側のプロジェクトに、CRMのdim_customerを参照するビューを作成
# (MOps側の権限で完結する自分のデータセット内のビュー)
resource "google_bigquery_table" "dim_customer_view" {
project = "mops-project"
dataset_id = "mops_marts"
table_id = "dim_customer_view"
view {
query = "SELECT customer_id, customer_segment, ltv_yen FROM `crm-project.crm_marts.dim_customer`"
use_legacy_sql = false
}
}
# 修正⑤-2: CRM側のデータセットに、上記ビューだけを「認可されたビュー」として登録
# これによりMOpsのSAはdim_customer_viewを経由した閲覧のみ可能になり、
# crm_martsデータセット全体へのIAM付与は不要になる
resource "google_bigquery_dataset_access" "mops_authorized_view" {
project = "crm-project"
dataset_id = "crm_marts"
view {
project_id = google_bigquery_table.dim_customer_view.project
dataset_id = google_bigquery_table.dim_customer_view.dataset_id
table_id = google_bigquery_table.dim_customer_view.table_id
}
}
# 修正⑤-3: 撤去対象。crm_martsデータセット全体へのdataViewer付与は
# このAuthorized View運用に置き換えるため削除する。
# resource "google_bigquery_dataset_iam_member" "mops_sa_crm_dataviewer" {
# project = "crm-project"
# dataset_id = "crm_marts"
# role = "roles/bigquery.dataViewer"
# member = "serviceAccount:mops-sa@mops-project.iam.gserviceaccount.com"
# }
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① 完全修飾テーブル名直書き | ref('crm','dim_customer') | dbtのDAGに乗りリネージが復活 |
| ② 全モデルprotected放置 | dim_customerのみaccess:public | 公開意図がコード上に明示される |
| ③ 公開モデルにPII混入 | 公開契約からemail/phoneを除外 | 最小権限が権限設定より前段で担保される |
| ④ Model Contract無し | contract.enforced:true | 列名リネームがCRM側CIでその場で失敗 |
| ⑤ データセット丸ごとdataViewer | Authorized Viewで1ビューに限定 | 中間モデル・生テーブルへの越境を遮断 |
| ⑥ group未設定 | dbt_project.ymlのgroupsで明示 | 変更承認の所有権がコードから読み取れる |
| ⑦ 鮮度監視なし | updated_at検証のsingular test | CRM側実行失敗をMOps側で検知 |
実行例(input → output)
input(改善前):
CRMチームが dim_customer の ltv_yen を将来さらに ltv_yen_ex_tax(税抜)へリネームしたとする。
output(Before・問題のある実装):
MOps側は完全修飾テーブル名を直書きしているためdbtのDAGに依存関係が乗っておらず、CRM側のCIはMOps側の破壊に気づかない。MOps側の mart_coupon_targeting はテーブル定義変更後の次回実行時に ltv_yen 列が存在せずエラー、あるいは列が残っていればサイレントにNULLが混入し、クーポン配信対象の絞り込みが数日単位で静かに誤動作する。
output(After・改善後):
CRM側で dim_customer の列を変更しようとすると、contract.enforced: true により、契約で定義された列(ltv_yen 等)を変更する dbt build がCRM側のCIでその場で失敗する。CRMチームはこの時点でMOpsチームへの事前連携が必要だと気づける。また、クロスプロジェクト ref('crm', 'dim_customer') により、dbtの依存関係グラフ上でMOps側のモデルがCRM側の変更に接続されているため、CRM側のDiscovery情報からMOps側で影響を受けるモデルを機械的に特定できる。
ポイント解説
ref() を使うことが、リネージとCI検知の両方の前提条件になる。access 修飾子は「公開する意図」をコードで宣言する仕組み — デフォルトのprotectedのまま全モデルを放置すると、「本当に公開してよいもの」と「たまたま参照できてしまうもの」の区別が失われる。publicは狭く・意図的に付与するのが原則。group によるモデル所有権の明示は、レビュー体制をコードに落とし込む手段 — 「誰がこのモデルの変更を承認すべきか」がコードから読み取れることで、クロスプロジェクト参照が増えても変更管理が属人化しにくくなる。実務への応用
MOpsチームは今後、CRM・在庫・注文など複数チームが所有するデータを横断的に参照する機会が増えていく。その際、BigQueryのテーブル名を直書きして「動けばよい」状態にすると、参照先チームの変更が見えないまま積み重なり、ある日突然サイレントに壊れるという事故が繰り返される。dbt Meshの access/group/contract は、複数チームが同じデータ基盤を使う際の「境界線」をコードとして明示する仕組みであり、複数チーム協業のプロジェクトでも同様の設計思想(公開範囲の明示・契約による破壊的変更の検知・鮮度の監視)がそのまま応用できる。
今日のまとめ
次のステップ
- 発展問題: CRMチームが
dim_customerをさらに分割し、dim_customer_basic(customer_id,customer_segment)とdim_customer_ltv(customer_id,ltv_yen)の2モデルに分ける設計変更を行う場合、MOps側のmart_coupon_targetingへの影響を最小化する移行手順(新旧モデルの並行稼働期間の設け方、access/contractの移行順序)を設計せよ。 - 参考: dbt Mesh(クロスプロジェクト
ref、access修飾子、group) / dbt Model Contracts / BigQuery Authorized View・Authorized Dataset / 最小権限の原則(Principle of Least Privilege)