Athena×Iceberg本番1年で痛感したこと——タイムトラベルとコスト最適化の現実
「S3でトランザクション管理できる」に飛びついて1年。最初の3ヶ月は正直しんどかった。同じ構成で詰まっている人に届けたい、実運用で見えてきたIcebergの勘所。
Icebergに全乗りして1年、正直に話す
うちのチームがAthena + Apache Icebergの本番運用を始めたのは2025年の春頃で、それからちょうど1年以上が経った。最初に導入を提案したのは自分で、「S3上のデータをトランザクション管理できて、タイムトラベルもできる」という触れ込みに飛びついた形だった。
正直に言うと、最初の3ヶ月はかなり苦労した。AthenaでIceberg本番運用6ヶ月のタイムトラベルとスキーマ進化の話でも少し触れたけど、設計の勘所を掴むまでに何度か痛い目を見た。この記事ではその経験を踏まえて、2026年現在の最新Iceberg仕様(v3ドラフト含む)を踏まえながら、実際に動く構成と知見をまとめておく。
同じような構成を検討している人、あるいは「ちょっと試してみたけどうまくいかない」という人の参考になれば。
実際に組んだ構成とアーキテクチャ
うちのデータ基盤は大まかに言うと、アプリのログとRDBのCDCデータをS3に集めて、Athena経由でクエリするという構成だ。Redshiftも一時期並走させてたけど、コストとアジリティのバランスを考えてIceberg + Athenaをメインに据えた。
graph TB
subgraph Sources["データソース"]
APP["アプリケーション\nログ"]
RDS["Aurora PostgreSQL\nCDC"]
EXT["外部API\nデータ"]
end
subgraph Ingestion["Ingestion Layer"]
KDS["Kinesis Data Streams"]
DMS["AWS DMS"]
LAMBDA["Lambda\nTransformer"]
end
subgraph Storage["S3 Data Lake"]
subgraph Raw["Raw Zone"]
S3RAW["s3://raw-bucket"]
end
subgraph Processed["Processed Zone (Iceberg)"]
S3ICE["s3://iceberg-bucket\n/namespace/table_name"]
end
subgraph Meta["Metadata"]
GLUE["AWS Glue\nData Catalog"]
end
end
subgraph Processing["Processing Layer"]
EMR["EMR Serverless\n(Spark + Iceberg)"]
GLUEJOB["Glue Job\n(小規模変換)"]
end
subgraph Query["Query Layer"]
ATHENA["Amazon Athena\n(Iceberg Engine)"]
QS["QuickSight"]
NOTEBOOK["SageMaker\nStudio Notebook"]
end
subgraph Ops["運用・監視"]
CW["CloudWatch"]
LF["Lake Formation\n(アクセス制御)"]
end
APP --> KDS
RDS --> DMS
EXT --> LAMBDA
KDS --> S3RAW
DMS --> S3RAW
LAMBDA --> S3RAW
S3RAW --> EMR
S3RAW --> GLUEJOB
EMR --> S3ICE
GLUEJOB --> S3ICE
S3ICE --> GLUE
GLUE --> ATHENA
ATHENA --> QS
ATHENA --> NOTEBOOK
LF --> ATHENA
CW --> EMR
CW --> ATHENA
この構成にたどり着くまでに実は紆余曲折があった。最初はGlue JobだけでIcebergテーブルを書いていたんだけど、大規模なバッチ処理になるにつれてEMR Serverlessが必要になってきた。EMR Serverlessで月15万円のバッチ処理を本番化した話も参考にしてほしい。
IcebergテーブルをAthenaで使う基本設定
テーブル作成とPropertiesの選定
2026年現在、AthenaはApache Iceberg v2フォーマットをネイティブサポートしており、一部v3の機能(行レベルのDeletes改善など)もプレビューで使えるようになっている。実際に使っているDDLはこんな感じだ。
-- Icebergテーブルの作成
CREATE TABLE iceberg_db.user_events (
event_id STRING,
user_id STRING,
event_type STRING,
properties MAP<STRING, STRING>,
event_ts TIMESTAMP,
created_date DATE
)
PARTITIONED BY (
days(event_ts), -- 日付パーティション(Iceberg hidden partition)
bucket(16, user_id) -- バケットパーティション
)
LOCATION 's3://my-iceberg-bucket/user_events/'
TBLPROPERTIES (
'table_type' = 'ICEBERG',
'format' = 'PARQUET',
'write_compression' = 'ZSTD',
'optimize_rewrite_delete_file_threshold' = '10',
'vacuum_max_snapshot_age_seconds' = '604800', -- 7日間
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
ここでハマったポイントをいくつか共有する。
write.delete.modeの選択は地味に重要だった。merge-on-readにするとdelete操作が速いけど、読み取り時にDelete Fileのマージが発生してクエリが遅くなる。copy-on-writeにすると逆。うちのユースケースは更新頻度が高くて読み取り頻度も高いという、割と最悪な組み合わせだったので、最終的にはMORにしてOptimizeを定期実行する戦略を取った。
MOR vs COWの選び方をざっくりまとめるとこうなる。
| 観点 | Merge-on-Read (MOR) | Copy-on-Write (COW) |
|---|---|---|
| 書き込み速度 | 速い(Delete Fileを追記するだけ) | 遅い(データファイルを再書き込み) |
| 読み取り速度 | 遅い(マージ処理が発生) | 速い(ファイルがクリーンな状態) |
| OPTIMIZE必要性 | 高い(定期実行が必須) | 低い |
| 向いているケース | 更新頻度が高い、準リアルタイム系 | 更新少なめ、分析クエリ中心 |
バケットパーティションの数も最初は悩んだ。bucket(16, user_id)の16は、データ量に応じて決める必要があって、小さすぎると1ファイルが大きくなりすぎ、大きすぎるとSmall File Problem(後述)が悪化する。うちは月間数十億レコードのテーブルで16を使っているが、これは正直まだ検証中のところもある。
スキーマ進化の実装
Icebergのスキーマ進化は本当に便利で、これだけでも導入する価値があると個人的には思っている。
-- カラム追加(互換性あり)
ALTER TABLE iceberg_db.user_events
ADD COLUMNS (session_id STRING AFTER user_id);
-- カラム名変更(Icebergのメタデータ変更のみ、データファイル変更なし)
ALTER TABLE iceberg_db.user_events
RENAME COLUMN event_type TO event_category;
-- カラムのデータ型変更(一部の変換のみサポート)
-- INT -> LONG, FLOAT -> DOUBLE, DATE -> TIMESTAMP など
ALTER TABLE iceberg_db.user_events
CHANGE COLUMN user_id user_id BIGINT;
実際に「昨日までSTRINGだったuser_idをBIGINTに変えてほしい」という依頼が来たとき、従来のHiveスタイルのテーブルだったら全データリライトが必要だったところ、Icebergではメタデータ変更だけで済んだ。「マジで助かった」と思った瞬間だった。
ただし注意点がある。カラム削除は論理削除になるので、古いSnapshotにはまだそのカラムが存在している状態になる。タイムトラベルで古いデータを参照したとき、削除したカラムが復活して驚くことがある。実際にあった。
タイムトラベルとスナップショット管理
-- スナップショット一覧の確認
SELECT snapshot_id, committed_at, operation, summary
FROM "iceberg_db"."user_events$snapshots"
ORDER BY committed_at DESC
LIMIT 10;
-- 特定時点のデータを参照(タイムトラベル)
SELECT *
FROM iceberg_db.user_events
FOR SYSTEM_TIME AS OF TIMESTAMP '2026-06-28 10:00:00'
WHERE event_type = 'purchase'
LIMIT 100;
-- 特定のスナップショットIDで参照
SELECT COUNT(*)
FROM iceberg_db.user_events
FOR SYSTEM_VERSION AS OF 8954321098765432;
-- パーティション情報の確認
SELECT *
FROM "iceberg_db"."user_events$partitions"
WHERE record_count > 0
ORDER BY record_count DESC;
タイムトラベルは実際のインシデント対応でかなり役に立った。「昨日のバッチで誤ったデータが入った、3日前の状態に戻してほしい」という案件で、まずタイムトラベルで問題のある行を特定して、その後DELETE + INSERTで修正するという手順を踏んだ。従来の構成だったらバックアップからのリストアを考えないといけなかったところ、これが数分で終わったのはかなり大きかった。
本番運用で直面した課題と対策
Small File Problem との戦い
Icebergを本番で使い始めて1ヶ月で最初の壁にぶつかった。ストリーミングインジェストを使っていたため、数分ごとに小さなデータファイルがS3に積み上がっていき、クエリのパフォーマンスが徐々に低下していった。
対策として実装したのが定期的なOptimizeとVACUUM。
-- Optimize:小さなデータファイルをコンパクション
OPTIMIZE iceberg_db.user_events
REWRITE DATA
USING BIN_PACK
WHERE created_date >= CURRENT_DATE - INTERVAL '7' DAY
WITH ("target-file-size-bytes" = '134217728'); -- 128MB
-- VACUUM:古いSnapshotとDelete Fileを削除
VACUUM iceberg_db.user_events;
これをEventBridge Schedulerで毎日深夜に実行するようにした。OPTIMIZE後のクエリ速度改善は体感できるレベルで、S3のGetリクエスト数も大幅に減ってコスト削減にもつながった。数字で見るとこんな感じだ。
xychart-beta
title "OPTIMIZE実施前後のクエリ実行時間比較(秒)"
x-axis ["Week1(最適化前)", "Week2(最適化前)", "Week3(OPTIMIZE導入)", "Week4(安定後)", "Week5(安定後)"]
y-axis "クエリ実行時間(秒)" 0 --> 120
bar [98, 112, 45, 38, 35]
line [98, 112, 45, 38, 35]
Week3でOPTIMIZEを入れた途端に半分以下になっているのが一目瞭然で、これを見たとき「もっと早く入れておけばよかった」とチーム全員で思った。OPTIMIZEとVACUUMはテーブル作成時から仕込んでおくのが正解で、後付けだとファイル数が爆発してから気づくことになる。
データ品質とUPDATE/DELETE操作
IcebergのUPDATEとDELETEはAthena経由でも使えるようになっているが、制約がある。
-- レコードの更新(MERGE ON READモードでは効率的)
UPDATE iceberg_db.user_events
SET event_category = 'checkout'
WHERE event_type = 'purchase'
AND created_date = CURRENT_DATE - INTERVAL '1' DAY;
-- 条件付き削除
DELETE FROM iceberg_db.user_events
WHERE event_type = 'spam'
AND created_date < CURRENT_DATE - INTERVAL '30' DAY;
-- MERGE操作(Upsert)
MERGE INTO iceberg_db.user_events target
USING (
SELECT
event_id,
user_id,
'updated_type' AS event_type,
CURRENT_TIMESTAMP AS event_ts
FROM staging_db.corrections
WHERE correction_date = CURRENT_DATE
) source
ON target.event_id = source.event_id
WHEN MATCHED THEN
UPDATE SET
target.event_type = source.event_type,
target.event_ts = source.event_ts
WHEN NOT MATCHED THEN
INSERT (event_id, user_id, event_type, event_ts)
VALUES (source.event_id, source.user_id, source.event_type, source.event_ts);
MERGEは便利だけど、実行計画がかなり複雑になるのでDEV環境でのスキャン量チェックを必ずすることをおすすめする。うちで一度、誤ったMERGE条件で全テーブルスキャンが走り、Athenaの料金が1日で普段の10倍になったことがあった。あれは本当に冷や汗をかいた。
データ品質管理2026年版の記事でも触れているけど、IcebergのMERGEを使うとUpsertが宣言的に書けるので、データ品質を担保しやすい。一方で、何が起きているかを把握しにくいという側面もあるので、ロギングはしっかり入れておいた方がいい。
コスト最適化の実際
1年運用してみてわかったコスト面の現実をまとめる。正直、最適化前の数字を見ると今でも少し気が滅入る。
| 要素 | 最適化前(月) | 最適化後(月) | 削減率 |
|---|---|---|---|
| S3 GetRequestコスト | 約18万円 | 約6万円 | 67% |
| Athenaクエリコスト | 約45万円 | 約28万円 | 38% |
| S3ストレージ | 約22万円 | 約15万円 | 32% |
| EMR Serverless実行コスト | 約12万円 | 約9万円 | 25% |
| 合計 | 約97万円 | 約58万円 | 40% |
S3 GetRequestコストが大きく下がったのはOPTIMIZEによるSmall File解消が主因だ。Athenaのコスト削減はパーティションプルーニングが正しく効くようになったことが大きかった。
パーティションプルーニングが効くかどうかは、クエリのWHERE句にIceberg hidden partitionのカラムを含めるかどうかで決まる。ここを誤解したまま運用すると、せっかくIceberg化した意味が半減してしまう。
-- パーティションプルーニングが効くクエリ(event_tsを条件に含める)
SELECT user_id, COUNT(*)
FROM iceberg_db.user_events
WHERE event_ts >= TIMESTAMP '2026-07-01 00:00:00'
AND event_ts < TIMESTAMP '2026-07-02 00:00:00'
GROUP BY user_id;
-- プルーニングが効かないクエリ(created_dateのみ、days()パーティションはevent_tsに対して定義)
SELECT user_id, COUNT(*)
FROM iceberg_db.user_events
WHERE created_date = DATE '2026-07-01' -- これだとfull scanになる
GROUP BY user_id;
最初はこの違いを認識していなかったので、クエリコストが異常に高くなることが何度かあった。同じ経験をした人は少なくないんじゃないかと思う。
EMR SparkからAthenaへのマルチエンジン戦略
Icebergの強みの一つは、複数のエンジンから同じテーブルを読み書きできること。うちではEMR Serverless(Spark)で大規模変換をして、クエリはAthenaで行うという役割分担にしている。これが個人的には一番「Icebergにして良かった」と感じるポイントかもしれない。
# EMR Serverless(PySpark)でのIceberg書き込み
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
.config("spark.sql.catalog.glue_catalog",
"org.apache.iceberg.spark.SparkCatalog") \
.config("spark.sql.catalog.glue_catalog.warehouse",
"s3://my-iceberg-bucket/") \
.config("spark.sql.catalog.glue_catalog.catalog-impl",
"org.apache.iceberg.aws.glue.GlueCatalog") \
.config("spark.sql.catalog.glue_catalog.io-impl",
"org.apache.iceberg.aws.s3.S3FileIO") \
.getOrCreate()
# Icebergテーブルへのupsert(MergeIntoBuilder)
source_df = spark.sql("""
SELECT
event_id,
user_id,
event_type,
properties,
CAST(event_ts AS TIMESTAMP) AS event_ts,
CAST(event_ts AS DATE) AS created_date
FROM glue_catalog.raw_db.raw_events
WHERE dt = '2026-07-02'
""")
source_df.createOrReplaceTempView("source_events")
# MERGEでupsert
spark.sql("""
MERGE INTO glue_catalog.iceberg_db.user_events AS target
USING source_events AS source
ON target.event_id = source.event_id
WHEN MATCHED AND target.event_ts < source.event_ts THEN
UPDATE SET *
WHEN NOT MATCHED THEN
INSERT *
""")
print(f"Merge completed. Snapshot ID: {spark.sql('SELECT snapshot_id FROM glue_catalog.iceberg_db.user_events$snapshots ORDER BY committed_at DESC LIMIT 1').collect()[0][0]}")
SparkとAthenaで同じGlue Catalogを参照しているので、Sparkで書いたデータをすぐAthenaでクエリできる。この一貫性は地味だけど本当に便利で、データエンジニアとアナリストが独立して作業できるようになったのは大きな変化だった。「Sparkで変換終わったんで確認してください」→「Athenaで即クエリ」という流れがシームレスになった。
Apache Spark 2026年最新動向の記事でも触れているIceberg統合の話と合わせて読んでもらえると理解が深まるかもしれない。
Lake FormationとIcebergのアクセス制御
セキュリティ面でも1点触れておきたい。Lake FormationのIceberg統合は2025年後半から本格的に使えるようになっていて、行レベル・列レベルのセキュリティがIcebergテーブルにも適用できるようになった。これが使えるようになったことで、「個人情報を含むカラムだけ特定ロールから隠す」という要件がだいぶ楽に実装できるようになった。
import boto3
lf = boto3.client('lakeformation', region_name='ap-northeast-1')
# 列レベルのアクセス制御設定
response = lf.grant_permissions(
Principal={
'DataLakePrincipalIdentifier': 'arn:aws:iam::123456789012:role/AnalystRole'
},
Resource={
'TableWithColumns': {
'DatabaseName': 'iceberg_db',
'Name': 'user_events',
'ColumnNames': [
'event_id', 'event_type', 'event_ts', 'created_date'
# user_id と properties は除外(個人情報保護)
]
}
},
Permissions=['SELECT'],
PermissionsWithGrantOption=[]
)
# 行フィルター設定(特定のevent_typeのみアクセス可能)
response_filter = lf.create_data_cells_filter(
TableData={
'TableCatalogId': '123456789012',
'DatabaseName': 'iceberg_db',
'TableName': 'user_events',
'Name': 'non_pii_filter',
'RowFilter': {
'FilterExpression': "event_type != 'internal_debug'"
},
'ColumnWildcard': {}
}
)
この辺の設計はデータカタログ完全ガイド2026にも通じるところがあって、Icebergテーブルをきちんとカタログ管理することで、ガバナンスがかなり楽になる。
まとめ
1年本番運用して見えてきたAthena + Icebergの実態をまとめると:
-
スキーマ進化とタイムトラベルは期待通りの価値があった — 運用コストの削減という形で確実に効いている。カラム追加・リネームがメタデータ変更だけで済むのは本当に助かる
-
Small File Problemは必ず来る、事前に対策を設計しておく — OPTIMIZEとVACUUMを定期実行するスケジュールはテーブル作成時から入れるべき。後付けだとファイル数が爆発してから気づくことになる
-
MOR(Merge-on-Read)とCOW(Copy-on-Write)の使い分けが性能を左右する — 更新頻度が高いならMOR + 定期OPTIMIZE、更新少なくてクエリ多ければCOWというのが基本の考え方
-
パーティション設計はIcebergのHidden Partitionを活かす —
days()やbucket()を使ったパーティション設計と、それに合ったクエリパターンをセットで考えないとコスト最適化の恩恵を受けられない -
マルチエンジン(Spark + Athena)の組み合わせが実は最大の強み — 大規模変換はEMR Serverless、即時クエリはAthena、という役割分担がGlue Catalogで統一されると運用がシンプルになる
次のアクションとしては、Iceberg v3の機能(特にRow Lineageとより細粒度なDelete管理)が正式リリースされたら評価してみたいと思っている。あとはZero-ETL系の統合がIcebergにどこまで入ってくるかも注目している。
この構成で困ったことや、「うちはこう解決した」という話があれば、ぜひ教えてください。