S3 TablesのIcebergテーブルをGoから直接操作し、OpenTelemetryで内部を可視化してみた

S3 TablesのIcebergテーブルをGoから直接操作し、OpenTelemetryで内部を可視化してみた

GoのプログラムからS3 TablesのIcebergテーブルを直接操作し、OpenTelemetryのトレースで内部動作を可視化してみました。Copy-on-Writeの実態やファイル読み飛ばしの仕組みが、トレースでどう見えるかをお見せします。
2026.07.24

こんにちは、ゲームソリューション部のsoraです。

今回は、GoのプログラムからS3 TablesのIcebergテーブルを直接操作し、その内部をOpenTelemetryのトレースで可視化してみました。

iceberg-goとS3 Tablesについて

iceberg-goはIcebergのGo実装で、ライブラリとしてアプリに組み込んで使います。
専用サーバは立てません。
アプリのプロセス内でメタデータを辿り、S3のParquetに直接アクセスします。

https://github.com/apache/iceberg-go

S3 TablesはIceberg専用のバケット種別で、Iceberg RESTエンドポイントを提供します。

  • エンドポイント: https://s3tables.<region>.amazonaws.com/iceberg
  • 認証: SigV4署名(署名名はs3tables
  • warehouse: テーブルバケットのARN

つまりアプリは、このRESTエンドポイントにSigV4で繋ぎ、Icebergのカタログとして扱います。

検証構成

構成はすべてTerraformで作りました。

01-architecture

  • アプリはECS Fargate(public subnet)で常駐HTTPサーバとして動かす。
  • トレースは同一タスクのADOT Collectorサイドカー経由で、別EC2のTempoへ送る。
  • Tempoはトレースの保管先をS3にする。
  • Grafanaはprivate subnetに置き、SSMポートフォワードで覗く。

Icebergのデータを置くS3 Tablesと、トレースを置くS3バケットは別物です。

アプリの実装(spanの計装)

アプリはiceberg-gov0.6.0を使い、REST+SigV4でS3 Tablesに繋ぎます。

cat, err := rest.NewCatalog(ctx, "s3tables", uri,
    rest.WithSigV4RegionSvc(region, "s3tables"),
    rest.WithWarehouseLocation(warehouse),
)

S3を扱うには、FileIOを登録するために次のblank importを入れておきます。

// これが無いと "io scheme not registered for path s3://..." で全操作が失敗する
import _ "github.com/apache/iceberg-go/io/gocloud"

各操作には手動でspanを切ります。
たとえばappendはこうです。

ctx, span := tracer.Start(ctx, "AppendTable", trace.WithAttributes(attribute.Int("rows", rows)))
defer span.End()

tbl, err := s.ensureTable(ctx)          // 無ければCreateTable
newTbl, err := tbl.AppendTable(ctx, arr, 1024, nil)

エンドポイントは3つに絞りました。

メソッド パス 操作 span
POST /append?rows=3 追加(初回はテーブル作成も) AppendTable
GET /scan?id=1004 読み出し(idでフィルタ) Scan
POST /delete?id=1004 行削除 DeleteRows

GoからIcebergテーブルを操作してみる

デプロイ後、アプリを叩いてみます。

curl -X POST "http://$IP:8080/append?rows=3"   # 3行追加
curl "http://$IP:8080/scan"                    # 全件
curl "http://$IP:8080/scan?id=1004"            # フィルタ
curl -X POST "http://$IP:8080/delete?id=1004"  # 削除
curl "http://$IP:8080/scan?id=1004"            # 削除後

実行結果がこちらです。

$ curl -X POST 'http://APP:8080/append?rows=3'
appended 3 rows. metadata=s3://...--table-s3/metadata/00010-....metadata.json

$ curl 'http://APP:8080/scan'
scanned 22 rows

$ curl 'http://APP:8080/scan?id=1004'
scanned 1 rows

$ curl -X POST 'http://APP:8080/delete?id=1004'
deleted id=1004. metadata=s3://...--table-s3/metadata/00011-....metadata.json

$ curl 'http://APP:8080/scan?id=1004'
scanned 0 rows

削除後のscanが0件になり、作成・追記・読み取り・削除が一通り効いていることが確認できました。
操作のたびにmetadata.jsonが00010→00011と採番されているのもポイントです(イミュータブルなコミット)。

S3 Tablesコンソールからテーブルの中身もプレビューできます。

02-s3tables-preview

Icebergのメタデータを覗いてみる

S3 Tablesの実体ファイルはAWS管理の隠しバケットにあり、aws s3 ls(LIST)はできません。
ですがmetadata.jsonは個別GETできるので、カタログのポインタから取得できます。

LOC=$(aws s3tables get-table --table-bucket-arn $ARN --namespace demo --name events --query metadataLocation --output text)
aws s3 cp "$LOC" - | jq '.snapshots[] | {seq: ."sequence-number", op: .summary.operation, added: .summary."added-records", deleted: .summary."deleted-records"}'

スナップショット履歴に、Goが打ったappend/deleteがそのまま残っていました。

seq=1  op=append  added=3  deleted=-
seq=2  op=delete  added=2  deleted=3
seq=3  op=append  added=3  deleted=-
...

metadataLocationがカタログの指すポインタ、versionTokenがcompare-and-swapのトークンです。
「今がNならN+1に差し替える」というアトミックなコミットが、ここで効いています。

トレースを可視化する

SSMポートフォワードでGrafanaを開き、Tempoでトレースを見ます。

appendのトレースを開くと、AppendTableの下にgocloud.dev/blob.NewWriter(S3への書き込み)が並びます。

03-trace-append-waterfall

ここで出ているgocloud.dev/blob.NewWriterNewRangeReaderのspanは、こちらで計装したものではありません。
iceberg-goはS3へのアクセスにgocloud.dev/blobというライブラリを使っており、このライブラリ自体がOpenTelemetryに対応しています。
そのおかげで、S3オブジェクトへの書き込みや読み取りが自動でspanになり、metadata.jsonやmanifest、data fileへのアクセスがそのままトレースに現れます。

deleteはCopy-on-Writeで「読んで書き直す」

deleteのトレースを見ると、appendがNewWriter(書き込み)主体だったのに対し、NewRangeReader(読み取り)だらけになります。

04-trace-delete-waterfall

これはIcebergのCopy-on-Writeの実体です。
1行消すために、その行を含むファイルを読んで、残る行を新しいファイルに書き直しています
先ほどのメタデータでもdeleteはadded=2 / deleted=3となっていて、「3行のファイルを読んで2行で書き直した」ことが数字で分かります。

実測でも、append 466msに対してdelete 642msと、deleteの方が重くなりました。
「Icebergを入れたら遅くなった」の一因がここにある、というのがトレースで腹落ちします。

非パーティション列でもファイルを読み飛ばせる

appendを何回か叩き、idの範囲が重ならないデータファイルを複数作ってから、フィルタあり・なしでscanを比べます。

curl "http://$IP:8080/scan"          # フィルタなし
curl "http://$IP:8080/scan?id=1004"  # フィルタあり

Tempo APIでトレース内のspan数と、そのうちのNewRangeReader(S3読み取り)の数を数えると、以下のようになりました。

scan S3読み取り(NewRangeReader) trace全体のspan数 duration
/scan(全体) 52 54 874ms
/scan?id=1004(フィルタ) 16 18 461ms

05-trace-scan-all-v2

06-trace-scan-filtered

全体のscanはNewRangeReaderが画面に収まらないほど並びます(画像は一部を切り出しています)。
一方でフィルタ付きのscanは、NewRangeReaderのspanがはっきり減っているのが分かります。

フィルタでS3読み取り(NewRangeReader)が52→16に減りました。

ここで重要なのは、idはパーティション列ではないという点です。
Hive形式のテーブルでも、Parquetのフッタにある行グループ単位のmin/maxを使えば、非パーティション列での絞り込み自体はできます。

ただしその判定にはファイルを開く必要があるため、ディレクトリのLISTと全ファイルへのアクセスは避けられません。
Icebergは各データファイルの列min/maxをmanifestに持つので、ファイルを開く前に候補から除外できます。

補足: トレースの保管先バケットの中身

今回はTempoの保管先をS3にしました。
Tempo用のバケットを覗くと、こういうファイルが並びます。

single-tenant/<UUID>/data.parquet         # トレース本体(span群)。Tempo独自のParquet
single-tenant/<UUID>/bloom-0              # Bloomフィルタ
single-tenant/<UUID>/index                # ブロック内索引
single-tenant/<UUID>/meta.json            # ブロックのメタ
single-tenant/index.json.gz               # テナントインデックス

Tempoはトレースを、bloomフィルタとインデックスで絞り込めるように、独自のParquetブロックとして保管します。

iceberg-go v0.6.0でできること・できないこと

操作 可否 備考
Append Table.AppendTable
Overwrite Table.OverwriteTable
行削除(Copy-on-Write) Table.Delete(述語で指定)
upsert / MERGE × 未対応。deleteappendで表現する
カタログ REST / Glue など
SigV4署名 WithSigV4RegionSvc(region, "s3tables")

最後に

GoからS3 TablesのIcebergテーブルを操作し、その内部(Copy-on-Writeやファイルの読み飛ばし)をOpenTelemetryのトレースで可視化してみました。
この記事がどなたかの参考になれば幸いです。

参考リンク

https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-integrating-open-source.html
https://grafana.com/oss/tempo/
https://opentelemetry.io/docs/languages/go/

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事