diff --git "a/source/_posts/2026/20260630a_Workspace_Events_API_\343\201\247GWS\343\203\225\343\202\251\343\203\253\343\203\200\343\201\256\345\244\211\346\233\264\343\202\222\343\203\252\343\202\242\343\203\253\343\202\277\343\202\244\343\203\240\346\244\234\347\237\245\343\201\227\343\200\201\343\202\271\343\203\227\343\203\254\343\203\203\343\203\211\343\202\267\343\203\274\343\203\210\343\202\222_BigQuery_\343\201\253\350\207\252\345\213\225\345\217\226\343\202\212\350\276\274\343\201\277\343\201\231\343\202\213.md" "b/source/_posts/2026/20260630a_Workspace_Events_API_\343\201\247GWS\343\203\225\343\202\251\343\203\253\343\203\200\343\201\256\345\244\211\346\233\264\343\202\222\343\203\252\343\202\242\343\203\253\343\202\277\343\202\244\343\203\240\346\244\234\347\237\245\343\201\227\343\200\201\343\202\271\343\203\227\343\203\254\343\203\203\343\203\211\343\202\267\343\203\274\343\203\210\343\202\222_BigQuery_\343\201\253\350\207\252\345\213\225\345\217\226\343\202\212\350\276\274\343\201\277\343\201\231\343\202\213.md" index c78aa607701..33f7209aacf 100644 --- "a/source/_posts/2026/20260630a_Workspace_Events_API_\343\201\247GWS\343\203\225\343\202\251\343\203\253\343\203\200\343\201\256\345\244\211\346\233\264\343\202\222\343\203\252\343\202\242\343\203\253\343\202\277\343\202\244\343\203\240\346\244\234\347\237\245\343\201\227\343\200\201\343\202\271\343\203\227\343\203\254\343\203\203\343\203\211\343\202\267\343\203\274\343\203\210\343\202\222_BigQuery_\343\201\253\350\207\252\345\213\225\345\217\226\343\202\212\350\276\274\343\201\277\343\201\231\343\202\213.md" +++ "b/source/_posts/2026/20260630a_Workspace_Events_API_\343\201\247GWS\343\203\225\343\202\251\343\203\253\343\203\200\343\201\256\345\244\211\346\233\264\343\202\222\343\203\252\343\202\242\343\203\253\343\202\277\343\202\244\343\203\240\346\244\234\347\237\245\343\201\227\343\200\201\343\202\271\343\203\227\343\203\254\343\203\203\343\203\211\343\202\267\343\203\274\343\203\210\343\202\222_BigQuery_\343\201\253\350\207\252\345\213\225\345\217\226\343\202\212\350\276\274\343\201\277\343\201\231\343\202\213.md" @@ -26,7 +26,7 @@ lede: "Google Drive の共有フォルダにあるスプレッドシートが更 |:--|:--|:--| | 6/30(火)| 柴田健太さん | Workspace Events API で GWS フォルダの変更をリアルタイム検知し、スプレッドシートを BigQuery に自動取り込みする(この記事です) | | 7/1(水)| 真野隼記さん | ガイドライン公開しました | -| 7/2(木)| 鈴木風真さん | 【S3 Tables(Iceberg)】timestamptz 型の登録に苦戦した話 | +| 7/2(木)| 鈴木風真さん | [S3 TablesのIceberg形式で、timestamptz型の登録に苦戦した話](http://localhost:4000/articles/20260702a/) | | 7/3(金)| 棚井龍之介さん | TBD | | 7/6(月)| 片岡久人さん | TBD | diff --git "a/source/_posts/2026/20260702a_S3_Tables\343\201\256Iceberg\345\275\242\345\274\217\343\201\247\343\200\201timestamptz\345\236\213\343\201\256\347\231\273\351\214\262\343\201\253\350\213\246\346\210\246\343\201\227\343\201\237\350\251\261.md" "b/source/_posts/2026/20260702a_S3_Tables\343\201\256Iceberg\345\275\242\345\274\217\343\201\247\343\200\201timestamptz\345\236\213\343\201\256\347\231\273\351\214\262\343\201\253\350\213\246\346\210\246\343\201\227\343\201\237\350\251\261.md" new file mode 100644 index 00000000000..7c7532b0161 --- /dev/null +++ "b/source/_posts/2026/20260702a_S3_Tables\343\201\256Iceberg\345\275\242\345\274\217\343\201\247\343\200\201timestamptz\345\236\213\343\201\256\347\231\273\351\214\262\343\201\253\350\213\246\346\210\246\343\201\227\343\201\237\350\251\261.md" @@ -0,0 +1,214 @@ +--- +title: "S3 TablesのIceberg形式で、timestamptz型の登録に苦戦した話" +date: 2026/07/02 00:00:00 +postid: a +tag: + - S3 + - S3Tables + - Iceberg +category: + - Programming +thumbnail: /images/2026/20260702a/thumbnail.png +author: 鈴木風真 +lede: "S3 Tablesという型があります。今回、このtimestamptz型でカラムを登録しようとしたとき、いろいろ面白い発見があったので、記事にしたいと思います。" +--- + +[データエンジニアリング連載](/articles/20260630a/)の3本目です。 + +# はじめに + +こんにちは、フューチャーの鈴木風真です! + +S3 Tables(Iceberg形式)にはtimestamptz(タイムゾーンつきタイムスタンプ)という型があります。今回、このtimestamptz型でカラムを登録しようとしたとき、いろいろ面白い発見があったので、記事にしたいと思います。 + +# timestamptzとは + +timestamptzはApache Icebergにおける型の種類の一つで、タイムゾーン付きタイムスタンプと呼ばれます。その名の通り、「日本時間」などのタイムゾーン情報とともに時刻を保持できる型です。時差に左右されない世界中の「まさにあの瞬間!」という絶対的なタイミングを記録できる便利なデータ型です。 + +日本時間で書き込んでも別の国の時間で書き込んでも、内部的にはすべてUTC(協定世界時)に揃えて保存してくれるため、あとから時間を計算し直すような面倒な手間がかかりません。 + +# Athenaから登録することはできない!? + +Athenaからtimestamptz型を含むCREATE文を書いてS3 Tablesを登録してみましょう。するとこんなエラーがでます。 + +image.png + +timestamp with timezoneに変えて実行してもうまくいきません。AWS公式サイトを確認してみると、以下の文言が。。。 +>CREATE TABLE などの Athena Iceberg DDL ステートメントでサポートされているのは、Iceberg タイムスタンプ (タイムゾーンなし) のみですが、Athena を介してすべてのタイムスタンプ型をクエリできます。 +[Athena の Iceberg テーブルでサポートされているデータ型](https://docs.aws.amazon.com/ja_jp/athena/latest/ug/querying-iceberg-supported-data-types.html) + +つまり、AthenaではtimestamptzのSELECTはできるがCREATEはできないということです。(なんじゃそれ!)この時点で、AthenaでDDLを実行してテーブルを作成する選択肢はなくなりました。 + +調べてみると、AWS Glue経由でDDLを実行すると、timetamptz型も登録できるようです。DDL実行用のGlueジョブを作成するのは少々面倒でしたが、Glueを使えば、**DDLファイルの配置→配置をトリガーにDDLを実行** みたいなパイプラインを作るのもやりやすいかなと思い、Glueでやることにしました。 + +# Glue経由でDDLを実行 + +というわけで、簡単なDDL実行用のGlueジョブを作ってみました。 + +DDLファイルには複数のCREATE文を含む想定で、Glueジョブ側でステートメントごとにループ処理させるようにしました。既存のテーブルを登録しようとするとエラーになりますが、かといって```DROP TABLE IF EXISTS```みたいなことをやって事故るのは怖かったので、あえてそのままエラーになるようにしています。結果として、100行程度のシンプルなGlueジョブになりました。 + +```py +# ... (boto3やGlue、Spark関連の標準的なimport文は省略) ... + +# 接続先のカタログ名・ネームスペース名・バケットARN +S3TABLES_CATALOG = "s3tables" +S3TABLES_NAMESPACE = "default" +S3TABLES_BUCKET_ARN = "" + +def main() -> None: + # ... (ジョブパラメータ取得・Glue/Sparkセッションの初期化処理は省略) ... + # ※ ここでは変数 spark (SparkSession) と args が利用可能な状態とします + + # S3 Tables用のIcebergカタログをSparkに登録する + spark.conf.set( + f"spark.sql.catalog.{S3TABLES_CATALOG}", + "org.apache.iceberg.spark.SparkCatalog", + ) + spark.conf.set( + f"spark.sql.catalog.{S3TABLES_CATALOG}.catalog-impl", + "software.amazon.s3tables.iceberg.S3TablesCatalog", + ) + spark.conf.set( + f"spark.sql.catalog.{S3TABLES_CATALOG}.warehouse", + S3TABLES_BUCKET_ARN, + ) + + # テーブルの親となるネームスペースを作成する(未作成の場合のみ) + spark.sql( + f"CREATE NAMESPACE IF NOT EXISTS {S3TABLES_CATALOG}.{S3TABLES_NAMESPACE}" + ) + + # S3からDDLファイルをテキストとして読み込む + # ※ read_s3_file: boto3による単純なS3オブジェクト読み込み処理のため実装は省略 + ddl_content = read_s3_file(args["ddl_file_path"]) + + # 読み込んだDDLをセミコロンで分割し、実行可能なステートメントのリストに変換 + statements = parse_ddl(ddl_content) + print(f"[INFO] {len(statements)} 件のDDL文を実行します。") + + # ★ここがポイント: 分割したDDL文をループ処理で1つずつSparkSQLで実行する + for i, stmt in enumerate(statements, start=1): + spark.sql(stmt) + print(f"[INFO] DDL ({i}/{len(statements)}) 完了") + + print("[INFO] 全DDL実行完了。") + job.commit() + + +def parse_ddl(ddl_content: str) -> list[str]: + """ + DDLテキストをセミコロンで分割し、空行やコメントのみの断片を除外して返す関数 + """ + return [ + stmt.strip() + for stmt in ddl_content.split(";") + if any( + line.strip() and not line.strip().startswith("--") + for line in stmt.strip().splitlines() + ) + ] + +# ... (if __name__ == "__main__": などの呼び出し部は省略) ... +``` + +次にDDLファイルを用意します。その前に、S3 Tables(Iceberg)と、Glue(Spark SQL)、AthenaのSQLにおけるtimestamp型の対応を確認します。 + +## Icebergのタイムスタンプ型対応表 + +| Icebergの型 | 説明 | Spark SQLのDDL | AthenaのDDL | +| :--- | :--- | :--- | :--- | +| **`timestamptz`** | タイムゾーンつきタイムスタンプ | **`TIMESTAMP`** | **定義不可**
(※Spark等で作成する必要あり) | +| **`timestamp`** | タイムゾーンなしタイムスタンプ | **`TIMESTAMP_NTZ`** | **`TIMESTAMP`** | + +>[Apache Spark公式サイトを参考に作成](https://spark.apache.org/docs/latest/sql-ref-datatypes.html) + +表で書くと分かりやすいですが、ここに大きなトラップがありますね! +つまり、 + +- Spark SQLで TIMESTAMP と書く → timestamptz(タイムゾーンあり)になる +- Athenaで TIMESTAMP と書く → timestamp(タイムゾーンなし)になる + +という風に、**同じTIMESTAMPと指定しても真逆になる**ということです。 + +したがって、Spark SQLだとtimestamptzに相当するのが「TIMESTAMP」になるので、作成するSQLファイルでは単にTIMESTAMPと定義しておきます。 + +※当然「timestamptz」と定義すると「そんな型はない!」とエラーになります。 + +```sql +CREATE TABLE event_time ( + event_time timestamp COMMENT 'イベント日時' +) +USING iceberg +COMMENT 'イベント日次テーブル' +TBLPROPERTIES ( + 'format-version'='2', + 'write_compression'='zstd' +); +``` + +このDDLファイルをS3に配置します。Glue側でSQLファイルパスをパラメータで指定してあげると、それをGlueが見に行って実行してくれるようにしました。 + +image.png + +試しに実行してみます。すると1分ほどで成功します。簡単な処理でもGlueって結構遅いんですよね。 + +image.png + +実際にテーブルが作成されているかを見てみましょう。S3の画面から作成されたテーブルを見てみます。 + +image.png + +たしかに、テーブルが作成されていることを確認できます。しかしこれだと、型が確認できないので肝心のtimestamptz型が登録されているかが分かりません。そこで、Athenaから登録されたテーブルを見てみます。Athenaからはエディタの画面からテーブルのデータ型が確認できます。 + +image.png + +よしよし、ってあれっ?timestamptzになってない! + +# Athenaからtimestamptzは確認できない!? + +どういうわけか、わざわざGlue経由でDDLを実行したのに、timestamptzで登録されていないです。しかし、SparkにおけるtimestampはIcebergだとtimestamptzになるはず、、もしかして、Athena画面上の表示の問題か? ...というわけで、テーブル定義そのものを覗いてみることにしました。テーブルの実体は、S3のテーブルバケットから確認できるように、メタデータで定義されています。 + +40600451-1f33-483c-885e-c4340cedc2c9.png + +このファイルはダウンロードしてみることはできないので、AWSコマンドを叩いて覗くしかないです。以下コマンドを実行してみます。 + +```sh +aws s3 cp [テーブルメタデータARN] - | jq . +``` + +(jsonファイルの中身を取得し、コマンドライン上で見やすく表示させる簡単なコマンドです) +すると、こんな結果が返ってきます。 + +```json +"schemas": [ + { + "type": "struct", + "schema-id": 0, + "fields": [ + { + "id": 1, + "name": "event_time", + "required": false, + "type": "timestamptz", + "doc": "イベント日時" + } + ] + } + ], +``` + +いや、ちゃんとtimetamptzで登録されてる笑 + +逆にAthenaで登録したテーブルはしっかりタイムゾーン無しtimestamp型で登録されていることも確認できます。つまり、**Athenaではtimestamp型もtimestamptz型も、画面上では書き分けずに表示する仕様**っぽいです!ややこしいですね! + +S3 Tablesの型定義がS3の画面上から確認できないなど、S3 Tablesは新しい技術だからか、まだ整備されていない感があるなと思いました。 + +# おわりに~AI時代の技術ブログについて~ + +この記事では、試行錯誤する中での驚きだったり笑いなどの**感情**を乗せて書くことを意識しました。 + +AIで答えがすぐに得られる時代に、技術ブログに求められることは何だろうかと考えたとき、それは**ファクトと人間味**なのではないかと思います。AIはハルシネーションも起こすし、結局のところ「言っているだけ」です。だからこそ、「実際にやってみた」というファクトが価値を持つと思います。その次に人間っぽい感想。筆者が何を考え、感じたのか。一読者としてはそれが見たいです。 + +今回の記事も、せんじ詰めれば「timestamptzはAthenaからだと登録できないが、Glue経由なら登録が可能。型の確認はメタデータを直接見る必要がある」というだけです。でも、それだけだと面白くない。その答えにたどり着くまでの、推理・発見・驚き・笑いといった、人間味のある試行錯誤の過程が、一読者としては読みたいです。 + +自分含め、トラブルシュートで「ググる」人はどんどん減っている気がします。それに伴いあらゆるWebサイトは徐々に見られなくなっている。そんな時代でも「フューチャーの技術ブログは読みたい」と思ってもらえる記事を出していきたいなと思っています。最後まで読んでいただきありがとうございました! diff --git a/source/images/2026/20260702a/40600451-1f33-483c-885e-c4340cedc2c9.png b/source/images/2026/20260702a/40600451-1f33-483c-885e-c4340cedc2c9.png new file mode 100644 index 00000000000..d5bdd14fd2c Binary files /dev/null and b/source/images/2026/20260702a/40600451-1f33-483c-885e-c4340cedc2c9.png differ diff --git a/source/images/2026/20260702a/image.png b/source/images/2026/20260702a/image.png new file mode 100644 index 00000000000..d53bd3ed9e4 Binary files /dev/null and b/source/images/2026/20260702a/image.png differ diff --git a/source/images/2026/20260702a/image_2.png b/source/images/2026/20260702a/image_2.png new file mode 100644 index 00000000000..e4150ff081d Binary files /dev/null and b/source/images/2026/20260702a/image_2.png differ diff --git a/source/images/2026/20260702a/image_3.png b/source/images/2026/20260702a/image_3.png new file mode 100644 index 00000000000..8c2b220b8ea Binary files /dev/null and b/source/images/2026/20260702a/image_3.png differ diff --git a/source/images/2026/20260702a/image_4.png b/source/images/2026/20260702a/image_4.png new file mode 100644 index 00000000000..1635d5399ff Binary files /dev/null and b/source/images/2026/20260702a/image_4.png differ diff --git a/source/images/2026/20260702a/image_5.png b/source/images/2026/20260702a/image_5.png new file mode 100644 index 00000000000..4b90f86dd0a Binary files /dev/null and b/source/images/2026/20260702a/image_5.png differ diff --git a/source/images/2026/20260702a/thumbnail.png b/source/images/2026/20260702a/thumbnail.png new file mode 100644 index 00000000000..bc8913a21ba Binary files /dev/null and b/source/images/2026/20260702a/thumbnail.png differ