こんにちは、開発グループの寺田です。 この記事では AWS Glue の Apache Iceberg Materialized View を実際に動かした検証をまとめています。
- はじめに
- ユースケースのイメージ
- 検証環境
- 前提条件
- Glue Job の設定
- MV 作成スクリプト
- 実装上の制約
- 実装のポイントまとめ
- 作成した MV の確認
- リフレッシュの動作確認
- まとめ
- 参考リンク
はじめに
外部システムからデータハブ内のデータを安全に参照させたい、という要件はデータ基盤を運用していると必ずといっていいほど出てきます。
そこで注目したのが AWS Glue に追加された Apache Iceberg の Materialized View(以下 MV) です。MV を「外部向けの安全なデータ参照レイヤー」として活用できるのではないかと考え、検証を始めました。
この記事はその検証シリーズの第1弾です。まずは MV を実際に作成するところまでを、手順とポイントをまとめながら紹介します。
シリーズ構成
- ① Glue Job で MV を作る ← 本記事
- ② 外部システムからデータを参照する (掲載予定)
- ③ Lake Formation で参照範囲を制御する (掲載予定)
ユースケースのイメージ
今回の検証のベースにあるユースケースは以下のようなイメージです。
[ 内部データ(rawテーブル)]
↓ Glue Job(Iceberg 形式で書き込み)
[ Iceberg テーブル群 ]
↓ Glue Job(MV 作成)
[ Materialized View ] ← 外部システムはここだけ参照
↓
[ 外部システム / Athena ]
内部のrawテーブルに直接アクセスさせるのではなく、MV を参照専用の公開レイヤーとして置く構成です。MV は集計済みデータを保持しているため、クエリのたびに重い JOIN・集計処理が走らず、外部システムからの参照コストも抑えられます。
検証環境
| コンポーネント | 内容 |
|---|---|
| ETL | AWS Glue Job(version 4.0)Iceberg 形式で書き込み |
| テーブル形式 | Apache Iceberg v2 |
| メタデータ管理 | AWS Glue Data Catalog |
| MV 作成 | AWS Glue Job(version 5.1) |
| ストレージ | Amazon S3 汎用バケット(※S3 Tablesとの比較は後述) |
| クエリエンジン | Amazon Athena v3 |
📝 S3 汎用バケット vs S3 Tables Iceberg テーブルの保存先は S3 汎用バケットと S3 Tables バケットの2択があります。今回は PoC のデータ規模(50万件)では性能差がほぼ出ないことから汎用バケットを選択しました。S3 Tables は Compaction・スナップショット管理が自動でフルマネージドですが、S3 Standard と比較してコストが約37%高いです。本番運用でデータ量・クエリ頻度が高い場合は S3 Tables の採用を検討してください。
| 項目 | S3 Tables | S3 汎用バケット |
|---|---|---|
| クエリ性能 | 最大3倍高速(AWS公称) | 基準 |
| トランザクション | 最大10倍高い TPS | 基準 |
| メンテナンス | Compaction・スナップショット管理が自動 | 手動または別途 Glue Job が必要 |
| コスト | S3 Standardより約37%高い | S3 Standard料金 |
前提条件
Glue バージョンは 5.1 以上が必須
Iceberg の Materialized View は Glue version 5.1 以上でのみサポートされています。4.0 以下では CREATE MATERIALIZED VIEW 構文が動作しないため、必ず 5.1 を指定してください。
ソーステーブルは Iceberg 形式で用意する
MV のソースとなるテーブルは Iceberg フォーマットで Glue Data Catalog に登録されている必要があります。今回は以下の 4 テーブルを事前に Iceberg 形式で用意しています。
| テーブル名 | 内容 | レコード数 |
|---|---|---|
| customers | 顧客マスタ | 1,000 件 |
| products | 商品マスタ | 500 件 |
| orders | 注文ヘッダ | 100,000 件 |
| order_items | 注文明細 | 500,000 件 |
Glue Job の設定
MV 作成ジョブの --conf パラメータには以下を設定します。
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions spark.sql.catalog.glue_catalog=org.apache.iceberg.spark.SparkCatalog spark.sql.catalog.glue_catalog.type=glue spark.sql.catalog.glue_catalog.warehouse=s3://<YOUR_BUCKET>/ spark.sql.catalog.glue_catalog.glue.region=<YOUR_REGION> spark.sql.catalog.glue_catalog.glue.id=<YOUR_ACCOUNT_ID> spark.sql.catalog.glue_catalog.glue.account-id=<YOUR_ACCOUNT_ID> spark.sql.defaultCatalog=glue_catalog spark.sql.optimizer.answerQueriesWithMVs.enabled=true spark.sql.materializedViews.metadataCache.enabled=true
Glue Job が正常に完了すると、以下のように Succeeded ステータスが確認できます。

