
【Snowflake】ウェアハウスを起動せずにIcebergテーブルをDuckDBで読んでみた
はじめに
データ事業本部のkasamaです。
今回は、ダッシュボード用に集計したテーブル(以下、gold のテーブル)を Snowflake-managed Iceberg テーブルにして、dbt Charts(DuckDB)でダッシュボードを表示してみたいと思います。ダッシュボードの表示で Snowflake のウェアハウスが動かないことも確認します。
前提
背景
BI ツールを Snowflake にライブ接続すると、ダッシュボードを開いたときに BI ツールが Snowflake でクエリを実行します。停止しているウェアハウスはクエリの実行で起動し、Snowflake は起動するたびに最低 60 秒分のクレジットを課金します。
gold のテーブルを Iceberg テーブルにすると、Snowflake の外のエンジンが Snowflake Horizon Catalog の Iceberg REST カタログ経由でテーブルを読めます。DuckDB は Horizon のカタログからテーブルのメタデータの場所と S3 の一時的な認証情報を受け取り、Parquet ファイルを S3 から直接読みます。そのため、クエリの実行に Snowflake のウェアハウスを使いません。Horizon の API 呼び出しは 100 万回あたり 0.5 クレジットで課金されます。
アーキテクチャ

- dbt がサンプルの CSV を seed で Snowflake のテーブルに読み込み、そのテーブルを集計して gold の Iceberg テーブルを作る。gold のデータファイルとメタデータは gold 用の S3 バケットに書き込まれる
- Lambda を手動で実行する。Lambda は Horizon のアクセストークンを取得し、
dct renderで HTML を作って S3 に置く。ダッシュボードには dbt Charts を使い、接続先に DuckDB を指定する - 利用者は CloudFront の URL で HTML を開く。CloudFront に付けた AWS WAF で接続元の IP アドレスを制限する
今回は静的な HTML を出力するので、利用者がダッシュボードを開いたときにクエリは実行されません。データが更新されたら Lambda を再実行して HTML を作り直します。gold のデータを読む権限は Lambda の service user の単位で決まり、許可した IP アドレスからダッシュボードを開く人は、全員が同じ内容を見ます。
Lambda の認証
Lambda は Snowflake の workload identity federation(WIF)で認証します。Lambda の実行ロールで STS の GetCallerIdentity リクエストに署名し、その署名を Horizon のトークンエンドポイントに送ってアクセストークンを受け取ります。Snowflake 側では、service user に Lambda の実行ロールの ARN を登録しておきます。今回は秘密情報を保存せずに済むように、AWS の WIF でトークンを取得する処理を Lambda 側に組み込んでいます。Snowflake は AWS の WIF では GetCallerIdentity の代わりに、AWS の outbound identity federation で発行した JWT を使う方法を推奨しています。今回の検証用の AWS アカウントでは outbound identity federation を有効にしていないので、GetCallerIdentity の方法にしています。
より簡単に実装したい場合は、Horizon のドキュメントに手順が記載されている PAT やキーペア認証でアクセストークンを取得する方法もあります。
前提条件
- Terraform 1.10 以上(Snowflake provider 2.22、AWS provider 5 系)
- AWS CLI・Docker(または互換の CLI)・uv・Snowflake CLI
- dbt-core 1.12.5、dbt-snowflake 1.12.4
- dbt Charts(
dbt-charts)0.10.0
実装
実装コードはGitHubに格納しています。
74_snowflake_iceberg_gold_dbt_charts/
├── dbt/
│ ├── dbt_project.yml
│ ├── profiles.yml
│ ├── dbt_charts.yml # dbt Charts の接続先
│ ├── charts/order_overview.yml # dbt Charts のボード
│ ├── macros/generate_schema_name.sql
│ ├── seeds/ # サンプルの CSV(jaffle_shop の注文・顧客・支払いの一部)
│ └── models/gold/ # order_status_summary, payment_method_summary, customer_orders
├── lambda/
│ ├── Dockerfile
│ └── handler.py
├── sql/
│ └── setup.sql # ACCOUNTADMIN で実行する(ロール・service user)
└── terraform/
├── snow-infra/ # データベース・ウェアハウス・External Volume・ロール・service user
├── aws/ # S3・IAM ロール・ECR・Lambda・CloudFront・WAF
└── modules/ # リソースの種類ごとのモジュール(aws/ と snow/)
setup.sql
setup.sql は、Terraform と dbt が使うデプロイ用のロールと service user(キーペア認証)を ACCOUNTADMIN で作ります。
Terraform
Terraform は state を snow-infra と aws の 2 つの層に分け、snow-infra・aws の順に apply します。snow-infra 層は IAM ロールの ARN を組み立てて Snowflake のオブジェクトを先に作り、aws 層は Snowflake が生成した IAM ユーザーと External ID を信頼ポリシーに入れて IAM ロールを作ります。リソースの定義は、terraform/modules/ にリソースの種類ごとのモジュールとして置いています。
snow-infra 層の storage.tf では、S3 バケットと IAM ロールの名前を決め、External Volume のモジュールを呼び出しています。aws 層はこの名前で IAM ロールを作るので、Snowflake に登録した ARN と実際のロールが一致します。
External Volume のモジュールでは、gold の Iceberg テーブルのファイルを置く S3 の場所を定義しています。
reader_role のモジュールでは、Lambda が gold を読むときに使うロールを定義しています。ロール CM_KASAMA_ICEBERG_BI_READER には、データベースとスキーマ GOLD の USAGE と、GOLD の Iceberg テーブルに対する future grant の SELECT だけを付けています。gold のテーブルは Terraform の apply の後に dbt が作るため、future grant にしています。
service_user のモジュールでは、Lambda が Snowflake にログインするときに使う service user を定義しています。service user には Lambda の実行ロールの ARN を登録しておき、その実行ロールで動く Lambda だけがこの service user としてログインできるようにしています。
aws 層の lambda_container のモジュールでは、ECR リポジトリと Lambda 関数を定義しています。Lambda の実行ロールに付けるのは、HTML を置く S3 バケットへの s3:PutObject だけです。Horizon の URL・カタログ名(Snowflake のデータベース名)・ロール名は、aws 層が snow-infra 層の output から環境変数に設定しています。
cloudfront_site のモジュールでは、HTML のバケットを OAC 経由で配信する CloudFront のディストリビューションと、AWS WAF の IP セットと Web ACL を定義しています。IP セットには変数 allowed_cidrs の IP アドレスを登録しています。Web ACL は IP セットに含まれる IP アドレスからのリクエストだけを許可し、それ以外のリクエストには 403 を返します。CloudFront に付ける WAF は us-east-1 に作る必要があります。そのため、aws 層の versions.tf に us-east-1 用の AWS provider(alias = "us_east_1")を追加し、モジュールに渡しています。
そのほかのファイル(S3・ウェアハウス)は GitHub を参照してください。
dbt
dbt_project.yml では、gold のモデルに +table_format: iceberg と +external_volume: CM_KASAMA_ICEBERG_BI_GOLD_VOL を指定しています。seed のテーブルはスキーマ SEED に通常のテーブルとして作ります。
seed は jaffle_shop の注文 20 件・顧客 10 人・支払い 23 件です。gold のモデルは 3 つです。order_status_summary は注文をステータスごとに数え、payment_method_summary は支払いの金額を支払方法ごとに合計します。customer_orders は顧客と注文と支払いを結合し、顧客ごとの注文数・最終注文日・売上を集計します。
dbt_charts.yml では、DuckDB に Horizon のカタログを type: iceberg でアタッチし、HORIZON_TOKEN のアクセストークンで接続しています。dbt Charts の公式ドキュメントには、DuckDB の source で Iceberg REST カタログをアタッチする例はありません。dbt Charts は secrets と attach の設定を DuckDB の CREATE SECRET と ATTACH の SQL にして実行するので、DuckDB の Iceberg 拡張のオプションをそのまま書けます。今回は DuckDB 2.0.0 で動くことを確認しました。access_delegation_mode: vended_credentials を指定すると、DuckDB は gold のファイルを読むための S3 の一時的な認証情報を Horizon から受け取ります。そのため、Lambda の実行ロールには gold のバケットを読む権限を付けていません。
order_overview.yml はボードの定義です。1 段目に売上・注文数・平均注文額の KPI、2 段目に支払方法別の売上のドーナツとステータス別の注文数の棒グラフ、3 段目に顧客別の売上の表を置いています。
Lambda
handler.py では、get_horizon_token() で取得したアクセストークンを環境変数 HORIZON_TOKEN に入れて dct render を実行しています。Lambda の実行環境で書き込めるのは /tmp だけなので、ボードの定義ファイルを /tmp/dashboard にコピーし、HOME=/tmp にして dct を実行しています。
Dockerfile では、dbt Charts とその依存パッケージと、DuckDB の拡張機能(iceberg・httpfs・avro)をビルド時にインストールしています。これらを合わせると zip の Lambda のサイズの上限を超えるので、コンテナイメージで Lambda を作っています。
デプロイ
1. ロールと service user を作る
キーペアを作り、ACCOUNTADMIN を使える接続(ブラウザの SSO など)で sql/setup.sql を実行します。Snowflake CLI は SNOWFLAKE_* の環境変数をすべての接続に適用するので、手順 1 は手順 2 の export より前に実行します。setup.sql は、ロールと、公開鍵を登録した service user を作ります。
KEY_PATH=~/.snowflake/cm_kasama_iceberg_bi_rsa_key.p8
KEY_PASSPHRASE='<passphrase>'
openssl genrsa 2048 | KEY_PASSPHRASE="$KEY_PASSPHRASE" openssl pkcs8 -topk8 -v2 aes-256-cbc -passout env:KEY_PASSPHRASE -out "$KEY_PATH"
chmod 600 "$KEY_PATH"
PUBLIC_KEY=$(KEY_PASSPHRASE="$KEY_PASSPHRASE" openssl rsa -in "$KEY_PATH" -passin env:KEY_PASSPHRASE -pubout 2>/dev/null | grep -v -- '-----' | tr -d '\n')
snow sql -c <admin-connection> --role ACCOUNTADMIN -D "public_key=${PUBLIC_KEY}" -f sql/setup.sql
2. Snowflake のオブジェクトと ECR リポジトリを作る
snow-infra 層を apply してから、aws 層の ECR リポジトリだけを先に作ります。Lambda を作るときに、ECR にイメージが必要なためです。
export AWS_PROFILE='<aws-profile>'
export SNOWFLAKE_ORGANIZATION_NAME='<snowflake-organization-name>'
export SNOWFLAKE_ACCOUNT_NAME='<snowflake-account-name>'
export SNOWFLAKE_USER='CM_KASAMA_ICEBERG_BI_DEPLOY_SVC'
export SNOWFLAKE_AUTHENTICATOR='SNOWFLAKE_JWT'
export SNOWFLAKE_PRIVATE_KEY_PATH="$KEY_PATH"
export SNOWFLAKE_PRIVATE_KEY_PASSPHRASE="$KEY_PASSPHRASE"
export SNOWFLAKE_PRIVATE_KEY="$(cat "$KEY_PATH")"
export TF_VAR_snowflake_account_identifier="${SNOWFLAKE_ORGANIZATION_NAME}-${SNOWFLAKE_ACCOUNT_NAME}"
export TF_VAR_allowed_cidrs='["<your-ip>/32"]'
# dbt Labs と HashiCorp へ利用統計を送信しない
export DO_NOT_TRACK=1
export CHECKPOINT_DISABLE=1
for layer in snow-infra aws; do terraform -chdir="terraform/${layer}" init; done
terraform -chdir=terraform/snow-infra apply
terraform -chdir=terraform/aws apply -target=module.dashboard_lambda.aws_ecr_repository.this
3. Lambda のイメージを build して push する
ECR_URL=$(terraform -chdir=terraform/aws output -raw ecr_repository_url)
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin "${ECR_URL%%/*}"
docker build --platform linux/arm64 -f lambda/Dockerfile -t "${ECR_URL}:latest" .
docker push "${ECR_URL}:latest"
Lambda はイメージを digest で参照します。イメージを作り直して push したら、terraform -chdir=terraform/aws apply を再実行すると Lambda のイメージが更新されます。
4. 残りのリソースを作る
terraform -chdir=terraform/aws apply
5. seed と gold のテーブルを作る
cd dbt
uv sync
uv run dbt build --profiles-dir .
cd ..
dbt build が終わると、gold 用の S3 バケットの _dbt/GOLD/ の下に、テーブルごとの data/ と metadata/ が作られます。
デプロイ後確認
ダッシュボードの表示
Lambda を実行して HTML を出力し、CloudFront の URL を確認します。
aws lambda invoke --function-name cm-kasama-iceberg-bi-dashboard /dev/stdout
terraform -chdir=terraform/aws output -raw dashboard_url
Lambda は {"status": "ok"} を返し、terraform output は CloudFront の URL を表示しました。
{"status": "ok"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
https://xxxxxxxxxxxxxx.cloudfront.net/
allowed_cidrs に含めた IP アドレスから CloudFront の URL を開くと、次のダッシュボードが表示されました。

許可していない IP アドレスから curl でリクエストすると、403 が返りました。
curl -s -o /dev/null -w "%{http_code}\n" https://xxxxxxxxxxxxxx.cloudfront.net/
403
ウェアハウスを使っていないことの確認
Lambda の service user のログイン回数と、クエリの実行回数を ACCOUNT_USAGE で確認します。LOGIN_HISTORY は反映まで最大 2 時間かかるので、Lambda の実行から時間をおいて実行します。
USE ROLE ACCOUNTADMIN;
SELECT
(SELECT COUNT(*) FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY
WHERE USER_NAME = 'CM_KASAMA_ICEBERG_BI_DASHBOARD_SVC'
AND EVENT_TIMESTAMP >= DATEADD(DAY, -1, CURRENT_TIMESTAMP())) AS login_count,
(SELECT COUNT(*) FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE USER_NAME = 'CM_KASAMA_ICEBERG_BI_DASHBOARD_SVC'
AND START_TIME >= DATEADD(DAY, -1, CURRENT_TIMESTAMP())) AS all_queries,
(SELECT COUNT(WAREHOUSE_NAME) FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE USER_NAME = 'CM_KASAMA_ICEBERG_BI_DASHBOARD_SVC'
AND START_TIME >= DATEADD(DAY, -1, CURRENT_TIMESTAMP())) AS warehouse_queries;

Lambda の service user のログインは 10 回記録されていますが、クエリの実行は 0 件でした。
最後に
今回はダッシュボード用のテーブルを Snowflake-managed Iceberg テーブルにして、Lambda 上の dbt Charts と DuckDB でダッシュボードを出力してみました。フィルターを操作したり、開くたびに最新のデータを表示したりする構成にしたい場合は、ECS などで dct serve を動かし、操作ごとに DuckDB でクエリを実行します。BI ツールの抽出のようにデータを取り込む運用ではウェアハウスが起動するのは抽出の更新時だけなので、この構成で減らせるコストは小さくなります。今回の構成は検証用のサンプルなので、本番で使う場合は権限や運用の要件に合わせて見直してください。参考になれば幸いです。






