Apache Iceberg + DuckDB:クラウド不要でローカルData Lakehouseを構築し、Time Travel Queryを実現する

Database tutorial - IT technology blog
Database tutorial - IT technology blog

実際の問題:ETLパイプライン実行後、元データが消える

こんな経験をしたことがある。ETLパイプラインが午前2時に動き出し、salesデータをtransformして既存のParquetファイルを上書きした。翌朝、analyticsチームから先月のレポート結果が突然変わったとクレームが来た。ログを調べても原因がわからない——元データが完全に上書きされていたからだ。

これは珍しいケースではない。中規模のパイプラインでも、jobがParquetを上書きするたびにスナップショットの履歴が完全に失われる——事前に手動バックアップしない限り、ロールバックする方法がない。多くのチームは日付でファイル名をつけて対応している:sales_2024_11_01.parquet、sales_2024_11_02.parquet……だがこの方法はストレージを食い、クエリが複雑になり、それでもACID保証がない。

なぜ純粋なParquetでは不十分なのか?

Parquetはanalyticsワークロードに非常に優れている——カラム型ストレージ、高い圧縮率、高速な読み取り。しかし、Parquetは純粋なファイルフォーマットであり、transactionやversioningという概念がない。Parquetファイルを上書きすると:

  • 変更履歴がない(audit trail)
  • 処理途中でjobが失敗してもrollbackできない
  • Schema evolutionが難しい——カラムの追加・削除でdownstreamのパイプラインが壊れやすい
  • 複数jobによる同時書き込みがrace conditionやdata corruptionを引き起こす

Parquetが悪いわけではない——設計された目的を正しく果たしている。足りないのは、その上に乗るmetadata管理とtransactionの層だ。

Apache Iceberg + DuckDBはどう解決するか

Icebergはまさにそのギャップを埋めるために生まれた。内部はParquetのままだが、Icebergはその上に4つのコア機能を持つmetadata管理レイヤーを追加する:

  • Snapshot-based versioning:書き込みのたびに新しいsnapshotを作成し、古いsnapshotは削除しない
  • ACID transactions:同時書き込みによるデータ破損を心配しなくてよい
  • Schema evolution:データ全体を書き直さずにカラムの追加・削除・リネームが可能
  • Time Travel:過去の任意の時点のデータをクエリできる

DuckDBはquery engineとして機能する:軽量で高速、ラップトップ上ですぐに動く。バージョン0.10+から、extension icebergがIceberg tablesの直接読み取りを公式サポートしている。スタック全体がローカル——AWSもHive Metastoreも不要だ。

環境セットアップ

Python 3.9+さえあれば十分で、他のサービスへの依存はない。

# virtual environmentを作成
python -m venv iceberg-env
source iceberg-env/bin/activate

# PyIcebergとDuckDBをインストール
pip install "pyiceberg[pyarrow,duckdb]" duckdb pandas pyarrow

インストール後にバージョンを確認:

import pyiceberg
import duckdb
print(pyiceberg.__version__)  # >= 0.7.0
print(duckdb.__version__)     # >= 0.10.0

はじめてのIceberg CatalogとTableを作成する

PyIcebergはmetadataを管理するためにcatalogが必要だ。ローカル環境では、SqlCatalogを使ってmetadataをSQLiteに保存する——追加のサービスセットアップは一切不要だ。

from pyiceberg.catalog.sql import SqlCatalog
import pyarrow as pa
import os

os.makedirs("warehouse/data", exist_ok=True)

catalog = SqlCatalog(
    "local",
    **{
        "uri": "sqlite:///warehouse/catalog.db",
        "warehouse": "file://warehouse",
    }
)

catalog.create_namespace("analytics")

schema = pa.schema([
    pa.field("order_id", pa.int64()),
    pa.field("customer_id", pa.int64()),
    pa.field("amount", pa.float64()),
    pa.field("status", pa.string()),
    pa.field("created_at", pa.timestamp("us")),
])

table = catalog.create_table("analytics.orders", schema=schema)
print("Table created:", table.identifier)

データを書き込みSnapshotを作成する

import pandas as pd
from datetime import datetime, timedelta

# Batch 1: 昨日のデータ
df1 = pd.DataFrame({
    "order_id": [1, 2, 3],
    "customer_id": [101, 102, 103],
    "amount": [150.0, 230.5, 89.99],
    "status": ["completed", "pending", "completed"],
    "created_at": [datetime.now() - timedelta(days=1)] * 3
})

table.append(pa.Table.from_pandas(df1))
snapshot_1 = table.current_snapshot().snapshot_id
print(f"Snapshot 1 ID: {snapshot_1}")

# Batch 2: 新しい注文を追加
df2 = pd.DataFrame({
    "order_id": [4, 5],
    "customer_id": [104, 105],
    "amount": [320.0, 45.0],
    "status": ["completed", "refunded"],
    "created_at": [datetime.now()] * 2
})

table.append(pa.Table.from_pandas(df2))
snapshot_2 = table.current_snapshot().snapshot_id
print(f"Snapshot 2 ID: {snapshot_2}")

