CloudTrail LakeからCloudWatch Logsへの移行時のクエリ制約を検証してみた
はじめに
みなさんこんにちは、クラウド事業本部コンサルティング部の浅野です。
CloudTrail Lakeが2026年5月31日をもって新規顧客への提供を停止することがアナウンスされました。既存ユーザーは引き続き利用可能ですが、今後は機能追加が行われません。AWS公式の推奨移行先はAmazon CloudWatchです。
CloudTrail Lakeで利用しているクエリ機能をCloudWatch Logsに移行できるかどうかは、お客様の運用に直結する論点です。特にネストしたクエリがCloudWatch Logs Insightsでどこまで再現できるかは事前に確認しておきたいポイントでした。
移行にあたっては、以下の記事も参考にしてみてください。本記事ではコンソール操作とネストしたクエリの再現性を中心に検証していきます。
移行について
新規受付停止後もCloudTrail Lakeの既存ユーザーは引き続き利用可能ですが、AWS公式の推奨移行先はCloudWatch Logsです。本セクションでは移行判断のポイントを整理します。
Organization EDSとAccount EDSの違い
CloudTrail LakeのEDS(イベントデータストア)は2種類あり、新規受付停止後の扱いが異なります。
| 項目 | Organization EDS | Account EDS |
|---|---|---|
| 既存EDSの運用継続 | 可能 | 可能 |
| 新規メンバーアカウントの自動取り込み | 継続 | 不可 |
| 新規リージョンへの展開 | 継続 | 継続 |
Organization EDS運用なら当面そのまま継続できます。Account EDS運用は新規アカウントが取り込まれないため、Organization EDSへの切り替えかCloudWatch移行が必要になります(公式ドキュメント)。
CloudWatch Logsへの移行経路
既存データ(Lakeに蓄積済みのもの)はLakeの「Export to CloudWatch」機能でCloudWatch Logsへ移行できます。新規データ(今後発生するCloudTrailイベント)は別途、CloudTrail証跡(Trail)からCloudWatch Logsへ送信する設定を行う必要があります。
補足として、既存データのエクスポート時は以下の制約に注意してください。
- Infrequent Accessクラスが強制: 送信先Log GroupはStandardクラスを選択できません
- 送信先Log Groupは自動作成: 名前指定や既存Log Groupの指定は不可
- 2023年以前のデータは移行不可: それ以前が必要な場合はLake内クエリ継続またはS3エクスポートで対応
新規イベントの送信先ではLog Groupを自由に指定でき、Standardクラスで作成すればMetric Filters / Subscription Filtersも利用できます。
アーキテクチャの比較
| 機能 | CloudTrail Lake | CloudWatch Logs |
|---|---|---|
| データソース | 3 AWS / 16 3rd | 60+ AWS / 12 3rd |
| 多階層ネストSQL | 対応 | 非対応(1階層のみ) |
| クロスリージョン有効化 | 一括 | リージョンごとに必要 |
| ネイティブ分析 | SQL | Logs QL / OpenSearch SQL / PPL |
| CloudTrail Event Selector | フル対応 | Data Events Advanced Selectorのみ |
| データエクスポート | 対応 | S3 / S3 Tables / Subscription Filters(Standardのみ) |
| アラート・メトリクス | 非対応 | 対応(Standardのみ) |
| ダッシュボード | Managed / Highlights / Custom dashboards | CloudWatch Dashboards(Logs Insightsクエリをウィジェット化可能) |
ダッシュボード機能はLake・CloudWatchの双方にあります。Lake側はEDSに対するSQL結果をウィジェット化する方式、CloudWatch側はCloudWatch Dashboardsを作成しLogs Insightsクエリの結果をウィジェットとして配置する方式で、Lakeで運用していたダッシュボードの主要なグラフ・集計はCloudWatch Logs Insightsのクエリで再現できる範囲が多いです(ただしクエリ移植の手間と、ネストなどクエリ表現の制約は別途考慮が必要)。
CloudWatch Logsはアラート・メトリクス連携(Metric Filters / Subscription Filters)に対応している点が魅力です。ただしLake側の既存データのエクスポート先Log Group(IAクラス強制)では使えないため、アラート要件がある場合は新規イベントのみCloudTrail証跡からCloudWatch Logsへ送信する経路をStandardクラスのLog Group宛に用意する必要があります。
取り込み側の制約: CloudTrail Event Selector
CloudTrail Event Selectorは取得対象イベントを細かく絞るフィルタ機能で、Lakeはフルサポートですが、CloudWatch Logsへ取り込む経路では対応範囲が狭くなります(データイベント向けのAdvanced Selectorのみ対応)。例えば「特定のS3バケットへのPutObjectだけを取得」は引き続き組めますが、ネットワークアクティビティイベントの絞り込みなどはできません。
Infrequent Accessログクラスの制約
Lakeのエクスポート先Log GroupはIAクラスとなり、公式の機能比較のとおり以下が利用できません。
| 機能 | Standard | IA |
|---|---|---|
| Logs Insightsクエリ(Logs QL / SQL / PPL) | 対応 | 対応 |
| S3エクスポート / S3 Tables / クロスアカウント | 対応 | 対応 |
| Metric Filters / Subscription Filters | 対応 | 非対応 |
| Live Tail / Field Indexing / 異常検出 | 対応 | 非対応 |
| GetLogEvents / FilterLogEvents API | 対応 | 非対応 |
ログクラスは作成後に変更できないため、エクスポート先Log Groupは「クエリ・S3連携専用」として設計し、アラート要件があればCloudTrail証跡からCloudWatch Logsへ送信する経路をStandardクラスのLog Group宛に別途用意する必要があります。
コスト比較
最新の単価は公式の料金ページで確認してください。
以下の単価表は次の前提にて取得しています。
- リージョン: 東京(ap-northeast-1)
- CloudTrail Lakeの料金プラン: 1年延長可能保持(One-year extendable retention pricing)
- CloudWatch Logsのログクラス: Infrequent Access(Lakeからのエクスポート先で強制適用されるクラス)
- 含まないもの: KMS暗号化料金、リージョン間データ転送料金、CloudTrail証跡(Trail)へのS3配信料金
| 項目 | CloudTrail Lake (1年延長可能保持) | CloudWatch Logs (Infrequent Access) |
|---|---|---|
| 取り込み | $0.75 / GB | $0.38 / GB |
| ストレージ | $0(1年分含まれる)、1年経過後 $0.023 / GB-月 | $0.033 / GB-月 |
| クエリスキャン | $0.005 / GB | $0.0076 / GB |
単価だけ見ても全体感はつかみづらいので、一般的な監査ログ用途のシチュエーションで月額を試算します。
シチュエーション: 監査ログ用途
- 月間取り込み量: 100 GB(CloudTrail管理イベント想定)
- 月間クエリスキャン量: 100 GB
- 保持期間: 1年(1年運用後の定常状態)
- 無料枠:
- CloudWatch Logsの常時無料枠(取り込み5 GB/月・アーカイブストレージ5 GB-月・Logs Insightsスキャン5 GB/月)を反映。
- CloudTrail Lakeの30日/5GBトライアルは除外
| 内訳 | CloudTrail Lake | CloudWatch Logs IA(無料枠考慮) |
|---|---|---|
| 取り込み | 100 × $0.75 = $75.00 | (100 - 5) × $0.38 = $36.10 |
| ストレージ | $0(1年分含まれる) | (1200 - 5) GB-月 × $0.033 = $39.44 |
| クエリスキャン | 100 × $0.005 = $0.50 | (100 - 5) × $0.0076 = $0.72 |
| 月額合計 | $75.50 | $76.26 |
このシチュエーションの場合差は約 $0.7 でほぼ同水準でした。
今回は既存データのエクスポートのみをシチュエーションにしましたが、新規データの取り込みをCloudWatch LogsのStandardクラス(アラート要件で必要になる経路)で行う場合、Standardクラスの取り込み単価はIAクラスより高いため、CloudTrail Lakeより若干高めになる感触です。Standardクラスを使う必要があるかどうかでコスト差が出てくるので、アラート要件の有無も含めて検討するとよさそうです。
やってみた
ここから実際の検証手順です。以下の流れで進めます。
- CloudTrail Lakeでイベントデータストア(EDS)を作成
- Lakeのクエリエディタで4種類のSQLを実行
- EDSをCloudWatch Logsへエクスポート
- CloudWatch Logs Insightsで同じ4種類のクエリを再実行し、再現性を確認
イベントデータストアの作成
CloudTrailコンソールの「Lake」>「イベントデータストア」から「イベントデータストアの作成」を押下します。
ステップ1「イベントデータストアの設定」では基本情報を入力します。今回は以下の設定にしました。
- 名前:
demo-lake-verification - 料金オプション: 1年延長可能保持料金
- 保持期間: 7日
- 暗号化: AWSマネージドキー
検証目的なので保持期間は短くしています。

