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.92.0
Python最低要件3.8+3.9+
dbt debug コマンド非推奨警告なし一部オプション変更
ref()のcross-project参照dbt Mesh依存dbt Mesh + 公式サポート強化
--select構文のstate比較state:modifiedstate: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_nulluniqueだけ書いておけばいいでしょ」という雑なスタートでした。それがどれだけ甘かったか、本番のデータ異常を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-utilsdbt-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_bycluster_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を使っているチームの知見があれば聞いてみたいですね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事