この記事では、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
devとprodの2ターゲットが生成され、デフォルトのtargetはdevになっています。CI用のターゲットは後から追加するため、この時点では触りません。DuckDBアダプタで指定できる項目はdbt 公式 Developer Hubにまとまっています。
以降の作業はプロジェクトディレクトリ内で行います。
% cd dbt_ci_demo
dbt initはmodels/example/にサンプルモデルも生成します。このサンプルは変更差分の確認対象に混ざるため、削除しておきましょう。
% rm -rf models/example
代わりに、テストの成否を意図的に切り替えられるモデルを1つ置いておきます。
select 1 as id, 'a' as name
union all
select 2 as id, 'b' as name
models/schema.ymlでid列にnot_nullとuniqueのテストを設定しましょう。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.ymlにprodターゲットがすでにあるので、それをコピーしてベースにします。
% cp ~/.dbt/profiles.yml ./profiles.yml
コピーしたファイルのdevターゲットの下に、PRビルド用のciターゲットを追記します。DuckDBのデータベースはファイル1つなので、ターゲットごとに別のファイル名を指定します。書き込み先はci.duckdb、参照だけしたいprod.duckdbはread_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
デフォルトのtargetはdevのままにしています。ciに変更すると、prodターゲットのビルドをまだ実行していないローカル環境ではattach先のprod.duckdbが無い状態でdbt buildを走らせることになります。devブロックの内容は~/.dbt/profiles.ymlからそのままコピーしているため、このファイルをルートに置いてもローカルのdbt buildの挙動は変わりません。
.circleci/config.yml を作成する
ジョブはbuild_mainとbuild_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.jsonとprod.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.duckdbとmanifest.jsonが手に入ります。
復元できたかどうかでコマンドを分けているのは、mainのビルドを一度も実行していないリポジトリではprod.duckdbとmanifest.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.txt・profiles.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上で成功しました。
確認したいのはSaving cacheとUploading 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で一致させる範囲で動作しています。