ステップ2「イベントの選択」では、取り込むイベントタイプを指定します。今回は以下の設定にしました。
- イベントタイプ: AWSイベント
- CloudTrailイベント: 管理イベント(全て)、データイベント(全て)、ネットワークアクティビティイベント
検証用にひと通りのイベント種類を有効化してEDSにデータが入ってくる状態にしています。実運用では必要なものだけに絞って課金量を抑えるのが基本です。

ステップ3「イベントをエンリッチ化し、大規模イベントを可能にする」はオプション扱いのステップで、リソース・プリンシパルタグキーの追加、IAMグローバル条件キーの追加、イベントサイズの拡大(既定の256 KBから1 MBへ)が設定できます。今回の検証では追加のエンリッチ化もイベントサイズ拡大も不要なため、いずれも既定の無効状態のまま進めます。

作成すると数分でイベントが蓄積され始め、イベントデータストア一覧に demo-lake-verification が登録されます。

CloudTrail Lakeでクエリ実行
データが蓄積されたところでLakeのクエリエディタで4種類のSQLを試します。方針として、CloudTrail Lakeで書いた4種類のクエリがCloudWatch Logs Insightsでも同じ感覚で書けるかを後ほど検証します。
エディタは「Lake」>「クエリ」>「エディタ」から開きます。左のEvent data storeで対象EDSを選択し、右にSQLを書いてRunで実行します。

