
S3 TablesのIcebergテーブルをGoから直接操作し、OpenTelemetryで内部を可視化してみた
こんにちは、ゲームソリューション部のsoraです。
今回は、GoのプログラムからS3 TablesのIcebergテーブルを直接操作し、その内部をOpenTelemetryのトレースで可視化してみました。
iceberg-goとS3 Tablesについて
iceberg-goはIcebergのGo実装で、ライブラリとしてアプリに組み込んで使います。
専用サーバは立てません。
アプリのプロセス内でメタデータを辿り、S3のParquetに直接アクセスします。
S3 TablesはIceberg専用のバケット種別で、Iceberg RESTエンドポイントを提供します。
- エンドポイント:
https://s3tables.<region>.amazonaws.com/iceberg - 認証: SigV4署名(署名名は
s3tables) - warehouse: テーブルバケットのARN
つまりアプリは、このRESTエンドポイントにSigV4で繋ぎ、Icebergのカタログとして扱います。
検証構成
構成はすべてTerraformで作りました。

- アプリは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コンソールからテーブルの中身もプレビューできます。

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への書き込み)が並びます。

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

これは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 |


全体の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 | × | 未対応。delete+appendで表現する |
| カタログ | ○ | REST / Glue など |
| SigV4署名 | ○ | WithSigV4RegionSvc(region, "s3tables") |
最後に
GoからS3 TablesのIcebergテーブルを操作し、その内部(Copy-on-Writeやファイルの読み飛ばし)をOpenTelemetryのトレースで可視化してみました。
この記事がどなたかの参考になれば幸いです。
参考リンク









