Amazon Athena で Apache Iceberg を試して内部構造を解剖してみた

Amazon Athena で Apache Iceberg を試して内部構造を解剖してみた

2026.01.28

はじめに

コンサルティング部のぐっさんです。

前回の記事では Athena を使って CSV と Parquet のスキャン量を比較し、Parquet の列指向フォーマットの効率性を確認しました。

https://dev.classmethod.jp/articles/amazon-athena-csv-parquet-scan/

今回は、Parquet などのデータファイルを束ねて管理するテーブルフォーマットである Apache Iceberg について、Athena で実際に触りながら学んでいきます。

一言まとめ

Apache Iceberg は Parquet などのファイル形式の上に構築されるテーブルフォーマットで、ACID トランザクション、タイムトラベル、スキーマ進化などの機能を提供します。データレイクを RDB のように操作できるようにするための技術です。


Apache Iceberg とは

ファイル形式 vs テーブルフォーマット

前回までの記事で扱った Parquet は「ファイル形式」です。1つ1つのファイルがどのようにデータを格納するかを定義しています。

一方、Iceberg は「テーブルフォーマット」です。複数のファイルをどのようにテーブルとして管理するかを定義しています。

なぜ Iceberg が必要なのか

S3 + Parquet だけでも分析は可能ですが、以下のような課題があります。Iceberg はこれらを解決します。

課題 S3 + Parquet のみ Iceberg
データ更新 ファイル全体を書き換えが必要 行レベルで UPDATE/DELETE が可能
同時書き込み 競合の可能性あり ACID で整合性を保証
スキーマ変更 全ファイル再作成が必要な場合も スキーマ進化をサポート
過去データ参照 別途バックアップが必要 タイムトラベルで参照可能

Iceberg の内部構造

Iceberg は Metadata LayerData Layer の2層でデータを管理しています。

iceberg-metadata

引用元: Apache Iceberg Spec - Overview

Iceberg Catalog

要素 役割
Catalog テーブル名と現在のメタデータファイルへのポインタを管理

Metadata Layer

要素 役割
Metadata File スキーマ、パーティション設定、スナップショット定義(ID、タイムスタンプ、Manifest List パス)を管理
Manifest List Manifest File のパス・サイズ・統計サマリーの一覧
Manifest File データファイルのパスとファイル単位の統計情報(min/max、行数、null数など)

Data Layer

要素 役割
Data Files 実際のデータ(Parquet/ORC/Avro)

この構造によって、データレイクにおいて以下のような機能を実現しています。

  • スナップショット - Catalog のポインタを切り替えるだけで過去の状態を参照できる
  • 効率的なクエリ - Manifest の統計情報でファイルをスキップできる
  • ACID トランザクション - 新しい Metadata File を作成し、最後に Catalog を更新(アトミック)
  • 効率的な書き込み - Manifest File はスナップショット間で再利用される

Iceberg の主要機能

  1. ACID トランザクション - 複数の書き込み操作が同時に発生しても整合性を保証
  2. タイムトラベル - 過去の任意の時点のデータを参照可能
  3. スキーマ進化 - カラムの追加・削除・名前変更が柔軟に可能
  4. パーティション進化 - パーティション構成を後から変更可能
  5. 隠しパーティショニング - ユーザーがパーティションを意識せずにクエリ可能

では実際にデータを投入して、これらの一部機能を試してみましょう。

検証の流れ

  1. テストデータ(NYC タクシー乗車データ)を Python で作成
  2. CSV を S3 にアップロード
  3. Athena で CSV の外部テーブルを作成
  4. Iceberg テーブルを作成し、CSV からデータを投入
  5. Iceberg の機能(UPDATE、DELETE、タイムトラベル)を試す

検証データ

Python の seaborn ライブラリに含まれる NYC タクシー乗車データ(6,433件)を使用します。

データ構造

カラム 説明
pickup 乗車日時
dropoff 降車日時
passengers 乗客数
distance 距離(マイル)
fare 運賃
tip チップ
tolls 通行料
total 合計金額
color タクシーの色(yellow/green)
payment 支払方法(credit card/cash 等)
pickup_zone 乗車ゾーン
dropoff_zone 降車ゾーン
pickup_borough 乗車区(Manhattan 等)
dropoff_borough 降車区

データ生成

# 仮想環境の作成・有効化
python -m venv .venv
source .venv/bin/activate

# 依存ライブラリのインストール
pip install -r requirements.txt

# データ生成スクリプトを実行
python generate_taxi_data.py

requirements.txtの中身

seaborn>=0.12.0

出力例

データ生成完了: 6433件

カラム一覧:
  - pickup: datetime64[ns]
  - dropoff: datetime64[ns]
  - passengers: int64
  - distance: float64
  - fare: float64
  - tip: float64
  - tolls: float64
  - total: float64
  - color: object
  - payment: object
  - pickup_zone: object
  - dropoff_zone: object
  - pickup_borough: object
  - dropoff_borough: object

ファイルサイズ:
  CSV: 869,349 bytes (849.0 KB)

先頭5件:
               pickup             dropoff  passengers  distance  fare   tip  tolls  total   color      payment            pickup_zone           dropoff_zone pickup_borough dropoff_borough
