はじめに
7/18にJAWS-UG BigData支部のイベントがあり、参加してきました。
Apache IcebergにまつわるLT数個と運営の方が作成されたハンズオンという豪華なラインナップでした。
LTも非常に勉強になりましたが、ハンズオンを行った際にいくつか発見があったため、ハンズオン内容について、以下を中心に書いていきます。
気になる方はぜひ↓のイベントページをご覧ください。
- あえて前提設定を飛ばして、どんなエラーになるか確かめてみた話
- 動作検証の結果
- ハンズオン本編にはないCloudWatchダッシュボードを自作して見えたこと
ハンズオンの内容
ECS Fargateのログを FireLens で振り分けて Iceberg に貯めるハンズオン教材を一通り実施しました。
構成をざっくり書くと、こうなっています。
- ECS Fargate上のGo製ダミーOTelログジェネレーターがログを出力
- FireLens (Fluent Bit) がseverityで振り分け
- Amazon Data Firehose経由で3箇所に配信
- Amazon S3 … 全severityをそのまま
- Amazon CloudWatch Logs … ERROR / FATALのみ
- Icebergテーブル … ERROR / FATALのみ(S3 Tables と Glueセルフマネージドの2系統)
構成に出てくる用語を、先に整理しておきます。
- OTel(OpenTelemetry)… ログやメトリクスなどを統一フォーマットで扱うための標準仕様です。今回動いているのは「その形式に沿ったダミーログをひたすら出し続けるだけのアプリ」になります
- FireLens … ECSのログを好きな宛先に振り分けるための仕組みです。実体としてはログ処理エンジンの Fluent Bit がサイドカーコンテナとして動き、振り分けのルールを担当します
- severity … ログの重大度のことです。今回のダミーログは TRACE / DEBUG / INFO / WARN / ERROR / FATAL の6段階を出力していて、このうち ERROR と FATAL を「エラーログ」として扱います
- Apache Iceberg … 大量のデータファイルを「1つのテーブル」として扱うための、オープンなテーブルフォーマットの仕様です。データ本体とは別にメタデータを持つことで、スキーマ変更や過去バージョンの参照ができます。保存先やファイル形式は問いませんが、今回はS3上にParquetで置かれます
- S3 Tables … Icebergテーブルをマネージドで持てるS3の機能です。「テーブルバケット」という専用のバケットの中に、namespace(データベース相当)とテーブルを作ります。今回はこれと、自前でS3バケットとGlueカタログを組み合わせる「Glueセルフマネージド」の2系統を並べて比較しています
- Glue Data Catalog / Lake Formation … 前者はテーブルのメタデータを管理する台帳、後者はそこへのアクセス権限をIAMとは別のレイヤーで管理するサービスです。テーブルの実体・カタログ上の見え方・アクセス権限がそれぞれ別に管理されている点が、今回つまずいた部分の核心でした
S3 Tables と Lake Formation を実際に触れる教材はまだ多くないですし、私自身、触れる機会が今まで無かったので今回のハンズオンは貴重な機会でした。
前提
- 個人のサンドボックスアカウントで実施
- リージョンは
ap-northeast-1 - 作業用EC2からTerraformを実行する構成
あえて試した:「AWS 分析サービスとの統合」を有効化しないとどうなるか
手順書の冒頭「前提」には、事前にやっておく設定が2つ書かれています。
そのうちの1つが「AWS 分析サービスとの統合」の有効化です。
Amazon S3 → テーブルバケット → 「統合を有効にする」
手順書には「Athena、Redshift、EMR などのクエリエンジンからテーブルバケットへのアクセスを許可する機能」と説明があります。
読んだときに、「これを有効化しないまま進めたら、どこで、どんなエラーになるんだろう」というのが気になりました。
事前設定というのは、たいてい「やっておけば何も起きない」ものです。
逆に言うと、やらなかったときに何が起きるかは体験しないと分かりません。サンドボックスアカウントですし、あえて飛ばして進めてみることにしました。
結果:grant で Database not found
Lake FormationでFirehoseのロールに権限を付与するところで止まりました。
$ aws lakeformation grant-permissions --region "$AWS_REGION" \
--principal DataLakePrincipalIdentifier="$FIREHOSE_S3TABLES_ROLE" \
--permissions DESCRIBE \
--resource "{\"Database\":{\"CatalogId\":\"$S3TABLES_CATALOG\",\"Name\":\"$NAMESPACE\"}}"
An error occurred (InvalidInputException) when calling the GrantPermissions operation: Database not found.
面白いのは、この直前にTerraformでS3 Tablesのnamespaceとtableは作成済みだという点です。
作ったばかりのものに対して「Database not found」と言われます。もし理由を知らずにこのエラーだけ見たら、かなり混乱するかもしれないなと思いました。
エラーから原因にたどり着くまで
原因は分かった上での実験でしたが、せっかくなので「このエラーを見た人が、どう切り分けると原因にたどり着けるか」を順番に踏んでみました。
1. 環境変数は正しいか
$ echo "NAMESPACE=$NAMESPACE"
NAMESPACE=otel_log_pipeline_dev
$ echo "S3TABLES_CATALOG=$S3TABLES_CATALOG"
S3TABLES_CATALOG=xxxxxxxxxxxx:s3tablescatalog/otel-log-pipeline-dev-s3tables
正しくセットされています。変数の展開ミスではありません。
2. Lake Formation管理者に自分が入っているか
$ aws lakeformation get-data-lake-settings --region "$AWS_REGION" \
--query 'DataLakeSettings.DataLakeAdmins'
[
{
"DataLakePrincipalIdentifier": "arn:aws:iam::xxxxxxxxxxxx:role/handson-iceberg-ec2-role"
}
]
こちらも問題ありません。
3. そもそもカタログが存在するか
$ aws glue get-catalog --catalog-id "s3tablescatalog" --region "$AWS_REGION"
An error occurred (EntityNotFoundException) when calling the GetCatalog operation: Catalog not found.
ここで原因にたどり着きます。
Database not found と言われていたものの、実際にはその1つ上の階層であるカタログ自体が存在していなかったわけです。
なぜこうなるのか
「AWS 分析サービスとの統合」を有効化すると、S3 Tablesのテーブルバケットが s3tablescatalog という federatedカタログ としてGlue Data Catalogに登録されます。
この工程が無いと、S3 Tables側にテーブルが実在していても、Lake Formationから見るとnamespaceがDatabaseとして存在しません。そのため Database not found になります。
S3 Tables側にテーブルが存在することと、Glue / Lake Formationから見えることは別問題ということですね。
普通に手順どおり進めていたら気づかなかった部分だと思うので、あえて飛ばしてみた甲斐がありました。
有効化してみる
コンソールから統合を有効化したところ、カタログが作られました。
$ aws glue get-catalog --catalog-id "s3tablescatalog" --region "$AWS_REGION"
{
"Catalog": {
"CatalogId": "xxxxxxxxxxxx:s3tablescatalog",
"Name": "s3tablescatalog",
"FederatedCatalog": {
"Identifier": "arn:aws:s3tables:ap-northeast-1:xxxxxxxxxxxx:bucket/*",
"ConnectionName": "aws:s3tables",
"ConnectionType": "aws:s3tables"
},
...
}
}
namespaceも見えるようになりました。
$ aws glue get-databases --catalog-id "$S3TABLES_CATALOG" --region "$AWS_REGION" \
--query 'DatabaseList[].Name'
[
"otel_log_pipeline_dev"
]
注意点
同じことを試す場合、1点だけ気をつけてください。
手順書にも書かれていますが、この統合は解除方法が確立されていません。教材でも「戻し作業は対象外」とされています。
私は個人のサンドボックスアカウントだったので気にせず有効化しましたが、普段使いのアカウントで実施する場合は認識した上で進めた方がよいと思います。
補足:terraform apply を飛ばすと Invalid principal になる
こちらは意図せず引っかかった話なので、短く書いておきます。
統合を有効化してgrantを再実行したところ、今度は別のエラーになりました。
An error occurred (InvalidInputException) when calling the GrantPermissions operation:
Invalid principal, arn:aws:iam::xxxxxxxxxxxx:role/otel-log-pipeline-dev-firehose-s3tables-iceberg
付与先のIAMロールが存在していませんでした。
このロールはTerraform(iam_firehose.tf)が作るものです。手順書のステップ4には「権限不足で落ちる」というコメント付きの terraform apply があり、あれを流していないとロールが作られません。私は cd infra のところで手順を読み違えて、この無印の terraform apply を飛ばしていました。
素直に実行したところ、こうなりました。
Apply complete! Resources: 40 added, 0 changed, 0 destroyed.
手順書では「権限不足で落ちる」とされている箇所ですが、私は先に統合の有効化とLake Formation管理者への追加を済ませていたため、そのまま全部通ったようです。
なお「エラーメッセージが変わったら前進している」というのは、切り分けの指標として使えると改めて感じました。同じエラーが出続けるなら対処が的外れ、変わったなら一つ解決している、という判断ができます。
動作検証
無事デプロイできたので、E2E検証に進みました。
検証①:S3に全severityが蓄積されているか
Firehoseが配信した .gz を取得して、severityの分布を数えました。
$ gunzip -c /tmp/full-logs-sample.gz \
| python3 -c "import sys,json,collections; c=collections.Counter(json.loads(l)['severityText'] for l in sys.stdin if l.strip()); print(dict(c))"
{'FATAL': 52, 'INFO': 46, 'DEBUG': 45, 'TRACE': 56, 'ERROR': 42, 'WARN': 54}
6種類すべてが揃い、非エラーログも含まれています。絞り込みなしで全部流すルートとして正しい挙動です。
検証②:CloudWatch Logsにエラーのみが来ているか
30件取得して確認したところ、すべて ERROR か FATAL でした。INFO / WARN / DEBUG / TRACE はゼロ件です。
Fluent Bitのseverityベースルーティングが効いていることが確認できました。
検証③:Icebergテーブルにエラーログのみが入っているか
Athenaから確認しました。
Glueセルフマネージド側
SELECT count(*) AS total,
count_if(severity_text IN ('ERROR','FATAL')) AS error_rows,
count_if(severity_text NOT IN ('ERROR','FATAL')) AS non_error_rows
FROM "otel_log_pipeline_dev_logs"."errors";
total 104
error_rows 104
non_error_rows 0
S3 Tables側
federatedカタログなので3階層パスで参照します。
FROM "s3tablescatalog/otel-log-pipeline-dev-s3tables"."otel_log_pipeline_dev"."error_logs"
total 103
error_rows 103
non_error_rows 0
両方とも error_rows = total かつ non_error_rows = 0 でした。severity分布も ERROR と FATAL の2種類のみです。
S3 Tables側が1件少ないのはバッファのタイミング差なので問題ありません。
CloudWatchダッシュボードを作って可視化してみた
ここからはハンズオン本編にはない部分です。
Firehoseのモニタリングタブを1ストリームずつ見ていたのですが、3本のストリームを並べて比較したくなったので、CloudWatchダッシュボードを作りました。
put-dashboard にJSONを渡せばCLIで作れます。
cat > /tmp/dashboard.json << 'EOF'
{
"widgets": [
{
"type": "metric", "x": 0, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Iceberg 成功行数 vs 失敗行数",
"region": "ap-northeast-1",
"stat": "Sum", "period": 300,
"metrics": [
[ "AWS/Firehose", "DeliveryToIceberg.SuccessfulRowCount", "DeliveryStreamName", "otel-log-pipeline-dev-s3tables-iceberg", { "label": "S3Tables 成功" } ],
[ "...", "otel-log-pipeline-dev-glue-iceberg", { "label": "Glue 成功" } ],
[ "AWS/Firehose", "DeliveryToIceberg.FailedRowCount", "DeliveryStreamName", "otel-log-pipeline-dev-s3tables-iceberg", { "label": "S3Tables 失敗" } ],
[ "...", "otel-log-pipeline-dev-glue-iceberg", { "label": "Glue 失敗" } ]
]
}
}
]
}
EOF
aws cloudwatch put-dashboard \
--dashboard-name otel-log-pipeline-dev \
--dashboard-body file:///tmp/dashboard.json \
--region ap-northeast-1
"..." は「直前のメトリクスと同じ名前空間・メトリクス名で、ディメンションだけ差し替える」という省略記法です。手書きするときに便利でした。
作ったウィジェットは以下の4つです。
- Iceberg 成功行数 vs 失敗行数
- Iceberg コミット成功数
- 受信レコード数(3ストリーム)
- 配信の鮮度(秒)/ S3成功率
Tips:メトリクス名は list-metrics で確認する
最初、メトリクス名をうろ覚えで書いてしまい「データがありません」になりました。
対象のストリームが実際に発行しているメトリクスは、以下で一覧できます。
aws cloudwatch list-metrics --namespace AWS/Firehose \
--dimensions Name=DeliveryStreamName,Value=otel-log-pipeline-dev-s3tables-iceberg \
--region ap-northeast-1 --query 'Metrics[].MetricName' --output text | tr '\t' '\n' | sort -u
DeliveryToIceberg.ActivePartitionsCount
DeliveryToIceberg.Bytes
DeliveryToIceberg.DataFreshness
DeliveryToIceberg.FailedRowCount
DeliveryToIceberg.IncomingRowCount
DeliveryToIceberg.Success
DeliveryToIceberg.SuccessfulRowCount
DeliveryToIceberg.TableCount
DeliveryToIceberg.WriteAttemptSuccess
...
面白いことに、公式ドキュメントの一覧に載っていないメトリクスがありました。
ドキュメントに記載があるのは Bytes / IncomingRowCount / SuccessfulRowCount / FailedRowCount / DataFreshness / Success / JQProcessing.Duration の7つですが、実際には以下も発行されています。
DeliveryToIceberg.WriteAttemptSuccessDeliveryToIceberg.TableCountDeliveryToIceberg.ActivePartitionsCount
ダッシュボードを組む前にこれを叩いておくと確実だと思います。
ダッシュボードでわかったこと
以下個人的に面白いと思った点です。
失敗行数はゼロだった
ECSが動き始めてからFirehoseロールに Lake Formation の権限を付与するまでの間、S3 Tables側は権限が足りていない状態でした。その間の配信は失敗して FailedRowCount に少なくとも数件は出ていると思っていました。
しかし、結果はゼロでした。
理由はFirehoseのリトライです。FailedRowCount は「S3バックアップバケットに配信された失敗行数」と定義されていて、最終的に諦めた行だけがカウントされます。Firehoseは配信エラーをリトライし、retry duration が切れた時点でS3バックアップバケットに書き出します。逆に言えば、リトライ中に問題が解消すればそのまま正常に配信され、失敗としては計上されません。今回の構成は retry_duration = 300 なので、5分の猶予があったことになります。
実際、後片付けでS3バケットを空にしたときも、失敗データの退避先である firehose-s3tables-iceberg-errors/ 配下にはオブジェクトが1件もありませんでした。本当にロストゼロだったようです。
配信が滞ると DataFreshness に現れる
DeliveryToIceberg.DataFreshness は、Firehose内にある最も古いレコードが、入ってきてから今まで何秒経ったかを表すメトリクスです。
ECSサービスを停止した直後、この値が6.8分近くまで跳ね上がりました。新しいデータが来なくなり、バッファ内の最古レコードが押し出されなくなったためです。
失敗数だけ見ていると「問題なし」に見えてしまう場面でも、配信の滞りは鮮度の方に現れます。
Firehoseの監視項目を選ぶとき、FailedRowCount だけでなく DataFreshness も入れておきたいと思いました。
値が0のときデータ点は「消える」
後片付けの過程で気づいたことです。
ECSを止めた後、「受信レコード数が0になるのを待とう」と思ってメトリクスを叩き続けたのですが、いつまで経っても 0.0 という行が出てきません。
| 2026-07-18T06:06:00+00:00 | 174.0 | ← ここが最後
(06:11 の行が存在しない = 受信ゼロ)
受信がないとFirehose側がメトリクスを発行しないため、データ点そのものが存在しない状態になります。
つまり「0が表示されるのを待つ」のではなく「行が現れなくなったこと」で判断する必要がありました。
これはCloudWatchアラームを組むときの TreatMissingData の話にも直結します。データが来なくなった障害を「欠測」として扱うか「異常」として扱うか、設計時に意識しないと検知漏れになりそうです。
後片付け
課金リソースを残さないよう、検証後は削除します。思いつきでダッシュボードを作っておいたおかげで、後片付けの進行メーターとして使えたのが思わぬ副産物でした。
手順としては以下の順番で進めました。
- ECSサービスを停止(
--desired-count 0)してログ生成を止める - ダッシュボードで受信レコード数が落ちるのを確認
- Icebergへの配信も止まるのを確認(バッファ間隔300秒なので最大5分)
- S3バケットを空にする
terraform destroy- ECRリポジトリ削除、Lake Formation権限のrevoke
- CloudWatchダッシュボードを削除
- コンソールでIAMロール削除、EC2終了、Lake Formation設定を元に戻す
2〜3を目視で確認できると、手順書にある「遅れて配信されたログが流れてS3が削除できない」という事象を確実に避けられます。
7のダッシュボードは自作したものでTerraform管理外なので、terraform destroy では消えません。手動で削除する必要があります。
aws cloudwatch delete-dashboards --dashboard-names otel-log-pipeline-dev --region ap-northeast-1
記事用のスクリーンショットを撮るなら、この削除の前に済ませておいてください。
最後にコンソールでの作業です。IAMロールを削除し、作業用EC2を終了します。EC2を終了するとTerraformのstateも失われるので、必ず terraform destroy を済ませてから終了してください。
あわせて、前提として有効化した2つの設定のうち Lake Formation のほうは元に戻せます。
AWS Lake Formation → Data Catalog settings → チェックボックスを2つ付け直す
もう一方の「AWS 分析サービスとの統合」は、冒頭に書いたとおり解除方法が確立されていないため戻せません。s3tablescatalog はアカウントに残ったままになります。中身は空で課金も発生しませんが、この非対称は把握しておくとよいと思います。
destroyは無事完走しました。
Destroy complete! Resources: 43 destroyed.
事前に検証用ロールへ DROP / ALTER / DELETE を付与していたおかげで、Lake Formationの権限エラーは出ませんでした。
削除ログを眺めるとIcebergの物理構造が見えて面白かったです。
iceberg/errors/data/00001-194-xxxx-00001.parquet
iceberg/errors/metadata/snap-xxxxxxxxxxxx-1-xxxx.avro
iceberg/errors/metadata/00004-xxxx.metadata.json
データ本体のParquet、スナップショットやマニフェストのAvro、メタデータのJSONがそのまま並んでいました。
まとめ
今回確認できたことをまとめます。
| 確認したこと | 結果 |
|---|---|
| 「AWS 分析サービスとの統合」を有効化せずに grant するとどうなるか |
Database not found。テーブルは実在するが、federatedカタログ自体が作られていないため |
terraform apply を飛ばすとどうなるか |
Invalid principal。付与先のIAMロールが未作成のため |
動作検証の結果です。
| 検証項目 | 結果 |
|---|---|
| S3に全severityが蓄積される | ✅ |
| CloudWatch LogsにERROR / FATALのみ | ✅ |
| Glue Icebergにエラーログのみ | ✅ |
| S3 Tables Icebergにエラーログのみ | ✅ |
学んだこと
- S3 Tablesにテーブルがあることと、Glue / Lake Formationから見えることは別問題。統合の有効化が必要
- Firehoseのリトライは優秀で、権限を後から付けてもデータロスしなかった
- 配信の滞りは失敗数ではなく鮮度に出る。
DataFreshnessは監視項目に入れたい - メトリクス名は
list-metricsで実物を確認する。ドキュメント未記載のものもある - 値が0のときはデータ点そのものが出ない。
TreatMissingDataの設計に直結する
最後に
「エラーが出た=データが失われた」ではないこと、そして「メトリクスの選び方次第で異常が見えなくなる」ことを、自分の手を動かした結果として体感できたのが良かったです。
事前設定をあえて飛ばしてみたのも、やってよかったと思っています。手順どおりに進めていたら「そういうものか」で通り過ぎていた部分が、エラーを通して構造として理解できました。
今回こちらのイベントに参加できて良かったです。
参考リンク



