1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CircleCI で dbt Core のビルドとテストを PR ごとに実行する

1
Last updated at Posted at 2026-08-20

この記事では、dbtのモデル変更を検証するCIワークフローをCircleCIで作る方法を紹介します。schema.ymlに定義した検証をCircleCI上で実行し、モデルの変更などがマージ可能かどうかをチェックできるようにします。

dbt プロジェクトの準備(DuckDB 接続)

uvが入っていない場合は、先にインストールしてください。

curl -LsSf https://astral.sh/uv/install.sh | sh

作業用ディレクトリを作成し、仮想環境を作ってdbt CoreとDuckDBアダプタをインストールします。

% mkdir dbt-circleci-duckdb-demo && cd dbt-circleci-duckdb-demo
% uv venv
% source .venv/bin/activate
% uv pip install dbt-core dbt-duckdb
% dbt --version

次にdbt initでプロジェクトを作成します。dbt initはアダプタを対話形式で聞いてくるため、実行後にプロンプトへの応答が必要です。インストール済みのアダプタがduckdbだけであれば選択肢も1つで、選んだ時点で~/.dbt/profiles.ymlが生成されます。接続設定を手で書く必要はありません。

% dbt init dbt_ci_demo

生成された~/.dbt/profiles.ymlはこのような内容となります。

dbt_ci_demo:
  outputs:
    dev:
      type: duckdb
      path: dev.duckdb
      threads: 1

    prod:
      type: duckdb
      path: prod.duckdb
      threads: 4

  target: dev

devprodの2ターゲットが生成され、デフォルトのtargetdevになっています。CI用のターゲットは後から追加するため、この時点では触りません。DuckDBアダプタで指定できる項目はdbt 公式 Developer Hubにまとまっています。

以降の作業はプロジェクトディレクトリ内で行います。

% cd dbt_ci_demo

dbt initmodels/example/にサンプルモデルも生成します。このサンプルは変更差分の確認対象に混ざるため、削除しておきましょう。

% rm -rf models/example

代わりに、テストの成否を意図的に切り替えられるモデルを1つ置いておきます。

select 1 as id, 'a' as name
union all
select 2 as id, 'b' as name

models/schema.ymlid列にnot_nulluniqueのテストを設定しましょう。uniqueを入れておくことで、idを重複させるだけでテストが失敗する状態を作れます。

version: 2
models:
  - name: stg_example
    columns:
      - name: id
        tests:
          - not_null
          - unique

dbt buildは、モデルの作成とテストの実行をまとめて行います。

% dbt build

3件(モデル1件、テスト2件)すべてがPASSすれば、ローカルでの準備は完了です。

CircleCI での CI 構成

ここから先はリポジトリ側の作業です。ローカルで動いたdbtプロジェクトをGitに載せ、CIのコンテナでも同じ結果が出る状態にしていきましょう。

Git リポジトリを作成して push する

DuckDBのデータベースファイルはビルドごとに再生成されるため、リポジトリには含めません。.gitignoreに次の2行を書きます。

% cat > .gitignore <<'EOF'
*.duckdb
*.duckdb.wal
EOF

ローカルリポジトリを作成し、コミットします。

% git init
% git add .
% git commit -m "initial dbt project"

GitHub上に空リポジトリを作成し、mainブランチにpushします。

% git remote add origin <GitHubリポジトリのURL>
% git branch -M main
% git push -u origin main

requirements.txt と profiles.yml を準備する

CI側でインストールする依存パッケージはrequirements.txtにバージョンを固定して記述します。

dbt-core==1.12.0
dbt-duckdb==1.10.1

