dbt Core 2.0移行後1年、本番運用で積み上がった設計の知見
「マクロどこまで抽象化する?」チームで1時間半議論したことありませんか。dbt Core 2.0移行後の実運用で気づいたモデル設計・テスト・CI/CD構成の失敗と改善を共有します。
先日チームのSlackで「dbtのマクロ、どこまで抽象化すべきか」という議論が盛り上がって、気づいたら1時間半経ってた。それくらい、dbtは「使えばわかる」系のツールなんですよね。ドキュメントを読むだけでは見えない判断ポイントが山ほどある。
うちのチームは約1年半前にdbt Cloud→dbt Core移行を完了して、今はdbt Core 2.0系で本番運用しています。移行の話はdbt 1.9→2.0移行で半年ハマった話でも触れたけど、今回は「移行後の運用で積み上がった知見」をメインに書きます。特にモデル設計の判断ポイント、テスト戦略、CI/CDとの統合あたり。同じ悩みを持っているエンジニアに刺されば嬉しい。
dbt Core 2.0で何が変わったか(2026年時点の整理)
正直、1.9→2.0は「破壊的変更が少ない」とアナウンスされていたのに、うちのチームは結構ハマりました。主な変更点を整理しておきます。
| 項目 | 1.9 | 2.0 |
|---|---|---|
| Python最低要件 | 3.8+ | 3.9+ |
dbt debug コマンド | 非推奨警告なし | 一部オプション変更 |
ref()のcross-project参照 | dbt Mesh依存 | dbt Mesh + 公式サポート強化 |
--select構文のstate比較 | state:modified | state:modified+のデフォルト挙動変更 |
| semantic model | プレビュー | GA |
| Unit Tests | プレビュー | GA・構文変更あり |
特に影響が大きかったのがUnit Testsの構文変更とsemantic modelのGAです。Unit Testsはプレビュー期間中に書いたYAMLが一部そのまま動かなくなった。ここは正直しんどかった。
# 旧(1.9プレビュー時の書き方)
unit_tests:
- name: test_order_amount_calculation
model: fct_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, amount: 1000, discount: 100}
expect:
rows:
- {order_id: 1, net_amount: 900}
# 新(2.0 GA後の書き方、overrides構文が追加)
unit_tests:
- name: test_order_amount_calculation
model: fct_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, amount: 1000, discount: 100}
expect:
rows:
- {order_id: 1, net_amount: 900}
overrides:
macros:
current_timestamp: "2026-01-01 00:00:00"
overrides.macrosが追加されて、now()系のマクロをモック化できるようになったのはマジで助かった。日時依存のモデルテストが書きやすくなりましたね、これは地味に大きい。
モデル設計で本当に迷ったこと
うちのデータスタックはBigQueryをDWHとして使っていて、Fivetranでのデータ取り込み→dbtで変換→MetabaseとSupersetでの可視化という流れ(BIツールの話はMetabaseとSuperset徹底比較2026に書いています)。dbtのモデル構成は下記のようなアーキテクチャにしています。
flowchart TB
subgraph sources["Sources"]
RAW[(Raw BigQuery\nDatasets)]
end
subgraph staging["Staging Layer"]
STG_ORD[stg_orders]
STG_USR[stg_users]
STG_PRD[stg_products]
end
subgraph intermediate["Intermediate Layer"]
INT_ORD[int_orders_enriched]
INT_USR[int_users_lifetime]
end
subgraph marts["Data Marts"]
FCT[fct_orders]
DIM_USR[dim_users]
DIM_PRD[dim_products]
REPT[rpt_monthly_revenue]
end
subgraph consumers["Consumers"]
BI[Metabase / Superset]
DS[Data Science]
API[Application API]
end
RAW --> STG_ORD
RAW --> STG_USR
RAW --> STG_PRD
STG_ORD --> INT_ORD
STG_USR --> INT_USR
INT_ORD --> FCT
INT_USR --> DIM_USR
STG_PRD --> DIM_PRD
FCT --> REPT
DIM_USR --> REPT
REPT --> BI
FCT --> DS
DIM_USR --> API
この構成でチームが一番迷ったのが「intermediateレイヤーをどこまで使うか」問題。stagingからいきなりmartsに行けるシンプルなケースと、複数のstagingを組み合わせてロジックが複雑になるケースの境界線が曖昧で、最初の3ヶ月はモデルが散らかり放題だった。
結局うちのチームで決めたルールはシンプルで、「2つ以上のstagingモデルをJOINする処理が出てきたらintermediate層に切り出す」です。これだけ決めたらレビューも楽になった。ルールがシンプルなほど迷いがなくなるんですよね。
もう一つ悩んだのがファイルの命名。models/直下をstaging/, intermediate/, marts/に分けて、さらにソースシステム別にサブディレクトリを切っています。
models/
├── staging/
│ ├── salesforce/
│ │ ├── _salesforce__sources.yml
│ │ ├── stg_salesforce__accounts.sql
│ │ └── stg_salesforce__opportunities.sql
│ └── stripe/
│ ├── _stripe__sources.yml
│ └── stg_stripe__charges.sql
├── intermediate/
│ ├── int_orders_enriched.sql
│ └── int_users_lifetime_value.sql
└── marts/
├── core/
│ ├── fct_orders.sql
│ └── dim_users.sql
└── reporting/
└── rpt_monthly_revenue.sql
これ、最初は「そこまでやる必要ある?」と思ってたけど、モデルが100個を超えたあたりから圧倒的に検索しやすくなった。早めに決めておいてよかったと思ってます。
テスト戦略と品質担保の現実解
データ品質の話をするとき、dbtのテストは欠かせない。でも正直、最初は「not_nullとuniqueだけ書いておけばいいでしょ」という雑なスタートでした。それがどれだけ甘かったか、本番のデータ異常を3回経験して痛感した。反省してます。
データ品質管理の考え方についてはデータ品質管理2026年版が参考になるので合わせて読んでほしいんですが、dbtの文脈では以下のように層別にテストを分けています。
# models/staging/stripe/_stripe__sources.yml
version: 2
sources:
- name: stripe
database: raw
schema: stripe
tables:
- name: charges
columns:
- name: id
tests:
- not_null
- unique
- name: amount
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 10000000
- name: status
tests:
- accepted_values:
values: ['succeeded', 'pending', 'failed', 'refunded']
- name: created
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_of_type:
column_type: timestamp
# models/marts/core/_core.yml
version: 2
models:
- name: fct_orders
description: "受注ファクトテーブル"
tests:
- dbt_utils.recency:
datepart: hour
field: created_at
interval: 25 # 25時間以内にデータが存在すること
columns:
- name: order_id
tests:
- not_null
- unique
- name: user_id
tests:
- not_null
- relationships:
to: ref('dim_users')
field: user_id
- name: net_amount
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
パッケージはdbt-utilsとdbt-expectationsの2つを入れておけばだいたい事足りる。dbt-expectationsはGreat Expectationsのdbt移植版なんだけど、型チェックや値の範囲チェックが書きやすくてお気に入りです。
テストの実行頻度については、CIで差分ビルド時は変更のあったモデルのみ、本番の日次実行後はすべてのテストを回す構成にしています。テスト種別によって実行時間にかなり差があるのが下のグラフを見るとわかる。
xychart-beta
title "テスト種別ごとの平均実行時間(秒)"
x-axis ["not_null/unique", "accepted_values", "relationships", "dbt_expectations", "Unit Tests", "recency"]
y-axis "実行時間(秒)" 0 --> 120
bar [3, 5, 15, 25, 8, 12]
relationshipsテストが意外と重い。参照先テーブルのスキャンが走るから当然なんですが、最初は「なんでこんなに遅いんだ」と悩んでた。not_null/uniqueと比べると5倍以上かかるのは地味に堪える。
CI/CDパイプライン構成とstate:modifiedの使い方
ここが一番「実務的な知見がないと詰む」ポイントだと思っています。全モデルを毎回フルビルドしていたら時間とコスト両方が爆発するので、差分ビルドは必須。
うちのチームはGitHub ActionsでCI、本番実行はCloud Schedulerからdbt runを叩く構成です。
# .github/workflows/dbt_ci.yml
name: dbt CI
on:
pull_request:
paths:
- 'models/**'
- 'macros/**'
- 'dbt_project.yml'
- 'packages.yml'
jobs:
dbt-slim-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dbt
run: |
pip install dbt-bigquery==2.0.3
- name: Fetch production manifest
run: |
# GCSから本番のmanifest.jsonを取得
gcloud storage cp gs://your-bucket/dbt/prod/manifest.json ./prod_manifest/manifest.json
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
- name: dbt build (modified only)
run: |
dbt build \
--select state:modified+ \
--defer \
--state ./prod_manifest \
--target ci
env:
DBT_PROFILES_DIR: ./profiles
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
- name: Upload CI manifest
if: success()
run: |
gcloud storage cp ./target/manifest.json gs://your-bucket/dbt/ci/${{ github.sha }}/manifest.json
ポイントは--deferフラグです。これを使うと、CIのジョブで変更していないモデルへの参照を本番の実体に向けてくれる。つまりCIでは変更したモデルだけビルドすればいい。これがないとCIで全モデルをビルドし直すことになって、時間が10倍以上かかる。個人的にはこれを知ったときが一番「dbtわかってきた」と感じた瞬間だった。
dbt 2.0での変更点として、state:modified+のデフォルト挙動が少し変わっています。具体的には、+なし(state:modifiedのみ)だと直接変更のあったモデルだけを対象にするけど、state:modified+にすると変更のあったモデルの下流も含む。うちは基本的にstate:modified+を使っています。誤って下流モデルがこけた状態で本番に出すより、CIで検知してくれる方が絶対いい。
sequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant GCS as GCS(manifest)
participant BQ as BigQuery(CI)
participant BQ_PROD as BigQuery(Prod)
Dev->>GH: Pull Request作成
GH->>GCS: prod manifest.json取得
GCS-->>GH: manifest.json
GH->>BQ: dbt build --select state:modified+ --defer
Note over GH,BQ: 変更モデルのみCIにビルド<br/>未変更モデルは本番を参照
BQ-->>GH: ビルド・テスト結果
GH->>Dev: CI結果通知
Dev->>GH: merge
GH->>BQ_PROD: dbt run --target prod (全モデル)
GH->>GCS: manifest.json更新
semantic modelとMetricFlowの実践的な使い方
dbt 2.0でGAになったsemantic modelとMetricFlow、正直まだ検証中の部分も多いけど、実務で使えるレベルには達してきた印象です。
ざっくり言うと「ビジネスメトリクスの定義をdbtで一元管理する仕組み」で、BIツール側でメトリクスの定義が散らばる問題を解決できます。「月次売上の定義がSuperset版とMetabase版で微妙に違う」みたいな事故、どのチームでも一度は経験あるんじゃないかな。
# models/marts/core/fct_orders.yml (semantic model部分)
semantic_models:
- name: orders
description: "受注のsemantic model"
model: ref('fct_orders')
defaults:
agg_time_dimension: created_at
entities:
- name: order
type: primary
expr: order_id
- name: user
type: foreign
expr: user_id
dimensions:
- name: created_at
type: time
type_params:
time_granularity: day
- name: status
type: categorical
- name: country
type: categorical
measures:
- name: order_count
agg: count
expr: order_id
- name: total_revenue
agg: sum
expr: net_amount
- name: avg_order_value
agg: average
expr: net_amount
metrics:
- name: monthly_revenue
description: "月次売上"
type: simple
type_params:
measure: total_revenue
filter: |
{{ Dimension('order__status') }} = 'completed'
これを定義しておくと、MetricFlow CLIで問い合わせができる。
# MetricFlow CLIでのクエリ例
$ mf query \
--metrics monthly_revenue \
--group-by metric_time__month,country \
--start-time 2026-01-01 \
--end-time 2026-06-30
✔ Success 🦄 - query completed after 3.21 seconds
| METRIC_TIME__MONTH | COUNTRY | MONTHLY_REVENUE |
|-------------------|---------|----------------|
| 2026-01-01 | JP | 12500000 |
| 2026-01-01 | US | 8750000 |
| 2026-02-01 | JP | 13200000 |
...
BIツールと連携する場合、今はdbt Cloud経由のMetricFlow APIを使うケースが多いと思うけど、dbt Core + Coreだと自前でMetricFlowのサーバーを立てる必要がある。この辺りは正直まだ運用コストが高くて、うちのチームではCLIでの確認にとどまっています。dbt CloudとCoreの機能差はここが一番大きいかもしれない。
下のグラフで両者を比べると、MetricFlowまわりでの差が一目でわかります。
xychart-beta
title "dbt Cloud vs dbt Core 機能比較スコア (5点満点)"
x-axis ["Slim CI", "semantic model", "MetricFlow", "Explorer UI", "スケジューラ", "コスト"]
y-axis "スコア" 0 --> 5
bar [4, 5, 5, 5, 5, 2]
(コストスコアが低いのはdbt Cloudが高いという意味。dbt Coreは無料だがセルフホスト運用コストがある)
データパイプライン全体設計との統合ポイント
dbtは変換レイヤーに特化したツールなので、オーケストレーションはAirflowやCloud Composerと組み合わせるケースが多い。うちのチームではCloud Composer(Airflow 2.x)でdbtのジョブを管理しています。
Airflowとdbtの連携についてはデータパイプラインで2年間ハマり続けた話に詳しく書いているので見てほしいんですが、最近気に入っているパターンを一つ共有します。
DagsterやPrefectへの移行も検討したんですが、うちのチームはAirflowのオペレーター知識の蓄積があったので、DbtTaskGroupを使ったシンプルな構成で落ち着きました。乗り換えコストより継続の価値が上回ったというか。
# dags/dbt_daily_run.py
from airflow import DAG
from airflow.providers.dbt.cloud.operators.dbt import DbtRunOperator
from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig
from cosmos.constants import LoadMode
from datetime import datetime, timedelta
with DAG(
dag_id='dbt_daily_production',
start_date=datetime(2026, 1, 1),
schedule_interval='0 3 * * *', # JST午前3時 = UTC午後6時
catchup=False,
default_args={
'retries': 2,
'retry_delay': timedelta(minutes=5),
},
) as dag:
dbt_run = DbtTaskGroup(
group_id='dbt_models',
project_config=ProjectConfig('/opt/airflow/dbt_project'),
profile_config=ProfileConfig(
profile_name='production',
target_name='prod',
profile_mapping=BigQueryOauth2TokenAccessToken(
profile_args={'project': 'your-gcp-project'},
),
),
render_config=RenderConfig(
load_method=LoadMode.DBT_LS,
select=['tag:daily'], # 日次タグの付いたモデルのみ
),
)
CosmosというAirflow provider for dbtを使うと、dbtのDAGをAirflowのタスクグラフとして綺麗に可視化できる。これ、デバッグのときに本当に便利で、どのモデルでこけたかが一目でわかる。地味に便利という言葉が似合う機能のひとつです。
モデルにタグを付けておくのも重要で、実行頻度(daily/hourly/weekly)やデータ重要度(critical/standard)をタグで管理しています。
# models/marts/core/fct_orders.sql の先頭
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
partition_by={
'field': 'created_at',
'data_type': 'timestamp',
'granularity': 'day'
},
cluster_by=['user_id', 'status'],
tags=['daily', 'critical']
) }}
BigQueryのincrementalモデルではpartition_byとcluster_byの設定が特に重要で、これを入れ忘れるとフルスキャンが走って課金が爆発します。うちのチームで一度やらかして月のクエリコストが2倍になったことがある。笑えない話なのでまた別で書きます。
まとめ
1年以上dbtを本番運用して、「最初に決めておけばよかった」ことと「運用して初めてわかった」ことが両方たくさん出てきました。要点を整理するとこんな感じです。
- モデル命名・ディレクトリ構成は早期に決める: 100モデルを超えると後から変えるのはかなり辛い。staging/intermediate/martsの3層構成はシンプルで機能する
--deferフラグでSlim CIは必須: フルビルドのCIは時間・コスト両面で持続不可能。manifest.jsonのGCSなどへの保存と合わせて設定しておくべき- Unit Testsは積極活用する: 2.0でGAになって構文も安定した。時刻依存ロジックのテストが書けるようになって、テストカバレッジが大幅に改善した
- semantic modelはdbt Coreだと運用コストが高い: MetricFlowの恩恵を完全に受けたいならdbt Cloud検討の価値あり。Coreだとまだ制約がある
- incrementalモデルのpartition設定は必ず入れる: BigQueryではこれが費用に直結する
次のアクションとしては、semantic modelの本番活用をもう少し押し進めて、BIツールとの連携部分を自動化したいと思っています。あと、うちのチームではまだdbt Meshをちゃんと使えていないので、マルチプロジェクト構成への移行も検討中。皆さんのチームはどんなdbt構成で運用しています?特にdbt Meshを使っているチームの知見があれば聞いてみたいですね。