クエリ1: 基本クエリ(フィルタ・ソート)
直近のイベントを発生時刻の新しい順に20件取得するだけのクエリです。
SELECT
eventTime,
eventName,
userIdentity.arn,
sourceIPAddress,
awsRegion
FROM <EDS_ID>
ORDER BY eventTime DESC
LIMIT 20
正常に実行され、ソート済みの最新イベントが返ってきました。

クエリの実行履歴は「コマンド出力」タブから確認できます。試行錯誤中の失敗ステータスもまとめて見えるため、書き直しながら検証する用途には便利です。

クエリ2: GROUP BY + 集計
直近24時間のIAMプリンシパルごとのAPIコール数を集計し、上位10件を出します。
SELECT
userIdentity.arn,
count(*) as api_calls
FROM <EDS_ID>
WHERE eventTime > date_add('hour', -24, now())
GROUP BY userIdentity.arn
ORDER BY api_calls DESC
LIMIT 10
集計結果が問題なく返ってきました。

クエリ3: サブクエリ(IN句)
まず内側のサブクエリで、直近24時間内に発生した書き込み系イベント(readOnly = false)のイベント名を DISTINCT で重複排除して列挙します。次に外側のクエリで、そのイベント名のいずれかに該当するイベントだけを抽出して新しい順に20件取得します。サブクエリのネストは1階層です。
SELECT
eventTime,
eventName,
userIdentity.arn
FROM <EDS_ID>
WHERE eventName IN (
SELECT DISTINCT eventName
FROM <EDS_ID>
WHERE eventTime > date_add('hour', -24, now())
AND readOnly = false
)
AND eventTime > date_add('hour', -24, now())
ORDER BY eventTime DESC
LIMIT 20
IN句のサブクエリも問題なく動作します。