CI用のプロファイルは、リポジトリのルートにprofiles.ymlとして置きます。dbt Coreはprofiles.ymlをカレントディレクトリ → ~/.dbt/の順で探すため(dbt-core Issue #6066)、ルートに置いた時点でこのファイルが優先されます。~/.dbt/profiles.ymlprodターゲットがすでにあるので、それをコピーしてベースにします。

% cp ~/.dbt/profiles.yml ./profiles.yml

コピーしたファイルのdevターゲットの下に、PRビルド用のciターゲットを追記します。DuckDBのデータベースはファイル1つなので、ターゲットごとに別のファイル名を指定します。書き込み先はci.duckdb、参照だけしたいprod.duckdbread_only: trueでattachする形です。クラウドのデータウェアハウスではprodとCIで書き込み先を分ける構成が一般的なため、ここでも同じ形をファイル単位で再現しています。

    # PR の差分ビルド用: ci.duckdb に書き込み、prod.duckdb を読み込み専用で参照
    ci:
      type: duckdb
      path: ci.duckdb
      threads: 4
      attach:
        - path: prod.duckdb
          read_only: true

追記後のprofiles.yml全体は次のようになります。

# ~/.dbt/profiles.yml(dbt init 生成分)をコピーし、PR ビルド用に調整したもの
dbt_ci_demo:
  outputs:
    dev:
      type: duckdb
      path: dev.duckdb
      threads: 1

    prod:
      type: duckdb
      path: prod.duckdb
      threads: 4

    # PR の差分ビルド用: ci.duckdb に書き込み、prod.duckdb を読み込み専用で参照
    ci:
      type: duckdb
      path: ci.duckdb
      threads: 4
      attach:
        - path: prod.duckdb
          read_only: true

  target: dev

デフォルトのtargetdevのままにしています。ciに変更すると、prodターゲットのビルドをまだ実行していないローカル環境ではattach先のprod.duckdbが無い状態でdbt buildを走らせることになります。devブロックの内容は~/.dbt/profiles.ymlからそのままコピーしているため、このファイルをルートに置いてもローカルのdbt buildの挙動は変わりません。

.circleci/config.yml を作成する

ジョブはbuild_mainbuild_prの2つで、dbtのインストール部分だけが共通です。まず設定ファイルを置くディレクトリを作成します。

% mkdir -p .circleci

ここから、.circleci/config.ymlの内容を冒頭から順に見ていきます。共通部分はcommandsに切り出して両方のジョブから呼び出します。

.circleci/config.yml

version: 2.1

# profiles.yml = ~/.dbt/profiles.yml (dbt init) をコピーし、ci ターゲットを追加したもの。
# 常に --profiles-dir . と明示的な --target を指定する(profiles 内のデフォルトは dev)。

commands:
  setup_dbt:
    steps:
      - restore_cache:
          keys:
            - dbt-deps-v1-{{ checksum "requirements.txt" }}
      - run:
          name: Install dbt
          command: pip install --user -r requirements.txt
      - save_cache:
          key: dbt-deps-v1-{{ checksum "requirements.txt" }}
          paths:
            - ~/.local

続いてmainブランチ用のbuild_mainジョブです。

.circleci/config.yml(続き)

jobs:
  build_main:
    docker:
      - image: cimg/python:3.11
    steps:
      - checkout
      - setup_dbt
      - run:
          name: dbt debug
          command: dbt debug --profiles-dir . --target prod
      - run:
          # prod = ビルド済みの倉庫。PR 側の attach/defer の元データになる
          name: dbt build (full refresh)
          command: dbt build --full-refresh --profiles-dir . --target prod
      - run:
          name: Stage warehouse artifacts
          command: |
            mkdir -p .dbt-artifacts
            cp target/manifest.json .dbt-artifacts/
            cp prod.duckdb .dbt-artifacts/
      - save_cache:
          key: dbt-warehouse-v1-main-{{ .Revision }}
          paths:
            - .dbt-artifacts
      - store_artifacts:
          path: .dbt-artifacts

先頭にdbt debugを置いているのは、接続の失敗とモデルの失敗を切り分けるためです。--profiles-dirの指定やターゲット名を間違えていると、ビルドまで進まずこのステップで止まります。

--full-refreshを付けているのは、prod.duckdbをその時点の全モデルが揃った状態にしておくためです。PR側はこのファイルを未変更モデルの参照先として使うため、参照先のテーブルが揃っていることが前提になります。ビルド後はmanifest.jsonprod.duckdb.dbt-artifactsにまとめ、キャッシュとアーティファクトの両方に出しています。manifest.jsonを渡しているのは、PR側で使う--deferが、前回のビルド時点の状態と比較して未変更のモデルへの参照を既存のテーブルへ振り替えるもので、その比較対象となる状態がmanifest.jsonに記録されているためです。キャッシュキーに{{ .Revision }}(コミットハッシュ)を含めているため、mainへのコミットごとに別のキャッシュエントリが作られます。

次がPR用のbuild_prジョブです。

.circleci/config.yml(続き)

  build_pr:
    docker:
      - image: cimg/python:3.11
    steps:
      - checkout
      - setup_dbt
      - restore_cache:
          keys:
            - dbt-warehouse-v1-main-
      - run:
          name: Diff build or full fallback
          command: |
            if [ -f .dbt-artifacts/manifest.json ] && [ -f .dbt-artifacts/prod.duckdb ]; then
              # ci ターゲットは prod.duckdb を読み込み専用 attach する
              cp .dbt-artifacts/prod.duckdb ./prod.duckdb
              dbt build --profiles-dir . --target ci \
                --select state:modified+ \
                --defer --state .dbt-artifacts
            else
              # attach 先の prod.duckdb がまだ無いため、defer 無しの dev で実行
              echo "warehouse cache miss; full build with target=dev"
              dbt build --profiles-dir . --target dev
            fi

restore_cacheは、キーをプレフィックスdbt-warehouse-v1-main-までしか指定していません。PRのジョブはmainの最新コミットハッシュを知らないため、キー全体を一致させられないからです。プレフィックスで照合したときは一致するキャッシュのうち直近に生成されたものが復元されるため(キャッシュ)、mainで最後に保存されたprod.duckdbmanifest.jsonが手に入ります。

復元できたかどうかでコマンドを分けているのは、mainのビルドを一度も実行していないリポジトリではprod.duckdbmanifest.jsonが存在しないためです。attach先のファイルが無い場合の挙動は確認していないため、ciターゲットを使わない経路を用意しています。ファイルが揃っていればciターゲットでstate:modified+(変更されたモデルとその下流)を--defer付きで実行し、揃っていなければdevターゲットの全件ビルドに切り替えて、PRのテスト自体は止めないようにしています。

最後に、2つのジョブをブランチで振り分けます。

.circleci/config.yml(続き)

workflows:
  dbt-ci:
    jobs:
      - build_main:
          filters:
            branches:
              only: main
      - build_pr:
          filters:
            branches:
              ignore: main

requirements.txtprofiles.yml.circleci/config.ymlをコミットし、mainにpushします。

% git add requirements.txt profiles.yml .circleci/config.yml
% git commit -m "Add CircleCI config"
% git push

CircleCI にプロジェクトを登録する

main.circleci/config.ymlが入った状態で、CircleCI Web App(app.circleci.com)から対象リポジトリをプロジェクトとして登録します。

登録後、mainへのpushでパイプラインが実行されます。登録した時点のコミットでパイプラインが作られなかった場合は、mainに空コミットをpushしてください。

% git commit --allow-empty -m "trigger first pipeline"
% git push

main ブランチのビルドを確認する

mainへのpush後、build_mainジョブがCircleCI上で成功しました。

build_mainジョブの実行結果。全ステップ成功、合計53秒、Docker/Large

確認したいのはSaving cacheUploading artifactsが完了しているかどうかです。ここが失敗していると、次のPRビルドで復元するものが無く、devターゲットの全件ビルドへ落ちます。

テストの失敗がローカルと CI で一致するか確認する

ciターゲットはprod.duckdbのattachと--deferを伴うため、ローカルのdevターゲットとは接続の形が違います。この違いでテストの判定が変わらないかを、失敗するケースで確認します。models/stg_example.sqlの2行目を書き換え、id列の値を重複させます。

 select 1 as id, 'a' as name
 union all
-select 2 as id, 'b' as name
+select 1 as id, 'b' as name

ローカルでdbt buildを実行すると、uniqueテストが失敗します。

3 of 3 FAIL 1 unique_stg_example_id ............................................ [FAIL 1 in 0.01s]

この変更をinvalid-sqlという名前のブランチにpushしてbuild_prを実行しました。

% git checkout -b invalid-sql
% git commit -am "duplicate id to fail unique test"
% git push -u origin invalid-sql

失敗内容の取得にはCircleCI CLIを使い、-bにそのブランチ名を渡しています。

% circleci run get -b invalid-sql --failure-report

## workflow: dbt-ci

### job: build_pr

#### step 106: Diff build or full fallback [exit: 1]

06:47:37  Running with dbt=1.12.0
06:47:38  Registered adapter: duckdb=1.10.1
06:47:38  Unable to do partial parsing because saved manifest not found. Starting full parse.
06:47:40  Found 1 model, 2 data tests, 486 macros
06:47:40
06:47:40  Concurrency: 4 threads (target='ci')
06:47:40
06:47:40  1 of 3 START sql view model main.stg_example ................................... [RUN]
06:47:41  1 of 3 OK created sql view model main.stg_example .............................. [OK in 0.12s]
06:47:41  2 of 3 START test not_null_stg_example_id ...................................... [RUN]
06:47:41  3 of 3 START test unique_stg_example_id ........................................ [RUN]
06:47:41  2 of 3 PASS not_null_stg_example_id ............................................ [PASS in 0.09s]
06:47:41  3 of 3 FAIL 1 unique_stg_example_id ............................................ [FAIL 1 in 0.09s]
06:47:41
06:47:41  Finished running 2 data tests, 1 view model in 0 hours 0 minutes and 0.38 seconds (0.38s).
06:47:41
06:47:41  Completed with 1 error, 0 partial successes, and 0 warnings:
06:47:41
06:47:41  [ERROR]: in test unique_stg_example_id (models/schema.yml)
06:47:41    Got 1 result, configured to fail if != 0
06:47:41
06:47:41    compiled code at target/compiled/dbt_ci_demo/models/schema.yml/unique_stg_example_id.sql
06:47:41
06:47:41  Done. PASS=2 WARN=0 ERROR=1 SKIP=0 NO-OP=0 REUSED=0 TOTAL=3

Concurrency: 4 threads (target='ci')の行から、ciターゲットが選択されていることがわかります。フォールバック側のdevに落ちていれば、ここはtarget='dev'になります。落ちたテストはローカルと同じunique_stg_example_idで、理由も同じです。ジョブはattachや--stateの解決でクラッシュせず、dbtのテスト結果としてFAILを返して終了しています。

まとめ

dbt CoreとCircleCIとDuckDBの組み合わせで、クラウドデータウェアハウスの契約なしにCIを構築できました。mainのフルビルド結果をキャッシュに保存し、PRではそれを読み込み専用attachで参照する構成は、テストの成否をローカルとCIで一致させる範囲で動作しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?