レイクハウスのテーブル形式 — Apache Iceberg をメタデータで理解する
テーブルを作り、metadata.json からデータファイルまでたどる
目標
Sparkでテーブルを作成して1日分の注文を2回コミットしたあと、カタログ → metadata.json → マニフェストリスト → マニフェスト → データファイルとつながるツリーを、ツールを使わず手でたどって下ります。各層で見つけた値をファイルに書き出すと、採点ツールがその値を実際のメタデータと比較します。
なぜ重要なのか
Icebergのテーブルは「ディレクトリ」ではなく「ファイルのリスト」です。読み取る側は、ディレクトリを走査せず、metadata.jsonが指すリストだけを信頼します。そのため、コミットはファイルを移す作業ではなく、新しいリストを書いておいてカタログのポインター1つを切り替える作業であり、その1か所が切り替わった瞬間に、読み取る人全員が新しい状態を見ます。 この構造を一度手でたどってみると、あとのすべての機能が同じ原理の変奏だとわかります。タイムトラベルは古いスナップショットのリストを読むことであり、ロールバックはポインターを古いスナップショットに戻すことであり、コンパクションと期限切れ処理はリストを書き直して、もう誰も指さなくなったファイルを削除することです。 障害が起きたときに最初に見るのもこのツリーです。「なぜこの行が見えないのか」は、ほとんどの場合「そのファイルは現在のスナップショットのリストにあるか」という問いに置き換わります。
ステップ
- /root/ice/meta/create.py(アプリ
ice-meta-create)で、ネームスペースlake.metaとテーブルlake.meta.ordersを作成してください。列はorder_id STRING, customer_id STRING, region STRING, amount INT, status STRING, order_ts TIMESTAMP、プロパティは'format-version' = '2'です。 - /root/ice/meta/load.pyが日付を1つ引数に取り、
/data/ice/orders/<날짜>.csv(プレースホルダーは日付です)をテーブルに1回コミットするようにして、2026-03-01を渡してください。 - 同じスクリプトに
2026-03-02を渡して、スナップショットを2つにしてください。 - カタログ
/root/ice/catalog.dbのiceberg_tablesから、このテーブルのmetadata_locationとprevious_metadata_locationを読み取り、/root/ice/meta/out/pointer.txtに2行で書いてください。 - 現在のmetadata.jsonからスナップショットの一覧を取り出し、/root/ice/meta/out/snapshots.jsonに書いてください。
- 現在のスナップショットのマニフェストリスト(Avro)をデコードして、マニフェストの一覧を作り、/root/ice/meta/out/manifests.jsonに書いてください。
- それらのマニフェストをデコードして、現在有効なデータファイルの一覧を作り、/root/ice/meta/out/datafiles.jsonに書いてください。
- /root/ice/meta/report.mdに、
## 포인터、## 스냅샷、## 파일の3つの節を書いてください(3つの見出しは順に、韓国語で「ポインター」「スナップショット」「ファイル」を意味する語です)。2つ目の節にはスナップショットの数を、3つ目の節にはマニフェストの数とデータファイルの数を入れてください。
参考
- スクリプトは
cd /root/ice/meta && spark-submit create.pyのように実行します。Sparkが起動するのに15–20秒かかります。 ice-loc meta.ordersは、カタログが指す現在のmetadata.jsonのパスを出力します。ice-avro <파일>(プレースホルダーはファイル名です)は、AvroをJSONに1行ずつ変換します(jqで絞り込んでください)。- パスの先頭の
file:はJavaが、file:///はPythonが付けます。採点ツールは、どちらも同じパスとして扱います。 - よくある間違いは、
load.pyを同じ日付で2回実行して、スナップショットが3つになることです。最初からやり直すには、spark-sql -e "DROP TABLE lake.meta.orders PURGE"を実行してから、ステップ1に戻ってください。書き留めた値はすべて、新しいテーブルから取り直す必要があります。 - 公式ドキュメント: Table Spec — Overview・Spark Getting Started・JDBC Catalog
テーブルの作成: スナップショットのない最初のmetadata
/root/ice/meta/create.pyをアプリ名ice-meta-createで作成し、lake.metaネームスペースとlake.meta.ordersテーブル(列は6つ、'format-version' = '2')を作って、spark-submitで実行してください。
テーブルを作成すると、metadataファイル(00000-….metadata.json)が1つできて、カタログにそのパスが1行入ります。まだコミットしたデータがないので、スナップショットはありません。採点ツールは、最初のmetadataファイルにスナップショットがないこと、フォーマットバージョンと列名・型が合っていることを確認します。
最初のコミット: スナップショット1つ
/root/ice/meta/load.pyをアプリ名ice-meta-loadで作成し、引数で受け取った日付の/data/ice/orders/<날짜>.csv(プレースホルダーは日付です)を、スキーマを指定して読み込み、writeTo("lake.meta.orders").append()で書き込むようにしてください。spark-submit load.py 2026-03-01で実行してください。
appendを1回行うとコミットが1回になり、コミット1回がスナップショット1つになります。採点ツールは、最初のスナップショットが親なしのappendで作られていること、要約(summary)のadded-recordsが当日のファイルの行数と一致していることを確認します。
2回目のコミット: 親を指すスナップショット
同じスクリプトでspark-submit load.py 2026-03-02を実行して、スナップショットをちょうど2つにしてください。
新しいスナップショットは、前のスナップショットを親(parent-snapshot-id)として指し、シーケンス番号が1つ増えます。最初のスナップショットのファイルは書き直されず、新しいファイルが1つ加わるだけです。同じ日付を2回入れてしまった場合は、テーブルをPURGEで削除して、ステップ1からやり直してください。
カタログのポインター2つ
/root/ice/catalog.dbのiceberg_tablesからmeta.orders行のmetadata_locationとprevious_metadata_locationを読み取り、/root/ice/meta/out/pointer.txtの1行目と2行目に書いてください。
JDBCカタログには、テーブルごとに1行しかありません。コミットは「現在の値が自分の読んだ値と同じときだけ新しい値に書き換える」という条件付きUPDATEで、書き換える前の値がpreviousの列に残ります。sqlite3 -separatorで、2つの列を2行にして出力できます。
metadata.jsonのスナップショット一覧
/root/ice/meta/out/snapshots.jsonを、現在のmetadata.json(ice-loc meta.orders)から{"current_snapshot_id": 정수, "snapshots": [{"snapshot_id", "parent_snapshot_id", "sequence_number", "manifest_list"}, …]}の形(プレースホルダーは整数です)で作成してください。
metadata.jsonのキーはハイフンを使います(current-snapshot-id、parent-snapshot-id)。jqでは.["snapshot-id"]のように角括弧で読みます。スナップショットIDは19桁の整数なので、手で写すと間違えやすいです。jqでそのまま写してください。
マニフェストリスト: マニフェストの一覧
/root/ice/meta/out/manifests.jsonに、現在のスナップショットのmanifest_listファイルをice-avroでデコードした結果を、[{"manifest_path", "added_snapshot_id", "added_files_count", "existing_files_count"}, …]の配列で書いてください。
2つ目のスナップショットのマニフェストリストには、最初のコミットが作ったマニフェストがそのまま入っています。新しいコミットは、古いマニフェストを書き直さず、指すだけです。だからコミットは安価で、古いスナップショットがそのまま残ります。added_snapshot_idで、どのコミットが作ったマニフェストかがわかります。
マニフェスト: データファイルと行数
/root/ice/meta/out/datafiles.jsonに、manifests.jsonのマニフェストをデコードして、statusが2(DELETED)ではないエントリのデータファイルを[{"file_path", "record_count"}, …]の形で書いてください。
マニフェストの1行(エントリ)は、status(0がEXISTING、1がADDED、2がDELETED)とdata_file構造体です。読み取るエンジンは、この一覧と列統計(下限・上限)だけを見て、どのファイルを開くかを決めます。ディレクトリは走査しません。採点ツールは、一覧が現在のスナップショットの有効なファイルとぴったり一致しているか、行数の合計がテーブルの行数と一致しているかを確認します。
ツリーを1枚にまとめたレポート
/root/ice/meta/report.mdに、## 포인터、## 스냅샷、## 파일の3つの節を書いてください(3つの見出しは順に、韓国語で「ポインター」「スナップショット」「ファイル」を意味する語です)。2つ目の節にはスナップショットの数を、3つ目の節には現在のスナップショットのマニフェストの数とデータファイルの数を、数字で入れてください。
誰かが「昨日入れたデータが見えない」と言ったとき、どの層から確認するかを順番に書いてみてください。数字は、自分のout/ファイルから写します。