Amazon AthenaへGlue Jobを使って100パーティションを超えるテーブルを作成する

# 目的

目的は「**1日1回実行のバッチ処理において、Athenaへ100を超えるパーティションを持つテーブルを作り直す形で更新する**」です。

## 背景

1. パフォーマンス向上の為、データ利用者がよく使う列に対してパーティションを切りたい
2. そのテーブルは巨大なファクトテーブルAと、パーティションに指定したい対象の列を持つディメンションテーブルBをJOINし毎日作り直す必要がある


Amazon Athenaでは、1クエリで作成できるパーティションの数が100までに制限されています。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/other-notable-limitations.html

> パーティションの最大数 – CREATE TABLE AS SELECT (CTAS) ステートメントで作成できるパーティションの最大数は 100 です。

よって、**1から作り直すようなテーブルに対して100よりも多いパーティションを作りたい場合に作ることができません**。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/ctas-insert-into.html

上記ページにはINSERT INTOで処理を分ける方法が記載されていますが、処理が煩雑になりますし、同じデータが入ってしまう懸念もあるので増分更新用のテーブルでなければ一度の処理で作りたいです。

Glue Job上でSparkを使い、IcebergフォーマットでS3にオブジェクトを配置し、Athenaへテーブルを作ることで**これについて対処できる**ので、一連の流れを書きました。

# Glue Job

Glue JobではAWSのAthenaにあるテーブルやS3データを読み込んで加工→S3とAthenaにロードするようなETL処理ができます。

https://docs.aws.amazon.com/ja_jp/glue/latest/dg/author-glue-job.html

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/296beba6-5b35-41b3-a30b-1d48f113c2f3.png)


Glue Jobの中にも種類がありますが、ビッグデータも処理できる最低2DPUから使用できるSparkを使います。
この他スモールデータバッチ処理用のPython ShellやSpark Streaming,Rayがあります。

![20250309.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/45934a97-1e15-4662-9d63-f28e47198e62.png)

ここにスクリプトを書き、

1. データをロード
1. 加工
1. 100を超えるパーティションを作りAthenaへテーブル作成

のETL処理を行い、目的を達成します。

Glueにはバージョンがあり、今回は5.0を使っています。

https://docs.aws.amazon.com/ja_jp/glue/latest/dg/release-notes.html

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/4398e0ad-2e14-4f98-a181-b20db0787670.png)


## 処理スクリプト

