stable株式会社さんが主催されている、データエンジニアリングもくもく会に参加しました。
どんなテーマで勉強しようかと思ったのですが、Iceberg について業務で触れることが少ないので、Iceberg + BigQuery の機能について調べることにしました。BigQuery の Iceberg マネージドテーブル(BigLake Iceberg tables in BigQuery) を使い、既存の BigQuery テーブルを Iceberg 形式で GCS に出力して DuckDB から読む、という検証をしました。
「BigQuery で書き込みつつ、DuckDB や Spark などの外部エンジンからも読める」という触れ込みの機能ですが、実際に触ってみると、通常の Iceberg とはスナップショットの扱いが大きく異なることがわかりました。先に結論をまとめます:
- Iceberg マネージドテーブルの書き込みは BigQuery 専用
EXPORT TABLE METADATAを実行するまで、外部エンジンからは一切読めない。GCS 上にあるのはプレースホルダだけで、Iceberg 仕様のメタデータですらない- EXPORT で書き出されるメタデータは常に「現在のスナップショット1件のみ」。過去のスナップショットへの参照を持たないため、外部エンジンからのタイムトラベルは原理的にできない(当然ですが、BigQuery 内部でのタイムトラベルは可能)
Apache Iceberg とは
Apache Iceberg は、大規模な分析用データセットのためのオープンなテーブルフォーマットです。GCS や S3 上の Parquet ファイル群に「テーブル」としての一貫性を与えるメタデータレイヤーで、主な特徴は以下です。
- ACID なコミット: 書き込みはスナップショット単位でアトミックに反映される
- タイムトラベル: 過去のスナップショット時点のデータを再現できる
- スキーマ進化: カラム追加・リネーム・型の拡張をデータ書き換えなしで行える
- 隠しパーティショニング:
month(order_date)のような変換パーティションを定義でき、クエリ側はパーティション列を意識しなくてよい - マルチエンジン: Spark、Trino、Flink、DuckDB など、Iceberg を理解するエンジンならどれでも同じテーブルを読める
特定のクエリエンジンにデータを閉じ込めない、というのが最大の価値だと理解しています。
Iceberg のファイル構造
Iceberg テーブルの実体は、階層化されたメタデータとデータファイルです。今回の検証でもこの構造がそのまま GCS 上に現れるので、先に整理しておきます。