クエリ4: ネストした集計(二重サブクエリ)
「平均よりも多くAPIコールしているユーザーのイベント一覧」を1クエリで取得します。手順としては、最も内側で userIdentity.arn ごとのAPIコール数を集計し、その1段外で avg(cnt) で全ユーザーの平均APIコール数を算出します。さらに外側で HAVING count(*) > 平均値 を満たすユーザー(userIdentity.arn)を絞り込み、最後に最外側でそのユーザーが発火したイベントの一覧を取得する、という4段階になります。サブクエリは2階層のネスト構造で、ここがCloudWatch Logs Insightsへの移行時に問題となるかを検証するポイントです。
SELECT
eventTime,
eventName,
userIdentity.arn
FROM <EDS_ID>
WHERE userIdentity.arn IN (
SELECT userIdentity.arn
FROM <EDS_ID>
WHERE eventTime > date_add('hour', -24, now())
GROUP BY userIdentity.arn
HAVING count(*) > (
SELECT avg(cnt) FROM (
SELECT count(*) as cnt
FROM <EDS_ID>
WHERE eventTime > date_add('hour', -24, now())
GROUP BY userIdentity.arn
)
)
)
AND eventTime > date_add('hour', -24, now())
ORDER BY eventTime DESC
LIMIT 20
Lakeでは二階層のサブクエリも正常に動作し、平均より多くAPIコールしているプリンシパルのイベントが返ってきました。

CloudWatchへエクスポート
次にLakeのデータをCloudWatch Logsにエクスポートします。エクスポートする際は以下の点に注意が必要です。
- 送信先Log Groupは自動作成(コンソールから既存Log Groupの指定や名前変更はできない)
- Infrequent Accessログクラスが強制適用(Standardクラスは選べない)
- エクスポート自体はCloudTrail側の追加料金なし。CloudWatch側のInfrequent Accessカスタムログ料金が発生
イベントデータストア詳細画面の下部にあるEvent export status欄から「Export to CloudWatch」ボタンを押下します。

設定項目は時間範囲とIAMロールのみで、Log Groupの指定UIはありません。今回はデータが少ないので時間範囲は省略(全期間)、IAMロールは新規作成にしました。
- Specify time range: 指定なし(全期間)
- IAM role: Create a new role
- IAM role name:
CloudTrailLake-export-role(プレフィックスはCloudTrailLake-ap-northeast-1-が自動付与)

Exportを実行するとEvent export statusが「進行中」になります。

しばらく待つと「完了済み」になり、Export IDと転送バイト数、CloudWatch managed log group(自動作成されたLog Group名)が確認できます。

CloudWatch側のLog Group詳細を開くと、ログクラスがInfrequent Accessになっていることが確認できます。仕様通りで、移行先Log GroupはIA強制です。

ログストリームから個別のログイベントを開こうとすると、Infrequent Accessクラスでは GetLogEvents API が使えないため「This operation is only supported on the Standard log class.」と表示されます。後述するように、IA Log Groupの中身はLogs Insights経由でクエリすることになります。

Log Groupの「ログストリーム」タブを開くと、エクスポートタスクのID(54ad0821-083c-46a0-993b-169b5b5c9e0e)を名前としたログストリームが1件作成されていることが分かります。

CloudWatch Logs Insightsでクエリ実行
エクスポートしたLog Groupに対して、Lakeで書いた4種類のクエリをCloudWatch Logs Insightsで書き直して試します。クエリ言語はエディタ下部のドロップダウン(画像では SQL ▼)から選択でき、本検証ではOpenSearch SQLを使用します。時間範囲は画面右上のカレンダーから絶対値で指定する必要があり、本検証では2026-04-16 00:00〜16:00を選択しました。画像はQuery 1相当のSQLを既に入力した状態で時間範囲ピッカーを開いた時点のものです。

クエリ1相当: 基本クエリ(フィルタ・ソート)
Lakeのクエリ1と同じく、直近のイベントを発生時刻の新しい順に20件取得するクエリです。userIdentity.arn のようにドット区切りのフィールドはバッククォートで囲む必要があります。
SELECT eventTime, eventName, `userIdentity.arn`, sourceIPAddress, awsRegion
FROM `aws/cloudtrail/<EDS_ID>`
ORDER BY eventTime DESC
LIMIT 20
Lakeとほぼ同じ構文でそのまま動作し、結果も同じように取得できました。

クエリ2相当: GROUP BY + 集計
Lakeのクエリ2と同じく、IAMプリンシパルごとのAPIコール数を集計し上位10件を出すクエリです。
SELECT `userIdentity.arn`, count(*) as api_calls
FROM `aws/cloudtrail/<EDS_ID>`
GROUP BY `userIdentity.arn`
ORDER BY api_calls DESC
LIMIT 10
GROUP BYとcount()の組み合わせは問題なく利用できます。