0 2019-03-23 20:21:09 2019-03-23 20:27:24           1      1.60   7.0  2.15    0.0  12.95  yellow  credit card        Lenox Hill West    UN/Turtle Bay South      Manhattan       Manhattan
1 2019-03-04 16:11:55 2019-03-04 16:19:00           1      0.79   5.0  0.00    0.0   9.30  yellow         cash  Upper West Side South  Upper West Side South      Manhattan       Manhattan
2 2019-03-27 17:53:01 2019-03-27 18:00:25           1      1.37   7.5  2.36    0.0  14.16  yellow  credit card          Alphabet City           West Village      Manhattan       Manhattan
3 2019-03-10 01:23:59 2019-03-10 01:49:51           1      7.70  27.0  6.15    0.0  36.95  yellow  credit card              Hudson Sq         Yorkville West      Manhattan       Manhattan
4 2019-03-30 13:27:42 2019-03-30 13:37:14           3      2.16   9.0  1.10    0.0  13.40  yellow  credit card           Midtown East         Yorkville West      Manhattan       Manhattan
  • generate_taxi_data.py の中身

データセットを読み込んでCSV化しているのみです!

import seaborn as sns
import os

# Taxi データセットを読み込み
df = sns.load_dataset('taxis')

# CSV で保存
df.to_csv('data/taxis.csv', index=False)

print(f"データ生成完了: {len(df)}件")
print(f"\nカラム一覧:")
for col in df.columns:
    print(f"  - {col}: {df[col].dtype}")
print(f"\nファイルサイズ:")
csv_size = os.path.getsize('data/taxis.csv')
print(f"  CSV: {csv_size:,} bytes ({csv_size/1024:.1f} KB)")
print(f"\n先頭5件:")
print(df.head())

S3 にアップロード

バケット作成

# バケット作成(バケット名は適宜変更してください)
aws s3 mb s3://your-bucket-name --region ap-northeast-1

データアップロード

# CSV をアップロード
aws s3 cp data/taxis.csv s3://your-bucket-name/csv/

確認

aws s3 ls s3://your-bucket-name/ --recursive

Athena でテーブル作成

データベース作成

クエリエディタでSQL文を実行し、作成します。

CREATE DATABASE IF NOT EXISTS taxi_db;

athea_1

CSV 外部テーブル作成

次に、CSV データを読み込むための外部テーブルを作成します。