MV 作成スクリプト
CREATE MATERIALIZED VIEW の構文
今回作成する MV は、4 テーブルを JOIN して顧客×地域×カテゴリ別の日次売上を集計するビューです。
CREATE MATERIALIZED VIEW glue_catalog.{database}.{mv_name} AS SELECT c.customer_id, c.customer_name, c.region, p.category, o.order_date, COUNT(o.order_id) AS order_count, SUM(oi.quantity) AS total_quantity, SUM(oi.quantity * oi.unit_price) AS total_sales FROM glue_catalog.{database}.customers c JOIN glue_catalog.{database}.orders o ON c.customer_id = o.customer_id JOIN glue_catalog.{database}.order_items oi ON o.order_id = oi.order_id JOIN glue_catalog.{database}.products p ON oi.product_id = p.product_id GROUP BY c.customer_id, c.customer_name, c.region, p.category, o.order_date
Glue Job スクリプト(全体)
import sys from awsglue.utils import getResolvedOptions from awsglue.context import GlueContext from awsglue.job import Job from pyspark.context import SparkContext args = getResolvedOptions(sys.argv, [ 'JOB_NAME', 'CATALOG_DATABASE', 'S3_WAREHOUSE_PATH', 'MV_NAME' ]) sc = SparkContext() glueContext = GlueContext(sc) spark = glueContext.spark_session job = Job(glueContext) job.init(args['JOB_NAME'], args) catalog_database = args['CATALOG_DATABASE'] mv_name = args['MV_NAME'] # defaultCatalog を設定 spark.conf.set("spark.sql.defaultCatalog", "glue_catalog") spark.conf.set("spark.sql.optimizer.answerQueriesWithMVs.enabled", "true") # 冪等性確保:既存 MV を削除してから再作成 spark.sql(f"DROP MATERIALIZED VIEW IF EXISTS glue_catalog.{catalog_database}.{mv_name}") # MV 作成 create_mv_sql = f""" CREATE MATERIALIZED VIEW glue_catalog.{catalog_database}.{mv_name} AS SELECT c.customer_id, c.customer_name, c.region, p.category, o.order_date, COUNT(o.order_id) AS order_count, SUM(oi.quantity) AS total_quantity, SUM(oi.quantity * oi.unit_price) AS total_sales FROM glue_catalog.{catalog_database}.customers c JOIN glue_catalog.{catalog_database}.orders o ON c.customer_id = o.customer_id JOIN glue_catalog.{catalog_database}.order_items oi ON o.order_id = oi.order_id JOIN glue_catalog.{catalog_database}.products p ON oi.product_id = p.product_id GROUP BY c.customer_id, c.customer_name, c.region, p.category, o.order_date """ spark.sql(create_mv_sql) job.commit()
実装上の制約
MV 定義のクエリには制約があります。特に増分更新(インクリメンタルリフレッシュ)を活かすには書き方に注意が必要です。詳細は公式ドキュメントの「Considerations and limitations」を参照してください。
実装のポイントまとめ
FROM 句は 3 階層フルパスで書く
defaultCatalog を設定していても、MV クエリ内のテーブル参照は glue_catalog.{database}.{table} のフルパスで記述します。省略するとテーブル解決エラーが発生することがあります。
DROP IF EXISTS で冪等性を確保する
MV の再作成や定義変更の際、既存 MV が残っていると CREATE が失敗します。スクリプト冒頭で DROP MATERIALIZED VIEW IF EXISTS を実行することで、何度実行しても安全に再作成できます。
⚠️ 商用運用での注意 DROP から CREATE が完了するまでの間、MV が存在しない状態になるため外部システムからのクエリがエラーになる瞬断リスクがあります。 また、Iceberg のスナップショット履歴が消えること、増分更新の蓄積が初期化されフルリフレッシュが毎回走ることにも注意が必要です。 なお、
ALTER MATERIALIZED VIEWは更新スケジュールの変更のみに対応しており、MV の定義(クエリ内容)の変更には使えません。CREATE OR REPLACE MATERIALIZED VIEWも非サポートのため、定義変更は現時点では DROP → CREATE 以外の方法はありません。 (Managing materialized views 参照) 商用運用では定期的なデータ更新にはREFRESH MATERIALIZED VIEWのみを実行し、 定義変更が必要な場合はメンテナンス時間を設けるか、新しい MV を別名で作成して参照先を切り替える方法を推奨します。
作成した MV の確認
Glue Job が正常に完了すると、Glue Data Catalog に MV が登録され、S3 に Iceberg 形式でデータが保存されます。

| 項目 | 値 |
|---|---|
| MV 名 | sales_summary_mv |
| データベース | poc_iceberg |
| フォーマット | Apache Iceberg v2(Parquet) |
| 集計グラニュラリティ | 顧客 × 地域 × カテゴリ × 日次 |
Glue Data Catalog 上で MV が登録されていることを確認できます。

Athena から以下のクエリで動作確認できます。
SELECT region, category, SUM(order_count) AS total_orders, SUM(total_sales) AS total_revenue FROM poc_iceberg.sales_summary_mv GROUP BY region, category ORDER BY total_revenue DESC;