統合カタログの Iceberg Open Table Format (プレビュー) のデータ品質 | Microsoft Learn
| レイヤー | 役割 |
|---|---|
| Catalog | 「このテーブルの最新 metadata JSON はどれか」というポインタを管理し、書き込み時のアトミックな差し替えを保証する |
| Metadata File | テーブル定義の中心。スキーマ、パーティション仕様、スナップショット一覧、現在のスナップショット ID を持つ |
| Manifest List | 1スナップショット = 1 manifest list。そのスナップショットを構成する manifest の一覧 |
| Manifest File | データファイルへのインデックス。ファイルパスに加えカラムごとの min/max 統計を持ち、クエリエンジンはこれで不要なファイルを読み飛ばす(プルーニング) |
| Data File | 実データ(Parquet など) |
スナップショットの構造(本来の Iceberg)
- データを変更するコミット(append / overwrite / delete / compaction)が成功するたびに、新しいスナップショットが1つ作られる
- 新しいスナップショットは
parent-snapshot-idで直前のスナップショットを指し、metadata JSON のsnapshots配列に過去のスナップショットも蓄積されていく - スナップショットはデータのコピーではなく manifest へのポインタ集合。変更のなかったデータファイルはスナップショット間で共有されるため、非常に軽量
- この履歴の連鎖がタイムトラベル(
snapshot-idや時刻を指定した過去データの参照)を支えている
snapshots: [
{ snapshot-id: 111, parent: null, operation: append },
{ snapshot-id: 222, parent: 111, operation: append },
{ snapshot-id: 333, parent: 222, operation: delete } ← current
]
BigQuery における Iceberg テーブルは2種類
検証を始める前にまず引っかかったのがここでした。BigQuery で「Iceberg」と名の付くテーブルには2種類あり、性質がまったく違います。
| Iceberg 外部テーブル | Iceberg マネージドテーブル | |
|---|---|---|
| 正式名称 | BigLake external tables for Apache Iceberg | BigLake Iceberg tables in BigQuery |
| 作成方法 | CREATE EXTERNAL TABLE で既存の Iceberg メタデータを参照 |
CREATE TABLE ... OPTIONS(table_format='ICEBERG') |
| 書き込み | 読み取り専用 | BigQuery から INSERT / UPDATE / DELETE 可能 |
| ユースケース | Spark 等が管理する既存 Iceberg テーブルを BigQuery から読む | BigQuery で書き込み・管理し、外部エンジンにも公開する |
当初は「Iceberg 形式の外部テーブルを作ってロードする」つもりでいたのですが、外部テーブルは読み取り専用で INSERT できません。「BigQuery のテーブルを Iceberg 形式で GCS に出力する」という今回の目的には、書き込みができるマネージドテーブルを利用しました。
検証
環境
※ プロジェクト名・テーブル名などはすべて架空のものに置き換えています。
- ソース:
my-project-prod.sales_mart.order_item(通常の BigQuery テーブル、約3,300万行・論理サイズ約19.3GB・70カラム) - 作成先:
my-project-dev.sandbox.order_item(Iceberg マネージドテーブル) - 出力先バケット:
gs://my-iceberg-bucket - 読み手: DuckDB v1.5.5(iceberg extension)
事前の注意点として、データセット・バケット・接続の3つのロケーションを一致させる必要があります(今回はすべて asia-northeast1 に揃えました)。
Step 1. Cloud Resource 接続の作成
BigQuery が GCS に書き込むための「接続」を作成します。
bq mk --connection \ --project_id=my-project-dev \ --location=asia-northeast1 \ --connection_type=CLOUD_RESOURCE \ iceberg-conn
接続を作るとサービスアカウント(bqcx-<プロジェクト番号>-xxxx@gcp-sa-bigquery-condel.iam.gserviceaccount.com 形式)が発行されるので、バケットへの権限を付与します。
gcloud storage buckets add-iam-policy-binding gs://my-iceberg-bucket \ --member="serviceAccount:bqcx-xxxx@gcp-sa-bigquery-condel.iam.gserviceaccount.com" \ --role="roles/storage.objectAdmin"
Step 2. Iceberg マネージドテーブルの作成
通常の CREATE TABLE に、接続と Iceberg 用オプションを付けるだけです。
CREATE TABLE `my-project-dev.sandbox.order_item` ( order_item_code STRING, order_code STRING, ... ) WITH CONNECTION `my-project-dev.asia-northeast1.iceberg-conn` OPTIONS ( file_format = 'PARQUET', table_format = 'ICEBERG', storage_uri = 'gs://my-iceberg-bucket/order_item' );
Step 3. データのロード
INSERT INTO `my-project-dev.sandbox.order_item` SELECT * FROM `my-project-prod.sales_mart.order_item`;
約3,300万行のロードは数秒で完了しました。GCS を見ると Parquet ファイルが 500 個生成されており、合計サイズは約3.6GB。BigQuery 上の論理サイズ 19.3GB がここまで小さくなっていました。
gs://my-iceberg-bucket/order_item/data/....parquet × 500 gs://my-iceberg-bucket/order_item/metadata/v0.metadata.json
Step 4. この時点で DuckDB から読んでみる → 読めない
metadata フォルダには v0.metadata.json があるので、一見読めそうです。DuckDB で試してみると:
Invalid Input Error: TableMetadata required property 'format-version' is missing
エラーになりました。v0.metadata.json の中身を見てみると:
{"properties":{"bigquery-table-id":"my-project-dev.sandbox.order_item"},"current-snapshot-id":-1}
Iceberg 仕様のメタデータですらなく、単なるプレースホルダでした。つまり EXPORT 前の状態は「古いデータが見える」のではなく、GCS に 3.6GB の Parquet があっても、それをテーブルとして解釈する手がかりが一切ない状態です。メタデータの正体は BigQuery 内部にあり、GCS 側はまだ空箱、ということのようです。
Step 5. EXPORT TABLE METADATA を実行
外部エンジンに公開するには、明示的にメタデータをエクスポートする必要があります。
EXPORT TABLE METADATA FROM `my-project-dev.sandbox.order_item`;
これで metadata フォルダに本物の Iceberg メタデータが生成されました。
metadata/ ├── v0.metadata.json # プレースホルダ(残る) ├── v1784942316.metadata.json # 本物のメタデータ (format-version: 2) ├── xxxx-f-manifest-list-....avro # manifest list ├── xxxx-f-....avro # manifest └── version-hint.text # 最新バージョン番号を指すヒントファイル
version-hint.text は「最新の metadata JSON はどれか」を外部エンジンに教えるためのファイルで、カタログを使わないファイルシステムベースの運用(旧 HadoopCatalog 方式)の慣習です。
ちなみにこの EXPORT、デフォルトでは毎回手動です。自動化したい場合は
- (a) scheduled query で定期実行する
- (b) Google に申請してテーブル変更ごとの auto-refresh を有効にしてもらう
- (c) dbt の post-hook などロードパイプラインの最終ステップに組み込む
という選択肢があります
Step 6. EXPORT 後、DuckDB から読む
今回はテーブルフォルダごとローカルにコピーして読みました(DuckDB から GCS を直接読むには HMAC キーという別のクレデンシャルが必要なため)。
INSTALL iceberg; LOAD iceberg; SELECT count(*) FROM iceberg_scan('order_item', allow_moved_paths=true); -- 33,158,251 行 (BigQuery と完全一致)
集計クエリも問題なく動き、NUMERIC(decimal)や DATE / DATETIME 型もそのまま扱えました。
ここで少しハマったのが allow_moved_paths=true です。これを付けないと:
IO Error: ... HTTP GET to 'https://storage.googleapis.com/my-iceberg-bucket/...'
と、GCS へ直接アクセスしようとして失敗します。metadata JSON 内のファイルパスは gs:// の絶対パスで記録されているため、ローカルコピーで読むには「絶対パスを無視してテーブルルートからの相対位置で解決する」このオプションが必要でした。
Step 7. もう一度 EXPORT してみる → スナップショット履歴が繋がらない
データを変更せずにもう一度 EXPORT TABLE METADATA を実行し、新旧の metadata JSON を比較してみました。
| 1回目 | 2回目 | |
|---|---|---|
| snapshots 配列 | 1件のみ | 1件のみ |
| snapshot-log | 自分自身の1件のみ | 自分自身の1件のみ |
| 過去スナップショットへの参照 | なし | なし(1回目の存在に触れていない) |
データを更新していないのに、2回目の EXPORT は新しいスナップショット ID を発行し、しかも1回目のスナップショットを履歴として引き継ぎません。本来の Iceberg なら「書き込みがなければ新しいスナップショットは生まれない」「履歴は snapshots 配列に蓄積される」ので、どちらも通常とは異なる挙動です。
検証結果のまとめ
| 観点 | 通常の Iceberg | BigQuery マネージドテーブル |
|---|---|---|
| スナップショット作成 | 書き込みコミットごとに自動 | 内部では管理されるが、GCS へは EXPORT 時に合成 |
| スナップショット履歴 | snapshots 配列に蓄積、parent で連鎖 | 常に最新1件のみ、親リンクなし |
| 外部エンジンからのタイムトラベル | 可能 | 不可能 |
| BigQuery 内でのタイムトラベル | — | 可能(内部メタデータ、デフォルト7日) |
| 書き込み | 任意の対応エンジン(カタログが調停) | BigQuery のみ |
公式ドキュメントでも、タイムトラベルは "Time travel for historical data access in BigQuery" と BigQuery 内に限定して記載されており、外部エンジン向けには「最新スナップショットへの直接クエリアクセス」だけが謳われています。さらに、タイムトラベル期間(デフォルト7日)を過ぎた古いデータファイルは GCS 上からガベージコレクションされるため、「古い metadata JSON を手元に保存しておいて過去を読む」という運用も成立しません。
良かった点・気になった点
良かった点
- 既存の BigQuery テーブルからの移行は「接続作成 → CREATE TABLE → INSERT」だけで、拍子抜けするほど簡単でした
- 19.3GB → 3.6GB という Parquet 圧縮の効果が体感できた
- BigQuery 課金なしで外部エンジン(Spark / DuckDB / Databricks 等)に大量データを読ませたい、という用途にはよく合いそうです
気になった点
- EXPORT を忘れると外部からは何も見えないのに、GCS 上には一見それらしいファイルが並ぶので気づきにくい
- スナップショット履歴が外部に公開されないため、外部エンジンからのタイムトラベルなど「Iceberg 本来のフル機能」を期待すると肩透かしを食う
- 外部エンジンからの書き込みもできないため、あくまで「BigQuery が主、外部エンジンは読み取り専用の従」という関係になる
総じて、BigQuery の Iceberg マネージドテーブルは「書き込みと管理は BigQuery が握り、外部エンジンには最新状態の静止画だけを公開する」という設計だとわかりました。「Iceberg フォーマットで保存されている」ことと「Iceberg のフル機能が使える」ことは別物、というのが今回一番の学びです。
Iceberg 本来のスナップショット履歴やスキーマ進化はまだ体験できていないので、次回は PyIceberg + ローカルカタログで自分がフルコントロールできるテーブルを作り、タイムトラベルやスキーマ進化を試したいと思います。