CREATE EXTERNAL TABLE taxi_db.taxis_csv (
    pickup STRING,
    dropoff STRING,
    passengers INT,
    distance DOUBLE,
    fare DOUBLE,
    tip DOUBLE,
    tolls DOUBLE,
    total DOUBLE,
    color STRING,
    payment STRING,
    pickup_zone STRING,
    dropoff_zone STRING,
    pickup_borough STRING,
    dropoff_borough STRING
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION 's3://your-bucket-name/csv/'
TBLPROPERTIES ('skip.header.line.count'='1');

athena_2

データ確認

SELECT * FROM taxi_db.taxis_csv LIMIT 5;

athena_3

Iceberg テーブル作成

Icebergテーブルを作成します!
TBLPROPERTIES'table_type'='ICEBERG' を指定します。

CREATE TABLE taxi_db.taxis_iceberg (
    pickup STRING,
    dropoff STRING,
    passengers INT,
    distance DOUBLE,
    fare DOUBLE,
    tip DOUBLE,
    tolls DOUBLE,
    total DOUBLE,
    color STRING,
    payment STRING,
    pickup_zone STRING,
    dropoff_zone STRING,
    pickup_borough STRING,
    dropoff_borough STRING
)
LOCATION 's3://your-bucket-name/iceberg/'
TBLPROPERTIES (
    'table_type' = 'ICEBERG'
);

athena_4

作成直後に、S3を見るとメタデータファイルが自動で格納されていました。

s3_2

s3_3

テーブル作成直後のメタデータファイル(metadata.json)の中身を確認してみました。

{
  "format-version" : 2,
  "table-uuid" : "002bd9f3-4026-4f4b-a47f-ada7fd579281",
  "location" : "s3://your-bucket-name/iceberg",
  "last-sequence-number" : 0,
  "last-updated-ms" : 1768386618442,
  "last-column-id" : 14,
  "current-schema-id" : 0,
  "schemas" : [ {
    "type" : "struct",
    "schema-id" : 0,
    "fields" : [ {
      "id" : 1,
      "name" : "pickup",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 2,
      "name" : "dropoff",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 3,
      "name" : "passengers",
      "required" : false,
      "type" : "int"
    }, {
      "id" : 4,
      "name" : "distance",
      "required" : false,
      "type" : "double"
    }, {
      "id" : 5,
      "name" : "fare",
      "required" : false,
      "type" : "double"
    }, {
      "id" : 6,
      "name" : "tip",
      "required" : false,
      "type" : "double"
    }, {
      "id" : 7,
      "name" : "tolls",
      "required" : false,
      "type" : "double"
    }, {
      "id" : 8,
      "name" : "total",
      "required" : false,
      "type" : "double"
    }, {
      "id" : 9,
      "name" : "color",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 10,
      "name" : "payment",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 11,
      "name" : "pickup_zone",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 12,
      "name" : "dropoff_zone",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 13,
      "name" : "pickup_borough",
      "required" : false,
      "type" : "string"
    }, {
      "id" : 14,
      "name" : "dropoff_borough",
      "required" : false,
      "type" : "string"
    } ]
  } ],
  "default-spec-id" : 0,
  "partition-specs" : [ {
    "spec-id" : 0,
    "fields" : [ ]
  } ],
  "last-partition-id" : 999,
  "default-sort-order-id" : 0,
  "sort-orders" : [ {
    "order-id" : 0,
    "fields" : [ ]
  } ],
  "properties" : {
    "write.object-storage.enabled" : "true",
    "write.object-storage.path" : "s3://your-bucket-name/iceberg/data",
    "write.parquet.compression-codec" : "zstd"
  },
  "current-snapshot-id" : -1,
  "refs" : { },
  "snapshots" : [ ],
  "statistics" : [ ],
  "snapshot-log" : [ ],
  "metadata-log" : [ ]
}

ポイント

  • format-version: 2 - Iceberg v2 フォーマット
  • schemas - カラム定義(DDL で指定した内容)
  • current-snapshot-id: -1 - まだデータがない状態
  • snapshots: [] - スナップショットも空
  • write.parquet.compression-codec: zstd - データは Zstandard 圧縮で保存される

CSV から Iceberg へデータ投入

INSERT INTO taxi_db.taxis_iceberg
SELECT * FROM taxi_db.taxis_csv;

athena_5

s3_4

s3_5

S3 を確認すると、data フォルダが作成され、その中に Parquet ファイルが自動生成されています。元データは CSV でしたが、Iceberg テーブルに INSERT されると内部的には Parquet 形式で保存されます。

データ確認

件数を確認します(CSV と同じ 6,433 件であれば OK)。

SELECT COUNT(*) FROM taxi_db.taxis_iceberg;

athena_6

データの中身も確認してみましょう。

SELECT * FROM taxi_db.taxis_iceberg LIMIT 5;

athena_7

Iceberg テーブルは内部的に Parquet で保存されているため、CSV テーブルに比べてスキャン量が削減されています。前回の記事で検証した通り、列指向フォーマットの恩恵を受けられます。


Iceberg の機能を試す

1. UPDATE(行レベル更新)

通常の Parquet 外部テーブルでは UPDATE ができませんが、Iceberg では可能です。

例:cash 支払いを「cash」から「Cash」に統一

まず現在の支払方法を確認します。

SELECT DISTINCT payment FROM taxi_db.taxis_iceberg;

athena_8

credit cardcash、そして空欄(NULL)の 3 種類があることがわかります。実データには欠損もあるんですね。

UPDATE を実行します。

UPDATE taxi_db.taxis_iceberg
SET payment = 'Cash'
WHERE payment = 'cash';

athena_9

再度確認すると、cashCash に変わっています。

SELECT DISTINCT payment FROM taxi_db.taxis_iceberg;

athena_10

データの中身も確認してみましょう。Cash に更新されたデータを表示します。

SELECT * FROM taxi_db.taxis_iceberg
WHERE payment = 'Cash'
LIMIT 5;

athena_11

2. DELETE(行レベル削除)

特定の条件に合うデータを削除できます。

例:乗客数が 0 人のデータを削除(データ不備として)

まず件数を確認します。

SELECT COUNT(*) FROM taxi_db.taxis_iceberg WHERE passengers = 0;

athena_12

削除実行

DELETE FROM taxi_db.taxis_iceberg
WHERE passengers = 0;

athena_13

削除後の件数を確認します。

SELECT COUNT(*) FROM taxi_db.taxis_iceberg;

athena_14

6,337 件に減りました(6,433 - 96 = 6,337)。乗客数 0 人のデータが正常に削除されています。

3. タイムトラベル

Iceberg はすべての変更履歴をスナップショットとして保持しているため、過去の時点のデータを参照できます。

スナップショット一覧の確認

SELECT * FROM "taxi_db"."taxis_iceberg$snapshots"
ORDER BY committed_at;

athena_15

特定のスナップショットを参照

2番目のスナップショット(UPDATE 後・DELETE 前)を指定して、削除前のデータを参照してみます。

-- 2番目のスナップショット ID を指定して、DELETE 前のデータを参照
SELECT COUNT(*)
FROM taxi_db.taxis_iceberg
FOR VERSION AS OF <snapshot_id>;

athena_16

6,433 件が返ってくれば、DELETE 前の状態を参照できたことが確認できます。なお、これは過去データを「見ている」だけで、実際のテーブルは現在の状態(6,337件)のままです。

-- 現在のテーブルは変わっていない
SELECT COUNT(*) FROM taxi_db.taxis_iceberg;

athena_17

4. スキーマ進化

新しいカラムを追加してみます。まず現在のカラムを確認します。

DESCRIBE taxi_db.taxis_iceberg;

athena_18

中身はこんなかんじですね。

# Table schema:
# col_name            data_type    comment
pickup                string
dropoff               string
passengers            int
distance              double
fare                  double
tip                   double
tolls                 double
total                 double
color                 string
payment               string
pickup_zone           string
dropoff_zone          string
pickup_borough        string
dropoff_borough       string

# Partition spec:
# field_name    field_transform    column_name

カラムを追加します。

ALTER TABLE taxi_db.taxis_iceberg
ADD COLUMNS (is_weekend BOOLEAN);

athena_19

再度確認します。

DESCRIBE taxi_db.taxis_iceberg;

athena_20

is_weekendが仲間入りしていますね!

# Table schema:
# col_name            data_type    comment
pickup                string
dropoff               string
passengers            int
distance              double
fare                  double
tip                   double
tolls                 double
total                 double
color                 string
payment               string
pickup_zone           string
dropoff_zone          string
pickup_borough        string
dropoff_borough       string
is_weekend            boolean

# Partition spec:
# field_name    field_transform    column_name

S3 上のファイル構造

せっかくなのでIceberg テーブルが S3 上でどのように管理されているか確認してみましょう。
ここまでの検証でどれくらいファイルが溜まっているか・・・

aws s3 ls s3://your-bucket-name/iceberg/ --recursive
2026-01-14 19:38:43     130255 iceberg/data/KqwkNw/20260114_103841_00124_him48-70180cf1-e9b3-470e-8b0a-5159ffcc0000.parquet
2026-01-14 19:55:42       3059 iceberg/data/NAwi7w/20260114_105540_00124_az5k7-a69af1ba-1d5c-40dc-8cdd-236b6c4936aa.parquet
2026-01-14 20:07:23        996 iceberg/data/_WNpKQ/20260114_110721_00043_k64c9-a1506c5c-239a-4736-9e0a-b85195b4d97a.parquet
2026-01-14 19:55:42      36924 iceberg/data/umF6Hw/20260114_105540_00124_az5k7-6adc69b9-54fc-4a93-811a-cf135bfd5cf2.parquet
2026-01-14 20:07:23       1099 iceberg/data/va5F1g/20260114_110721_00043_k64c9-bc0ad815-ad87-4ac8-9964-19d5d36aca70.parquet
2026-01-14 19:30:19       2314 iceberg/metadata/00000-8835bb85-b387-4c89-913b-6b1d3c6aefd3.metadata.json
2026-01-14 19:38:43       3387 iceberg/metadata/00001-3871af6f-0782-4419-8aa6-6cfd9e045fcc.metadata.json
2026-01-14 19:55:42       4522 iceberg/metadata/00002-99054ad3-7e8f-4252-b77c-dc6e566e03ec.metadata.json
2026-01-14 20:07:24       5596 iceberg/metadata/00003-3c7634c0-24e6-4904-9e6d-e8154a097d3e.metadata.json
2026-01-14 20:34:12       7363 iceberg/metadata/00004-8ca813ca-ad86-43f9-b17a-5c0f715ae8e4.metadata.json
2026-01-14 19:55:42       7673 iceberg/metadata/8dcbd209-8232-44dd-9d02-430ea1357489-m0.avro
2026-01-14 19:55:42       7487 iceberg/metadata/8dcbd209-8232-44dd-9d02-430ea1357489-m1.avro
2026-01-14 19:38:43       7691 iceberg/metadata/8f42097e-d438-4d91-8b3a-1909eff99dc2-m0.avro
2026-01-14 20:07:23       7610 iceberg/metadata/ac88c5f7-602f-4695-832a-7c8df4ab7db1-m0.avro
2026-01-14 19:38:43       4247 iceberg/metadata/snap-1477755431002976861-1-8f42097e-d438-4d91-8b3a-1909eff99dc2.avro
2026-01-14 20:07:23       4394 iceberg/metadata/snap-6043730672255953016-1-ac88c5f7-602f-4695-832a-7c8df4ab7db1.avro
2026-01-14 19:55:42       4335 iceberg/metadata/snap-635643277371762727-1-8dcbd209-8232-44dd-9d02-430ea1357489.avro

ファイル名が長くてわかりにくいですが、整理すると以下のような構造になっているようです。

iceberg/
├── data/
│   ├── KqwkNw/*.parquet   ← INSERT 時の元データ
│   ├── NAwi7w/*.parquet   ← UPDATE で追加(更新後の行)
│   ├── umF6Hw/*.parquet   ← UPDATE で追加(更新後の行)
│   ├── _WNpKQ/*.parquet   ← DELETE で追加(Position Delete File)
│   └── va5F1g/*.parquet   ← DELETE で追加(Position Delete File)
└── metadata/
    ├── 00000-*.metadata.json  ← テーブル作成時
    ├── 00001-*.metadata.json  ← INSERT 後
    ├── 00002-*.metadata.json  ← UPDATE 後
    ├── 00003-*.metadata.json  ← DELETE 後
    ├── 00004-*.metadata.json  ← スキーマ変更後
    ├── *-m0.avro              ← Manifest File
    └── snap-*.avro            ← Manifest List
  • data/ - 実際のデータ(CSV で INSERT しても Parquet で保存される)
  • metadata/ - テーブルのメタデータ、スナップショット情報

Iceberg ファイルの中身を読み取る

Iceberg のメタデータは JSON(metadata.json)と Avro(Manifest List、Manifest File)、データは Parquet で保存されています。Avro が採用されているのは、スキーマをファイル内に埋め込めて、かつコンパクトに保存できるためです。

バイナリ形式のファイル(Avro、Parquet)はテキストエディタでは開けないため、ここでは Python ライブラリ(fastavropyarrow)を使って読み取り、整形して表示しています。各ファイルの役割は後述のまとめで整理しています。

Python スクリプトの使い方

# Avro / Parquet 読み取り用ライブラリをインストール
pip install fastavro pyarrow

# S3 からファイルをダウンロード
aws s3 cp s3://your-bucket-name/iceberg/metadata/00004-xxx.metadata.json ./
aws s3 cp s3://your-bucket-name/iceberg/metadata/snap-xxx.avro ./
aws s3 cp s3://your-bucket-name/iceberg/metadata/xxx-m0.avro ./
aws s3 cp s3://your-bucket-name/iceberg/data/xxx/xxx.parquet ./

# 読み取りスクリプトの使い方
python read_iceberg_files.py snapshot <path>   # Manifest List (snap-*.avro)
python read_iceberg_files.py manifest <path>   # Manifest File (*-m0.avro)
python read_iceberg_files.py parquet <path>    # Data / Delete File (*.parquet)
read_iceberg_files.py(クリックで展開)
"""
Iceberg ファイル読み取りスクリプト

使い方:
    python read_iceberg_files.py snapshot <path>   # Manifest List (snap-*.avro)
    python read_iceberg_files.py manifest <path>   # Manifest File (*-m0.avro)
    python read_iceberg_files.py parquet <path>    # Data File (*.parquet)
"""

import sys

def read_snapshot(path):
    """Manifest List (snap-*.avro) を読む"""
    import fastavro

    print(f"=== Manifest List: {path} ===\n")

    with open(path, 'rb') as f:
        reader = fastavro.reader(f)

        print("--- Records ---")
        for i, record in enumerate(reader):
            print(f"\n[Manifest {i}]")
            print(f"  manifest_path: {record.get('manifest_path', 'N/A')}")
            print(f"  manifest_length: {record.get('manifest_length', 'N/A')} bytes")
            print(f"  partition_spec_id: {record.get('partition_spec_id', 'N/A')}")
            print(f"  content: {record.get('content', 'N/A')} (0=data, 1=deletes)")
            print(f"  added_snapshot_id: {record.get('added_snapshot_id', 'N/A')}")
            print(f"  added_rows_count: {record.get('added_rows_count', 'N/A')}")
            print(f"  existing_rows_count: {record.get('existing_rows_count', 'N/A')}")
            print(f"  deleted_rows_count: {record.get('deleted_rows_count', 'N/A')}")

def read_manifest(path):
    """Manifest File (*-m0.avro) を読む"""
    import fastavro

    print(f"=== Manifest File: {path} ===\n")

    with open(path, 'rb') as f:
        reader = fastavro.reader(f)

        print("--- Records ---")
        for i, record in enumerate(reader):
            data_file = record.get('data_file', {})
            content = data_file.get('content', 0)
            content_type = {0: 'data', 1: 'position deletes', 2: 'equality deletes'}.get(content, 'unknown')

            print(f"\n[Entry {i}]")
            print(f"  status: {record.get('status', 'N/A')} (1=added, 2=existing, 3=deleted)")
            print(f"  snapshot_id: {record.get('snapshot_id', 'N/A')}")
            print(f"  content: {content} ({content_type})")
            print(f"  file_path: {data_file.get('file_path', 'N/A')}")
            print(f"  file_format: {data_file.get('file_format', 'N/A')}")
            print(f"  record_count: {data_file.get('record_count', 'N/A')}")
            print(f"  file_size_in_bytes: {data_file.get('file_size_in_bytes', 'N/A')}")

            if i >= 9:
                print(f"\n... (以降省略、全 {i+1}+ 件)")
                break

def read_parquet(path):
    """Parquet ファイルを読む"""
    import pyarrow.parquet as pq

    print(f"=== Parquet File: {path} ===\n")

    parquet_file = pq.ParquetFile(path)
    metadata = parquet_file.metadata

    print("--- Metadata ---")
    print(f"  num_rows: {metadata.num_rows}")
    print(f"  num_columns: {metadata.num_columns}")

    print("\n--- Schema ---")
    schema = parquet_file.schema_arrow
    for field in schema:
        print(f"  {field.name}: {field.type}")

    print("\n--- Data Preview (先頭10行) ---")
    table = parquet_file.read()
    df = table.to_pandas()
    print(df.head(10).to_string())

def main():
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)

    file_type = sys.argv[1]
    path = sys.argv[2]

    if file_type == 'snapshot':
        read_snapshot(path)
    elif file_type == 'manifest':
        read_manifest(path)
    elif file_type == 'parquet':
        read_parquet(path)
    else:
        print(f"Unknown file type: {file_type}")
        sys.exit(1)

if __name__ == '__main__':
    main()

metadata.json の中身を覗いてみる

最新の metadata.json(00004-*.metadata.json)を見ると、Iceberg がどのように変更履歴を管理しているかがわかります。まず全体を見てから、各部分を解説します。

metadata.json 全体(クリックで展開)
{
  "format-version" : 2,
  "table-uuid" : "002bd9f3-4026-4f4b-a47f-ada7fd579281",
  "location" : "s3://your-bucket-name/iceberg",
  "last-sequence-number" : 3,
  "last-updated-ms" : 1768390451352,
  "last-column-id" : 15,
  "current-schema-id" : 1,
  "schemas" : [ {
    "type" : "struct",
    "schema-id" : 0,
    "fields" : [
      { "id" : 1, "name" : "pickup", "required" : false, "type" : "string" },
      { "id" : 2, "name" : "dropoff", "required" : false, "type" : "string" },
      { "id" : 3, "name" : "passengers", "required" : false, "type" : "int" },
      { "id" : 4, "name" : "distance", "required" : false, "type" : "double" },
      { "id" : 5, "name" : "fare", "required" : false, "type" : "double" },
      { "id" : 6, "name" : "tip", "required" : false, "type" : "double" },
      { "id" : 7, "name" : "tolls", "required" : false, "type" : "double" },
      { "id" : 8, "name" : "total", "required" : false, "type" : "double" },
      { "id" : 9, "name" : "color", "required" : false, "type" : "string" },
      { "id" : 10, "name" : "payment", "required" : false, "type" : "string" },
      { "id" : 11, "name" : "pickup_zone", "required" : false, "type" : "string" },
      { "id" : 12, "name" : "dropoff_zone", "required" : false, "type" : "string" },
      { "id" : 13, "name" : "pickup_borough", "required" : false, "type" : "string" },
      { "id" : 14, "name" : "dropoff_borough", "required" : false, "type" : "string" }
    ]
  }, {
    "type" : "struct",
    "schema-id" : 1,
    "fields" : [
      { "id" : 1, "name" : "pickup", "required" : false, "type" : "string" },
      { "id" : 2, "name" : "dropoff", "required" : false, "type" : "string" },
      { "id" : 3, "name" : "passengers", "required" : false, "type" : "int" },
      { "id" : 4, "name" : "distance", "required" : false, "type" : "double" },
      { "id" : 5, "name" : "fare", "required" : false, "type" : "double" },
      { "id" : 6, "name" : "tip", "required" : false, "type" : "double" },
      { "id" : 7, "name" : "tolls", "required" : false, "type" : "double" },
      { "id" : 8, "name" : "total", "required" : false, "type" : "double" },
      { "id" : 9, "name" : "color", "required" : false, "type" : "string" },
      { "id" : 10, "name" : "payment", "required" : false, "type" : "string" },
      { "id" : 11, "name" : "pickup_zone", "required" : false, "type" : "string" },
      { "id" : 12, "name" : "dropoff_zone", "required" : false, "type" : "string" },
      { "id" : 13, "name" : "pickup_borough", "required" : false, "type" : "string" },
      { "id" : 14, "name" : "dropoff_borough", "required" : false, "type" : "string" },
      { "id" : 15, "name" : "is_weekend", "required" : false, "type" : "boolean" }
    ]
  } ],
  "default-spec-id" : 0,
  "partition-specs" : [ { "spec-id" : 0, "fields" : [ ] } ],
  "last-partition-id" : 999,
  "default-sort-order-id" : 0,
  "sort-orders" : [ { "order-id" : 0, "fields" : [ ] } ],
  "properties" : {
    "write.object-storage.enabled" : "true",
    "write.object-storage.path" : "s3://your-bucket-name/iceberg/data",
    "write.parquet.compression-codec" : "zstd"
  },
  "current-snapshot-id" : 6043730672255953016,
  "refs" : { "main" : { "snapshot-id" : 6043730672255953016, "type" : "branch" } },
  "snapshots" : [ {
    "sequence-number" : 1,
    "snapshot-id" : 1477755431002976861,
    "timestamp-ms" : 1768387122365,
    "summary" : {
      "operation" : "append",
      "added-data-files" : "1",
      "added-records" : "6433",
      "total-records" : "6433",
      "total-data-files" : "1",
      "total-delete-files" : "0"
    },
    "manifest-list" : "s3://your-bucket-name/iceberg/metadata/snap-1477755431002976861-1-8f42097e-d438-4d91-8b3a-1909eff99dc2.avro",
    "schema-id" : 0
  }, {
    "sequence-number" : 2,
    "snapshot-id" : 635643277371762727,
    "parent-snapshot-id" : 1477755431002976861,
    "timestamp-ms" : 1768388141795,
    "summary" : {
      "operation" : "overwrite",
      "added-data-files" : "1",
      "added-position-delete-files" : "1",
      "added-delete-files" : "1",
      "added-records" : "1812",
      "added-position-deletes" : "1812",
      "total-records" : "8245",
      "total-data-files" : "2",
      "total-delete-files" : "1",
      "total-position-deletes" : "1812"
    },
    "manifest-list" : "s3://your-bucket-name/iceberg/metadata/snap-635643277371762727-1-8dcbd209-8232-44dd-9d02-430ea1357489.avro",
    "schema-id" : 0
  }, {
    "sequence-number" : 3,
    "snapshot-id" : 6043730672255953016,
    "parent-snapshot-id" : 635643277371762727,
    "timestamp-ms" : 1768388842854,
    "summary" : {
      "operation" : "overwrite",
      "added-position-delete-files" : "2",
      "added-delete-files" : "2",
      "added-position-deletes" : "96",
      "total-records" : "8245",
      "total-data-files" : "2",
      "total-delete-files" : "3",
      "total-position-deletes" : "1908"
    },
    "manifest-list" : "s3://your-bucket-name/iceberg/metadata/snap-6043730672255953016-1-ac88c5f7-602f-4695-832a-7c8df4ab7db1.avro",
    "schema-id" : 0
  } ],
  "statistics" : [ ],
  "snapshot-log" : [
    { "timestamp-ms" : 1768387122365, "snapshot-id" : 1477755431002976861 },
    { "timestamp-ms" : 1768388141795, "snapshot-id" : 635643277371762727 },
    { "timestamp-ms" : 1768388842854, "snapshot-id" : 6043730672255953016 }
  ],
  "metadata-log" : [
    { "timestamp-ms" : 1768386618442, "metadata-file" : "s3://your-bucket-name/iceberg/metadata/00000-8835bb85-b387-4c89-913b-6b1d3c6aefd3.metadata.json" },
    { "timestamp-ms" : 1768387122365, "metadata-file" : "s3://your-bucket-name/iceberg/metadata/00001-3871af6f-0782-4419-8aa6-6cfd9e045fcc.metadata.json" },
    { "timestamp-ms" : 1768388141795, "metadata-file" : "s3://your-bucket-name/iceberg/metadata/00002-99054ad3-7e8f-4252-b77c-dc6e566e03ec.metadata.json" },
    { "timestamp-ms" : 1768388842854, "metadata-file" : "s3://your-bucket-name/iceberg/metadata/00003-3c7634c0-24e6-4904-9e6d-e8154a097d3e.metadata.json" }
  ]
}

トップレベルの主要フィールド

フィールド 説明
format-version 2 Iceberg v2 フォーマット
current-schema-id 1 現在使用しているスキーマ ID
current-snapshot-id 6043730672255953016 現在参照しているスナップショット
schemas [...] スキーマの履歴(schema-id: 0, 1)
snapshots [...] スナップショットの履歴

スキーマ進化(schemas

schema-id カラム数 変更点
0 14 初期スキーマ(pickup 〜 dropoff_borough)
1 15 is_weekend カラムを追加

テーブルの current-schema-id は 1 ですが、既存の全スナップショットは schema-id: 0 で作成されています。スキーマ進化後にデータを書き込むと、新しいスナップショットは schema-id: 1 を参照します。

スナップショット履歴(snapshots

# operation schema-id 内容
1 append 0 INSERT: 6,433件追加
2 overwrite 0 UPDATE: 1,812件更新
3 overwrite 0 DELETE: 96件削除

UPDATE の仕組み

UPDATE(sequence-number: 2)の summary の項目を整理しましょう。

フィールド 意味
added-records 1812 更新後の新しいデータ行(payment = 'Cash'
added-position-deletes 1812 削除対象としてマークされた古い行(payment = 'cash'
added-delete-files 1 この操作で追加された Position Delete File の数
total-delete-files 1 テーブル全体の Position Delete File 数

UPDATE = 古い行の位置を Position Delete File に記録 + 新しい行を追加 するという動作になっています。

書き込み方式の詳細は以下のブログを参照ください!

https://dev.classmethod.jp/articles/apache-iceberg-mor-cow-storategy/

Manifest List の中身を覗いてみる

snap-*.avroManifest List ファイルです。ファイル名に snap- とスナップショット ID が含まれていますが、中身はスナップショットのデータではなく、Manifest File の一覧です。スナップショットの定義自体は metadata.json 内にあり、そこから Manifest List が参照されます。

なぜこの構造なのか?
スナップショットを復元するには「どのデータファイルが有効か」を知る必要がありますが、Iceberg はデータファイルを直接管理せず、Manifest File を経由します。そのため、Manifest List には「このスナップショット時点で参照すべき Manifest File の一覧」が格納されています。この間接参照により、新しい操作に対応する Manifest File だけ追加し、既存の Manifest File は再利用できるため効率的です(階層構造の詳細は後述のまとめを参照)。

Manifest List の主要フィールド(Avro スキーマより)

フィールド 説明
manifest_path Manifest File のパス
content 内容種別(0=data, 1=position deletes)
added_snapshot_id この Manifest が追加されたスナップショット ID
added_rows_count この Manifest が管理するファイル内の行数
Manifest List の中身(クリックで展開)
[Manifest 0]
  manifest_path: s3://.../8dcbd209-...-m0.avro
  manifest_length: 7673 bytes
  partition_spec_id: 0
  content: 0 (0=data, 1=deletes)
  added_snapshot_id: 635643277371762727
  added_rows_count: 1812
  existing_rows_count: 0
  deleted_rows_count: 0

[Manifest 1]
  manifest_path: s3://.../8f42097e-...-m0.avro
  manifest_length: 7691 bytes
  partition_spec_id: 0
  content: 0 (0=data, 1=deletes)
  added_snapshot_id: 1477755431002976861
  added_rows_count: 6433
  existing_rows_count: 0
  deleted_rows_count: 0

[Manifest 2]
  manifest_path: s3://.../ac88c5f7-...-m0.avro
  manifest_length: 7610 bytes
  partition_spec_id: 0
  content: 1 (0=data, 1=deletes)
  added_snapshot_id: 6043730672255953016
  added_rows_count: 96
  existing_rows_count: 0
  deleted_rows_count: 0

[Manifest 3]
  manifest_path: s3://.../8dcbd209-...-m1.avro
  manifest_length: 7487 bytes
  partition_spec_id: 0
  content: 1 (0=data, 1=deletes)
  added_snapshot_id: 635643277371762727
  added_rows_count: 1812
  existing_rows_count: 0
  deleted_rows_count: 0
Manifest content 説明 行数
Manifest 0 0 (data) UPDATE で追加された新しい行(payment = 'Cash' 1,812
Manifest 1 0 (data) INSERT で追加された元データ 6,433
Manifest 2 1 (deletes) DELETE で削除マークされた行(passengers = 0 96
Manifest 3 1 (deletes) UPDATE で削除マークされた古い行(payment = 'cash' 1,812

現在のスナップショットは 4つの Manifest File を参照しており、それぞれがデータファイルまたは Delete File を管理しています。タイムトラベル時は、過去のスナップショットが参照する Manifest File のセットを使ってデータを再構築します。

Manifest File の中身を覗いてみる

Manifest File(*-m0.avro)は、データファイルや Delete File のリストを管理しています。
DELETE 操作後の Manifest File を見ると、Position Delete File が登録されていることがわかります。

Manifest File の中身(クリックで展開)
[Entry 0]
  status: 1 (1=added, 2=existing, 3=deleted)
  snapshot_id: 6043730672255953016
  content: 1 (position deletes)
  file_path: s3://your-bucket-name/iceberg/data/va5F1g/20260114_110721_00043_k64c9-bc0ad815-ad87-4ac8-9964-19d5d36aca70.parquet
  file_format: PARQUET
  record_count: 83
  file_size_in_bytes: 1099

[Entry 1]
  status: 1 (1=added, 2=existing, 3=deleted)
  snapshot_id: 6043730672255953016
  content: 1 (position deletes)
  file_path: s3://your-bucket-name/iceberg/data/_WNpKQ/20260114_110721_00043_k64c9-a1506c5c-239a-4736-9e0a-b85195b4d97a.parquet
  file_format: PARQUET
  record_count: 13
  file_size_in_bytes: 996

ポイント

  • record_count: 8313 の合計 96件が、DELETE で削除した件数と一致
  • Position Delete File も Parquet 形式で保存されている

Iceberg の削除動作

UPDATE の仕組みでも触れましたが、Iceberg は Merge-on-Read 方式を採用しており、既存のデータファイルを直接変更しません。DELETE も同様に、「どの行を削除するか」を Position Delete File に記録します。

この仕組みにより、元データを保持したまま論理削除を実現しています。物理削除しないことで、タイムトラベルや障害復旧が可能になります。

ただし、Delete File が増えるとクエリ時のマージ処理が重くなります。運用では定期的に Compaction(データファイルの統合)や Expire Snapshots(古いスナップショットの削除)を実行して、不要なファイルを物理削除することが推奨されます。

参考: Apache Iceberg Spec - Position Delete Files
Apache Iceberg - Maintenance

Position Delete File の中身を覗いてみる

Position Delete File の Parquet ファイルを実際に開いてみると、その正体がわかります。

Position Delete File の中身(クリックで展開)
--- Metadata ---
  num_rows: 83
  num_columns: 2

--- Schema ---
  file_path: string
  pos: int64

--- Data Preview (先頭10行) ---
                                           file_path  pos
0  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet    7
1  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet   92
2  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  117
3  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  278
4  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  389
5  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  417
6  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  420
7  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  529
8  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  641
9  s3://.../KqwkNw/20260114_103841_00124_him48-....parquet  695

Position Delete File の構造

  • file_path - 削除対象のデータファイルのパス
  • pos - そのファイル内の削除対象の行番号(0-indexed)

つまり「KqwkNw/... の Parquet ファイルの 7行目、92行目、117行目、... を削除する」という情報が記録されています。クエリ実行時にこの情報を参照して、該当行をスキップすることで論理削除を実現しています。

もう1つの Position Delete File(Entry 1)も見てみると、参照先のデータファイルが異なることがわかります。

Entry 1 の Position Delete File(クリックで展開)
--- Metadata ---
  num_rows: 13
  num_columns: 2

--- Schema ---
  file_path: string
  pos: int64

--- Data Preview (先頭10行) ---
                                           file_path  pos
0  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet    3
1  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet   11
2  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet   60
3  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  184
4  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  290
5  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  499
6  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  534
7  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  559
8  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  604
9  s3://.../umF6Hw/20260114_105540_00124_az5k7-....parquet  624
Position Delete File 参照先データファイル 削除件数
Entry 0 KqwkNw/...(INSERT 時のデータ) 83件
Entry 1 umF6Hw/...(UPDATE 時のデータ) 13件

DELETE 対象の行が複数のデータファイルに分散していたため、それぞれに対応する Position Delete File が作成されています。合計 83 + 13 = 96件で、DELETE した件数と一致します。

Iceberg の階層構造まとめ

ここまで見てきた内容を整理すると、Iceberg は「リストのリスト」といった階層構造になっています。

ファイル 形式 役割
metadata.json JSON スナップショットのリスト(+ スキーマ、設定など)
Manifest List(snap-*.avro) Avro Manifest File のリスト
Manifest File(*-m0.avro 等) Avro データファイルのリスト
Data Files(*.parquet) Parquet 実データ / Position Delete File
metadata.json
└── snapshots[]
    └── snapshot → Manifest List(snap-*.avro)
                   └── Manifest File(*-m0.avro)
                       └── Data File(*.parquet)

この階層構造により、スナップショットごとに「どのデータファイルが有効か」を効率的に追跡でき、タイムトラベルや ACID トランザクションが実現されています。


お片付け

Athena テーブル・データベースの削除

DROP TABLE IF EXISTS taxi_db.taxis_iceberg;
DROP TABLE IF EXISTS taxi_db.taxis_csv;
DROP DATABASE IF EXISTS taxi_db;

S3 データの削除

aws s3 rm s3://your-bucket-name/ --recursive
aws s3 rb s3://your-bucket-name

まとめ

機能 S3 + Parquet のみ Iceberg テーブル
UPDATE/DELETE 不可 行レベルで可能
トランザクション なし ACID 保証
タイムトラベル 不可 スナップショットで可能
スキーマ変更 テーブル再作成が必要 ALTER TABLE で柔軟に変更可能

Apache Iceberg を使うことで、S3 上のデータレイクでもデータベースのような操作性を実現できます。

なお、今回はパーティションなしで検証しましたが、パーティションを活用するとクエリ時に不要なファイルをスキップでき、さらに効率的なデータ管理が可能です。

今後は AWS の新サービス S3 Tables(Iceberg をマネージドで扱える)についても触ってみたいと思います。

最後までお読みいただきありがとうございました!


参考リンク

この記事をシェアする

FacebookHatena blogX

関連記事