補足: Athena からクエリするユーザー/ロールには、Lake Formation で当該 MV テーブルへの SELECT 権限を付与する必要があります。
リフレッシュの動作確認
MV はソーステーブルのデータが更新された際にリフレッシュすることで、最新の状態を保持できます。ここでは新しい注文データを追加し、リフレッシュ後に差分が反映されることをビフォーアフター形式で確認します。
⚠️ 注意:
REFRESH MATERIALIZED VIEWは Athena ではサポートされていません。リフレッシュは必ず Glue Job(Spark)経由で実行してください。
今回の MV はフルリフレッシュで動作する
今回作成した sales_summary_mv は COUNT + SUM の集計関数を使用しています。
公式ドキュメントの制限事項に「増分更新では集計関数を含めることはできない」と明記されているため、フルリフレッシュにフォールバックして動作します。
制限事項の詳細は公式ドキュメントの「Considerations and limitations」を参照してください。
手順
1. リフレッシュ前の MV を Athena でクエリする
SELECT region, category, order_date, SUM(order_count) AS order_count, SUM(total_quantity) AS total_quantity, SUM(total_sales) AS total_sales FROM poc_iceberg.sales_summary_mv WHERE region = '東京' AND order_date = DATE '2026-03-23' GROUP BY region, category, order_date;

2. ソーステーブルに新しいレコードを追加する
orders・order_items テーブルに注文日が2026-03-23のデータを追加登録します。
3. Glue Job で MV をリフレッシュする
リフレッシュ専用の Glue Job を実行します。MV 作成ジョブとは別ファイルとして分離しています。
# glue_job_refresh_mv.py # Glue version 5.1 以上が必要 import sys from awsglue.utils import getResolvedOptions from awsglue.context import GlueContext from awsglue.job import Job from pyspark.context import SparkContext args = getResolvedOptions(sys.argv, [ 'JOB_NAME', 'CATALOG_DATABASE', 'MV_NAME' ]) sc = SparkContext() glueContext = GlueContext(sc) spark = glueContext.spark_session job = Job(glueContext) job.init(args['JOB_NAME'], args) catalog_database = args['CATALOG_DATABASE'] mv_name = args['MV_NAME'] # defaultCatalog を設定 spark.conf.set("spark.sql.defaultCatalog", "glue_catalog") spark.conf.set("spark.sql.optimizer.answerQueriesWithMVs.enabled", "true") # MV リフレッシュ spark.sql(f"REFRESH MATERIALIZED VIEW glue_catalog.{catalog_database}.{mv_name}") job.commit()

4. リフレッシュ後の MV をクエリして差分が反映されていることを確認する
SELECT region, category, order_date, SUM(order_count) AS order_count, SUM(total_quantity) AS total_quantity, SUM(total_sales) AS total_sales FROM poc_iceberg.sales_summary_mv WHERE region = '東京' AND order_date = DATE '2026-03-23' GROUP BY region, category, order_date;

リフレッシュタイプの確認
リフレッシュタイプは CloudWatch Logs または Glue のログで確認できます。
フルリフレッシュの場合、Glue のログに以下のような出力が確認できます。
lastRefreshType=FULL
増分更新の場合、CloudWatch Logs に以下の DEBUG ログが出力されます。
DEBUG RefreshMaterializedViewExec: Executed Incremental Refresh
補足: 自動更新を設定する場合は ALTER MATERIALIZED VIEW でスケジュールを指定できます。
ALTER MATERIALIZED VIEW poc_iceberg.sales_summary_mv ADD SCHEDULE REFRESH EVERY 2 HOURS;
今回の sales_summary_mv は集計関数(COUNT + SUM)を含むため、ログに lastRefreshType=FULL が出力されフルリフレッシュで動作していることが確認できました。
補足: 自動更新を設定する場合は Glue Job から
ALTER MATERIALIZED VIEWでスケジュールを指定できます。python spark.sql(""" ALTER MATERIALIZED VIEW glue_catalog.{catalog_database}.{mv_name} ADD SCHEDULE REFRESH EVERY 2 HOURS """)今回の検証では手動更新での動作確認にとどめています。
まとめ
Glue 5.1 × Iceberg の組み合わせで Materialized View の作成ができました。
作成にあたって押さえておくべきポイントを整理します。
| # | ポイント |
|---|---|
| 1 | Glue version は 5.1 以上が必須 |
| 2 | --conf に type=glue + glue.region + MV 最適化フラグを設定する |
| 3 | FROM 句はフルパス(3階層)で記述する |
| 4 | DROP IF EXISTS で冪等性を確保する(PoC 環境向け) |
| 5 | REFRESH MATERIALIZED VIEW は Athena 非サポート。Glue Job(Spark)経由で実行する |
| 6 | 集計関数・ウィンドウ関数を含む MV はフルリフレッシュで動作する(増分更新非対応) |
MV 自体は JOIN・集計の結果を事前計算して保持する仕組みです。外部システムが毎回重いクエリを実行する代わりに、MV を参照するだけでよくなるため、外部向けの安全・軽量なデータ参照レイヤーとして活用できる可能性を感じています。
次回は実際に外部システムから MV を参照する構成を検証します。