この記事ではテスト用データを使っているため、目的の流れとは違います。
ですが、少しのコード改変で達成できます。これについては[後述](#データ読み込み)しています。

テスト用データの流れは

1. データを生成
1. Athenaへテーブル作成とS3へデータ書き込み
1. Icebergテーブルの最適化と不要なオブジェクトの削除

です。

```python:ETL処理全容
import string
import random
import sys
from datetime import datetime
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from pyspark.sql.types import StructType, StructField, StringType, DoubleType
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME'])
sc = SparkContext()
glueContext = GlueContext(sc)
spark = glueContext.spark_session
job = Job(glueContext)
job.init(args['JOB_NAME'], args)
target_database = 'default'
target_table = 'iceberg_compaction_test_1'

# 生成行数
num_rows = 1000000000

# パーティション分割に使うカテゴリ
categories = [f"category_{i}" for i in range(200)]

# ランダム文字列生成
def random_string(length=50):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=length))

# RDDでデータ生成
rdd = spark.sparkContext.parallelize(range(num_rows)).map(lambda i: (
    random.choice(categories)    ,
    random_string(),
    random.uniform(0, 1000)
))

schema = StructType([
    StructField("category", StringType(), nullable=False, metadata=None),
    StructField("random_text", StringType(), nullable=False, metadata=None),
    StructField("value", DoubleType(), nullable=False, metadata=None)
])

df = spark.createDataFrame(rdd, schema)

# テーブルへデータ書き込み
df.writeTo(f"glue_catalog.{target_database}.{target_table}").tableProperty("format-version","2").partitionedBy("category").createOrReplace()

# AthenaでいうOPTIMIZE
spark.sql(f"CALL glue_catalog.system.rewrite_data_files(table=>'{target_database}.{target_table}')")

# AthenaでいうVACUUM
expire_time = (datetime.utcnow()).strftime('%Y-%m-%d %H:%M:%S')
spark.sql(f"CALL glue_catalog.system.expire_snapshots('{target_database}.{target_table}', TIMESTAMP '{expire_time}')")
spark.sql(f"CALL glue_catalog.system.remove_orphan_files(table=>'{target_database}.{target_table}')")

job.commit()
```

### データ読み込み

「データを読み込み」ですが、テスト用データではGlue Job上でデータを生成し作っています。
パーティション対象とする列のユニークな要素数が200になるようにデータを作っています。


```python:コード一部抜粋
# 生成行数
num_rows = 1000000000

# パーティション分割に使うカテゴリ
categories = [f"category_{i}" for i in range(200)]

# ランダム文字列生成
def random_string(length=50):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=length))

# RDDでデータ生成
rdd = spark.sparkContext.parallelize(range(num_rows)).map(lambda i: (
    random.choice(categories)    ,
    random_string(),
    random.uniform(0, 1000)
))

schema = StructType([
    StructField("category", StringType(), nullable=False, metadata=None),
    StructField("random_text", StringType(), nullable=False, metadata=None),
    StructField("value", DoubleType(), nullable=False, metadata=None)
])

df = spark.createDataFrame(rdd, schema)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/ef6b2674-90d3-438a-99e2-809272cee464.png)



データを生成ではなく、S3からデータをロードしたい場合は以下のように書けます。([他にもやり方はあります](https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-api-crawler-pyspark-extensions-glue-context.html#aws-glue-api-crawler-pyspark-extensions-glue-context-create_dynamic_frame_from_options)）
```python
df = spark.read.parquet("<s3_uri>")
```

Athenaからデータをロードしたい場合は以下のように書けます。

```python
# Hive式テーブルの場合
dyf = glueContext.create_dynamic_frame.from_catalog(
    database="default",
    table_name="sample_tsv",
    transformation_ctx="DataCatalogtable_node1",
)
df = dyf.toDF()

# Iceberg式テーブルの場合
# 参考 : https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-programming-etl-format-iceberg.html#aws-glue-programming-etl-format-iceberg-read

df = glueContext.create_data_frame.from_catalog(
    database="<your_database_name>",
    table_name="<your_table_name>"
)

```

GlueにはDynamicFrameクラスというスキーマの定義が不要なGlueに最適化されたSparkやPandasのDataFrameに似たクラスが存在します。これは上記コード例では`dyf`と表記しています。

https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-api-crawler-pyspark-extensions-dynamic-frame.html

SparkのDataFrameは`df`と表記しています。GlueではSparkContextをラップしたGlueContextクラスがあり、これによりSpark DataFrameを返すメソッドをAWSサービスと連携しやすくしています。

https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-api-crawler-pyspark-extensions-glue-context.html

加工やロードについては、Spark DataFrameで行うので、DynamicFrameで読み込んだデータも[toDF()](https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-api-crawler-pyspark-extensions-dynamic-frame.html#aws-glue-api-crawler-pyspark-extensions-dynamic-frame-toDF)でSpark DataFrameにしています。


### データ加工

テストコードではデータの加工は行っていませんが、SparkのDataFrameにした後はDataFrameに対するメソッドが使えるので、以下ドキュメントを参考に加工します。

https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html

`filter,groupBy,join,where,union`等Pandasに使い慣れていればすぐ分かるような内容になっています。

`join`については実際は巨大なファクトテーブルとディメンションテーブルをJOINするので、Broadcast JOINを使いました。

```python
from pyspark.sql.functions import broadcast
merged_df = df_big.join(
    broadcast(df_small),
    [df_big.user_id == df_small.user_id],
    "inner"
)
```

https://medium.com/@mjprub/optimizing-joins-in-apache-spark-the-power-of-broadcast-joins-af920476bbb4


ただ、Sparkは複数インスタンスから成る並列処理が行えるためそれ用のメソッドも存在します。
`cache,checkpoint,coalesce,persist,repartition,etc...`

パフォーマンスの観点も含めると上記メソッドも重要だと思います。私は以下の本をある程度読みました。

https://www.shoeisha.co.jp/book/detail/9784798186788

また、上記本の訳者である一人の @taka_yayoi さんもQiitaに[Sparkに関する記事を沢山上げて](https://qiita.com/search?sort=&q=user%3Ataka_yayoi+Spark)おられます。

### データ書き込み

コードでは以下の部分に当たります。シンプルです。


```python
df.writeTo(f"glue_catalog.{target_database}.{target_table}").tableProperty("format-version","2").partitionedBy("category").createOrReplace()
```

`partitionedBy()`でパーティション対象の列を選択します。`createOrReplace()`でテーブルを作り直します。
[後述します](#Iceberg最適化)が、`createOrReplace()`実行時に既にテーブルが存在する場合、[内部的にはAppendを実行](https://github.com/apache/spark/blob/d88db28910d0da398f6623f85ddcde55d77e1b24/sql/api/src/main/scala/org/apache/spark/sql/DataFrameWriterV2.scala)しているため、不要になったオブジェクトを削除する処理を後に加えています。

Icebergフォーマットでデータを書き込みテーブルをつくっているのですが、この場合はGlueの**Job detailsの項目で設定が必要**です。

![202503091.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/bfdd409f-df1f-45a4-afd7-058bb24772ea.png)

https://docs.aws.amazon.com/ja_jp/glue/latest/dg/aws-glue-programming-etl-format-iceberg.html

画像赤枠の通りですが、

> AWS Glue で Iceberg を有効化するには、以下のタスクを実行します。
> iceberg を --datalake-formats のジョブパラメータの値として指定します。詳細については、「AWS Glue ジョブでジョブパラメータを使用する」を参照してください。
> AWS Glue ジョブ用に、--conf という名前でキーを作成し、それに次の値を設定します。または、スクリプトで SparkConf を使用して、次の構成を設定することもできます。これらの設定は、Apache Spark が Iceberg テーブルを適切に処理する際に役立ちます。

```
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions 
--conf spark.sql.catalog.glue_catalog=org.apache.iceberg.spark.SparkCatalog 
--conf spark.sql.catalog.glue_catalog.warehouse=s3://<your-warehouse-dir>/ 
--conf spark.sql.catalog.glue_catalog.catalog-impl=org.apache.iceberg.aws.glue.GlueCatalog 
--conf spark.sql.catalog.glue_catalog.io-impl=org.apache.iceberg.aws.s3.S3FileIO
```

`--write-shuffle-files-to-s3`Keyは[シャッフルデータをS3に保存するための設定](https://docs.aws.amazon.com/ja_jp/glue/latest/dg/monitor-spark-shuffle-manager.html)で、必須ではないです。
シャッフル時の中間データをS3に書き出すことで、DPUメモリの利用を抑えることができます。


`spark.sql.catalog.glue_catalog.warehouse`は設定したバケット下に`database名.db`でicebergデータ用の階層が作られます。**既にDWH用にバケットがある場合はそこを指定したほうが良い**と思います。

### Iceberg最適化

icebergテーブルですが、使い続けるとオブジェクト数が多くなりパフォーマンスに影響します。
その為、「オブジェクトの統合」「不要オブジェクトの削除」を定期的にしてあげたほうが良いです。
何故そのようになるのかは以下ページが参考になります。

https://medium.com/snowflake/understanding-iceberg-table-metadata-b1209fbcc7c3

```python
# AthenaでいうOPTIMIZE
spark.sql(f"CALL glue_catalog.system.rewrite_data_files(table=>'{target_database}.{target_table}')")

# AthenaでいうVACUUM
expire_time = (datetime.utcnow()).strftime('%Y-%m-%d %H:%M:%S')
spark.sql(f"CALL glue_catalog.system.expire_snapshots('{target_database}.{target_table}', TIMESTAMP '{expire_time}')")
spark.sql(f"CALL glue_catalog.system.remove_orphan_files(table=>'{target_database}.{target_table}')")
```

AthenaにはIcebergテーブルを最適化&不要オブジェクト削除するコマンドがあり、OPTIMIZE,VACUUMという名前で用意されています。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/optimize-statement.html

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/vacuum-statement.html

ですが、こちらもAthenaの1クエリ100パーティション制限の影響か、**100パーティションを超えるテーブルに対して実行するとエラー**になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/94a4a2ab-2672-4b75-9a21-ba2c4666a42d.png)

なので、`spark.sql()`メソッドを使ってSpark上でIcebergテーブルを最適化・不要オブジェクトを削除するクエリを作成したテーブルに対して実行します。

https://iceberg.apache.org/docs/1.8.0/spark-procedures/#rewrite_data_files

Icebergには時点を遡るスナップショット機能がありますが、作り直しのテーブルの場合不要なので`expire_snapshots`で現時点の時間を指定し、期限切れになるように設定し`remove_orphan_files`で削除します。

## 実行結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/ff59f400-31ea-4823-b5d8-ad14f5b1e040.png)

処理データ量やタスク数などを調整して`Worker Type=G 2X`を10個立ち上げて11分程度で処理が終了しました。

実行すると、Athenaに対して100を超えるパーティションを持ったテーブルを作ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/b12ef228-1616-4c1c-ab5a-67fe1c09244b.png)

### Glue Job Spark UIでパフォーマンスの見直し

Glue JobのSpark UIから詳細のパフォーマンスが見られます。

処理によっては、`OPTIMIZE(rewrite_data_files)`に時間が掛かる場合がありました。

![image (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/2d662781-27bc-4a23-a7bd-33880d8c3aec.png)

`OPTIMIZE(SparkBinPackDataRewriter)`のJobが大量に出現
![image (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635079/010cc7ec-a7e3-4820-85b5-e575786c1eb4.png)


今回の実行は「ビッグデータの加工処理→テーブルを作る」ためのDPUスペックとDPU数で設定していて、
OPTIMIZEの場合それよりも処理の小さいタスクが大量に降ってくる=「DPUスペックよりもDPUの数が必要」ということだと思うので、**Glue Job内でOPTIMIZEするべきかどうかはチューニングに必要な時間、最適化することによる効果で判断したほうが良さげ**だと思いました。

Athenaではparquetの場合1オブジェクト`128MBより大きいと良い`ようです。

https://aws.amazon.com/jp/blogs/news/top-10-performance-tuning-tips-for-amazon-athena/

> Parquet と ORC は、さまざまなデータセットに合わせて調整できます。たとえば、ブロック (Parquet) またはストライプ (ORC) サイズを大きくすると、状況によっては有益な場合があります。データセットに多数のカラムがある場合は、Parquet のデフォルト 128MB、ORC のデフォルト 64MB から大きくすることをお勧めします。これにより、各カラムの十分な値が一緒に格納され、読み取りの回数が減ります。

また、S3 Tablesでは自動最適化機能があり、**ここではparquetファイルがMinimum 64MBで最適化**されるようです。

https://speakerdeck.com/bering/apache-iceberg-woxue-bi-amazon-s3-tables-wohuo-yong-siyou?slide=22

これを目安に1パーティション内のオブジェクト数を`aws s3 ls`等で見て確かめたほうが良いと思いました。

また、通常のS3オブジェクトの場合でもGlue Catalogから最適化の自動化ができるようです。

https://aws.amazon.com/jp/blogs/news/aws-glue-data-catalog-now-supports-automatic-compaction-of-apache-iceberg-tables/

Glue JobのDPUと同料金で動くようなので、金額面は変わりませんが実行は裏でやってくれるというメリットがあるということだと思います。


### Icebergオブジェクトのメンテナンスをしない場合

[Iceberg最適化](#Iceberg最適化)の部分ですが、行わない場合どうなるのかというと、createOrReplace()を行うたびにオブジェクト数が増加しパフォーマンスが低下していきます。Icebergの構造上使わないオブジェクトでもメタデータとして読みはするからです。


```bash:createOrReplace() 1回目
$ aws s3 ls s3://xxxxxxxx/default.db/iceberg_metadata_check/data/ --recursive --human --sum
Total Objects: 199
   Total Size: 283.9 KiB
```

```bash:createOrReplace() 2回目
$ aws s3 ls s3://xxxxxxxx/default.db/iceberg_metadata_check/data/ --recursive --human --sum
Total Objects: 398
   Total Size: 567.9 KiB
```

```sql:不要なオブジェクトを期限切れにしてVACUUM
ALTER TABLE default.iceberg_metadata_check SET TBLPROPERTIES (
  'vacuum_max_snapshot_age_seconds'='60'
);

VACUUM default.iceberg_metadata_check;
```

```bash:オブジェクト確認
$ aws s3 ls s3://xxxxxxxx/default.db/iceberg_metadata_check/data/ --recursive --human --sum
Total Objects: 199
   Total Size: 283.9 KiB
```

上記例は主に作り直しにおける過去データがパフォーマンス低下につながる例ですが、Appendで過去データも使う場合、パーティション内の参照データが細かくバラバラになるということもあるので、OPTIMIZEをしたほうが良いと思います。


# さいごに

無事100を超えるパーティションテーブルをAthenaに展開することができました。
実務で用いましたが、クエリ実行時間を大分抑えることができたので、今後も活用していこうと思います。

間違いやアドバイス等あればコメントいただけると嬉しいです。
ここまで読んでいただきありがとうございました。

## 参考

https://www.nri-digital.jp/tech/20240321-17576/

https://www.shoeisha.co.jp/book/detail/9784798186788

https://speakerdeck.com/bering/apache-iceberg-woxue-bi-amazon-s3-tables-wohuo-yong-siyou