クエリ3相当: 1階層サブクエリ(IN句)
Lakeのクエリ3と同じく、書き込み系イベント名のサブクエリをIN句に渡して、書き込みイベントだけを抽出する1階層ネストのクエリです。
SELECT eventTime, eventName, `userIdentity.arn`
FROM `aws/cloudtrail/<EDS_ID>`
WHERE eventName IN (
SELECT DISTINCT eventName
FROM `aws/cloudtrail/<EDS_ID>`
WHERE readOnly = false
)
ORDER BY eventTime DESC
LIMIT 20
OpenSearch SQLは1階層のサブクエリには対応しているため、ここまではLakeからの書き換え不要で動作します。

クエリ4相当: 二重サブクエリ → エラー、2段階分割で代替
Lakeのクエリ4と同じ「平均よりも多くAPIコールしているユーザーのイベント一覧」を取得するクエリです。最も内側でユーザーごとのAPIコール数を集計し、その平均値を算出した上で、平均を超えるユーザーのイベント一覧を返す、という2階層ネストの構造になります。Lakeと同じ構造で書いて投げます。
SELECT eventTime, eventName, `userIdentity.arn`
FROM `aws/cloudtrail/<EDS_ID>`
WHERE `userIdentity.arn` IN (
SELECT `userIdentity.arn`
FROM `aws/cloudtrail/<EDS_ID>`
GROUP BY `userIdentity.arn`
HAVING count(*) > (
SELECT avg(cnt) FROM (
SELECT count(*) as cnt
FROM `aws/cloudtrail/<EDS_ID>`
GROUP BY `userIdentity.arn`
)
)
)
ORDER BY eventTime DESC
LIMIT 20
InvalidParameterException: Only one level of nesting per query is allowed のエラーで弾かれました。OpenSearch SQLのネストは1階層までという制約が明示的に効いています。

代替策として、クエリを2段階に分割します。まず「ユーザーごとのAPIコール数の平均値」を1階層サブクエリで計算します。
SELECT avg(api_calls) as avg_calls
FROM (
SELECT `userIdentity.arn`, count(*) as api_calls
FROM `aws/cloudtrail/<EDS_ID>`
GROUP BY `userIdentity.arn`
)
サブクエリは1階層に収まっているので問題なく動作し、平均値が得られました。

得られた平均値31を手動で埋め込んで2段目のクエリを実行します。
SELECT eventTime, eventName, `userIdentity.arn`
FROM `aws/cloudtrail/<EDS_ID>`
WHERE `userIdentity.arn` IN (
SELECT `userIdentity.arn`
FROM `aws/cloudtrail/<EDS_ID>`
GROUP BY `userIdentity.arn`
HAVING count(*) > 31
)
ORDER BY eventTime DESC
LIMIT 20
こちらも問題なく動作し、平均API数を上回るユーザーのイベント一覧を抽出できました(LakeとCloudWatch Logs Insightsで対象時間窓が異なるため、登場するユーザーやイベントの内訳はCloudTrail Lakeの結果と完全には一致しません)。

二階層ネストが必要なクエリは、移行時にクエリの2段階分割への書き換えなどが必要になる前提で計画してください。
最後に
CloudTrail Lakeで書いていた4種類のクエリのうち、3つはCloudWatch Logs InsightsのOpenSearch SQLでほぼそのまま動き、二重サブクエリだけが「1階層まで」の制約で動かないことが確認できました。回避策としてはクエリを2段階に分割する方法が現実解です。
一方で既存データのエクスポート先Log GroupはInfrequent Accessクラスが強制となり、Metric Filters / Subscription Filters / 異常検出 / リアルタイムなログイベント参照は使えなくなります。アラートを必要とする運用は、新規データの取り込みをCloudTrail証跡からStandardクラスのLog Groupへ送信する経路に分離する必要があり、Lakeからの移行=単純な置き換えではなく、運用設計の見直しを伴うプロジェクトになる前提で計画した方がよさそうです。
新規受付停止のアナウンスはありましたが、既存のOrganization EDSは当面そのまま継続できるため、慌てて移行する必要はありません。お客様の保持期間・クエリ運用・アラート要件を整理した上で、移行計画を立てていくのが現実的だと思います。
今回は以上です。最後までお読みいただきありがとうございました。