この時点でIcebergは2つのsnapshotを保持している。snapshot 1の元データはディスク上にそのまま残り、削除も上書きもされない——これこそが純粋なParquetにはできないことだ。

DuckDBによるTime Travel Query

いよいよ最も価値ある部分だ。DuckDBはIceberg tableを直接読み取れる。特定のsnapshotを指定したクエリも同様だ:

import duckdb

conn = duckdb.connect()
conn.execute("INSTALL iceberg; LOAD iceberg;")

# 現在の状態をクエリ — 5件の注文すべて
result = conn.execute("""
    SELECT * FROM iceberg_scan('warehouse/analytics/orders')
    ORDER BY order_id
""").fetchdf()
print("Current state (5 rows):")
print(result)

# Time Travel — snapshot 1をクエリ(最初の3件のみ)
result_v1 = conn.execute(f"""
    SELECT * FROM iceberg_scan(
        'warehouse/analytics/orders',
        snapshot_id = {snapshot_1}
    )
    ORDER BY order_id
""").fetchdf()
print(f"\nSnapshot {snapshot_1} — 3 rows only:")
print(result_v1)

午前2時のパイプラインが誤って上書きしても、直前のsnapshotをクエリすれば元データがすぐに取れる——バックアップのリストアも、ダウンタイムも不要だ。

Schema Evolution — 既存のパイプラインを壊さずカラムを追加する

from pyiceberg.types import FloatType

# schemaにdiscount_pctカラムを追加
with table.update_schema() as update:
    update.add_column("discount_pct", FloatType())

# 古いsnapshotは引き続き読み取れる — 新しいカラムは古いデータにNULLを返す
result = conn.execute("""
    SELECT order_id, amount, discount_pct
    FROM iceberg_scan('warehouse/analytics/orders')
""").fetchdf()
print(result)
# discount_pct = NaN(5件の古い注文)— 完全に正常な動作

純粋なParquetでは、このようにカラムを追加すると古いsnapshotのreaderがschemaの不一致で壊れることが多い。Icebergは自動で処理する——新しいカラムは古いデータにNULLを返し、古いreaderは壊れない。

実戦から学んだTipsとBest Practices

1. 最初からsnapshotのretention policyを設定する

Snapshotは時間とともに積み重なり、ディスクを消費する。定期的にexpireさせるべきだ:

from datetime import datetime, timedelta

# 直近7日間のsnapshotを保持する
table.expire_snapshots(
    older_than_ms=int((datetime.now() - timedelta(days=7)).timestamp() * 1000)
).commit()
print("Expired old snapshots")

2. クエリを高速化するために時間でテーブルをpartitionする

table作成時にpartitionを宣言する(Iceberg v1では後から追加できない):

from pyiceberg.partitioning import PartitionSpec, PartitionField
from pyiceberg.transforms import DayTransform

# source_id=5はschema内のcreated_atカラムのインデックス
spec = PartitionSpec(
    PartitionField(
        source_id=5,
        field_id=1000,
        transform=DayTransform(),
        name="created_day"
    )
)

# table作成時に宣言する
table = catalog.create_table(
    "analytics.orders_partitioned",
    schema=schema,
    partition_spec=spec
)

DuckDBはファイル全体を読み込む代わりに、必要なpartitionだけをスキャンする。100GBのテーブルでは、特定の1日分のクエリがfull scanより20〜50倍速くなることもある。

3. ingest前にソースデータを正規化する

複数のソースからデータを受け取る際、Icebergに投入する前にCSVをJSONに変換して正規化する必要があることがよくある。10MB未満の小さなファイルであれば、ブラウザ上のコンバーターをtoolcraft.app/ja/tools/data/csv-to-jsonで使っている——クライアントサイドで動くため機密データが漏れる心配がなく、ソースごとにone-offスクリプトを書くより遥かに便利だ。

4. 複雑なtransformにはdbtと組み合わせる

DuckDBにはdbt用の公式adapter(dbt-duckdb)がある。スタック全体が完全ローカル:Iceberg storage + DuckDB compute + dbt transforms = クラウドコストゼロのmini data warehouse。小規模チームやproductionにスケールアップする前のproof-of-conceptに最適だ。

始めるための最善のアプローチ

Parquetファイルでdata pipelineを管理していて、データを失ったり履歴を追えなかった経験がある人に、Icebergへの段階的な移行を勧める:

  1. 最もcriticalでないテーブルから始め、dev環境でTime Travelをテストする
  2. ローカル環境ではSqlCatalogとSQLiteを使う——複雑なinfraは不要
  3. 最初からexpire_snapshotsポリシーを設定し、後回しにしない
  4. 慣れてきたら、必要に応じてREST CatalogやAWS Glue上のproductionへmigrationする

Icebergはあらゆる用途に適しているわけではない——数百万rows以下の小さなテーブルでは、metadata管理のオーバーヘッドに見合わない。しかし、データが数十GBに達したとき、またはaudit trailが必須のとき(金融、コンプライアンス)、これは自分が使ってきた中で最善の選択肢だ。このstackに移行してから、パイプラインのデバッグで夜を明かすことがなくなった。

Share: