【Terraform】Cost & Usage ReportをAthenaで分析する
はじめに
AWSのコスト分析には通常、Cost Explorer が利用されますが、複数のAWSアカウントを調査する際には各アカウントにログインする必要があります。 そこで、この記事では Cost and Usage Report(以下:CUR)のデータを S3 に保存し、Athena で分析する仕組みを作成します。
仕事では Terraform を使用することが多いため、本記事では Terraform で設定します。
今回作成するサービスとアーキテクチャ
背景
通常 CUR のデータを Athena を使って分析したい場合、以下のドキュメントやブログを参考にすれば、Cloud Formation で分析する仕組みを作成できます。
- Amazon Athena を使用したコストと使用状況レポートのクエリ - AWS Documentation
- Querying your AWS Cost and Usage Report using Amazon Athena - AWS Blog
Cloud Formation で作成できるアーキテクチャ
ブログは 2019 年に発表されており、Glue クローラーのクローリングの設定は Crawl all sub-folders (すべてのサブフォルダをクローリング)
になっています。
2021 年 10 月から AWS Glue のクローラーが Amazon S3 イベント通知をサポートされ、さらに以下のブログやニュースを読むと、2022 年 10 月に増分クローリングが発表されたことにより、クロール時間とクローラーの実行に必要なデータ処理ユニット(DPU)を削減する方法が紹介されています。
- AWS Glue クローラーは既存の AWS Glue データカタログテーブルでの増分 Amazon S3 クローリングをサポート - AWS whats new
- Build incremental crawls of data lakes with existing Glue catalog tables - AWS Blog
これらの仕組みを Terraform で実現している記事が見当たらなかったので、コードと一緒に記事を書くことにしました。
この記事では、ステップ・バイ・ステップでリソースを作成するため、Terraform のリソースはすべて main.tf ファイルに書いていきます。
バージョン
バージョン | |
---|---|
Terraform | 1.5.2 |
成果物
最終的に完成したコード(リファクタバージョン)は以下の GitHub に置いておきます。
https://github.com/kntks/blog-code/tree/main/2023/07/terraform-cur-athena
準備
$ tree.├── README.md├── backend.tf├── main.tf└── providers.tf
terraform { required_version = "~> 1.5.2"
required_providers { aws = { source = "hashicorp/aws" version = "~> 5.7.0" } }}
provider "aws" { region = "ap-northeast-1"}
terraform { backend "local" { path = "./terraform.tfstate" }}
$ terraform init
CUR の配信設定
S3 バケットを作成する
CUR のレポート配信先となる S3 バケットに必要な設定を確認してみます。
このときバケットを新規に作成するか、既存のバケットかを選択できます。
どちらかを選択しても、バケットポリシーを表示してもらえます。
バケットポリシーもわかったので、Terraform で S3 を作成します。
※いくつか値は伏せています。
バケットポリシー
```json { "Version": "2008-10-17", "Id": "Policy1335892530063", "Statement": [ { "Sid": "Stmt1335892150622", "Effect": "Allow", "Principal": { "Service": "billingreports.amazonaws.com" }, "Action": [ "s3:GetBucketAcl", "s3:GetBucketPolicy" ], "Resource": "arn:aws:s3:::(レポート名)", "Condition": { "StringEquals": { "aws:SourceArn": "arn:aws:cur:us-east-1:(AWSアカウントID):definition/*", "aws:SourceAccount": "(AWSアカウントID)" } } }, { "Sid": "Stmt1335892526596", "Effect": "Allow", "Principal": { "Service": "billingreports.amazonaws.com" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::(レポート名)/*", "Condition": { "StringEquals": { "aws:SourceArn": "arn:aws:cur:us-east-1:(AWSアカウントID):definition/*", "aws:SourceAccount": "(AWSアカウントID)" } } } ] } ```main.tf
data "aws_caller_identity" "current" {}data "aws_region" "current" {}
locals { s3_bucket_name = "example-cur-report" account_id = data.aws_caller_identity.current.account_id}
resource "aws_s3_bucket" "cur" { bucket = local.s3_bucket_name force_destroy = true}
resource "aws_s3_bucket_policy" "cur" { bucket = aws_s3_bucket.cur.id policy = data.aws_iam_policy_document.s3_cur.json}
data "aws_iam_policy_document" "s3_cur" { statement { principals { type = "Service" identifiers = ["billingreports.amazonaws.com"] } actions = [ "s3:GetBucketAcl", "s3:GetBucketPolicy", ] resources = [aws_s3_bucket.cur.arn] condition { test = "StringEquals" variable = "aws:SourceArn" values = ["arn:aws:cur:us-east-1:${local.account_id}:definition/*"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = ["${local.account_id}"] } }
statement { principals { type = "Service" identifiers = ["billingreports.amazonaws.com"] } actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.cur.arn}/*"] condition { test = "StringEquals" variable = "aws:SourceArn" values = ["arn:aws:cur:us-east-1:${local.account_id}:definition/*"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = ["${local.account_id}"] } }}
$ terraform plan$ terraform apply -auto-approve
CURを作成する
先ほど、S3 を作成しました。次に CUR を設定します。
AWSのリソースは基本 ap-northeast-1
に作成しますが、 CUR は us-east-1
に作成します。
Terraform でこういったケースでは Multiple Provider Configurations を利用するとリージョンを CUR のみ us-east-1
にできます。
providerを追加します。
...
provider "aws" { region = "ap-northeast-1"}
provider "aws" { alias = "virginia" region = "us-east-1"}
...
resource "aws_cur_report_definition" "cur_report" { provider = aws.virginia # CURはバージニア北部しかない
report_name = "example-cur-report" time_unit = "HOURLY" format = "Parquet" # "Parquetに設定するなら"compression"も必ず"Perquet" compression = "Parquet" additional_schema_elements = ["RESOURCES"] s3_bucket = local.s3_bucket_name s3_prefix = "billing" s3_region = aws_s3_bucket.cur.region additional_artifacts = ["ATHENA"] # ここがATHENAである場合、他のartifactsは設定できない report_versioning = "OVERWRITE_REPORT" # additional_artifacts = "ATHENA"である場合、ここは必ず"OVERWRITE_REPORT"
depends_on = [ aws_s3_bucket_policy.cur, ]}
Resource: aws_cur_report_definition - registry.terraform.io
$ terraform plan$ terraform apply -auto-approve
これで CUR のデータが配信されると S3 バケットの <bucket-name>/<s3_prefix>/<report-name>/<report-name>/year=20xx/month=x/xxxx.parquet
ができます。
ちなみに”背景”でお伝えしたCloud Formation のファイルは <bucket-name>/<s3_prefix>/<report-name>/crawler-cfn.yml
です。
Glue データカタログとクローラー
Glueのデータベースを作成する
ここでは以下のリソースを作成します。
- Glue Catalog database
- Athena workgroup
まず Glue データベースを作成します。
以下を追記します。
...
locals { catalog_db_name = "example_report"}
resource "aws_glue_catalog_database" "cur" { name = local.catalog_db_name create_table_default_permission { permissions = ["ALL"]
principal { data_lake_principal_identifier = "IAM_ALLOWED_PRINCIPALS" } }}
Resource: aws_glue_catalog_database
(オプション) ワークグループを作成する
必要に応じてワークグループを作成してください。
ワークグループを作成するとクエリ実行の画面右上でワークグループを切り替えることができるようになります。
resource "aws_athena_workgroup" "cur" { name = "example"
force_destroy = true configuration { publish_cloudwatch_metrics_enabled = false result_configuration { output_location = "s3://${aws_s3_bucket.cur.id}/query_log" } }}
Resource: aws_athena_workgroup - registry.terraform.io
クローラーを作成する
クローラーを作成します。
クローラーには IAM ロールを設定する必要があるため、一緒に作成します。
main.tf
...
resource "aws_glue_crawler" "cur" { database_name = aws_glue_catalog_database.cur.name name = "example-cur-crawler" role = aws_iam_role.glue_crawler.arn tags = {} lake_formation_configuration { use_lake_formation_credentials = false } lineage_configuration { crawler_lineage_settings = "DISABLE" } recrawl_policy { recrawl_behavior = "CRAWL_EVERYTHING" }
s3_target { path = "s3://${aws_s3_bucket.cur.bucket}/billing/example-cur-report/example-cur-report" exclusions = [ "**.json", "**.yml", "**.sql", "**.csv", "**.gz", "**.zip" ] } schema_change_policy { update_behavior = "UPDATE_IN_DATABASE" delete_behavior = "DELETE_FROM_DATABASE" }}
resource "aws_iam_role" "glue_crawler" { name = "exampleCURAthenaCrawler" managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole"] assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Sid = "" Principal = { Service = "glue.amazonaws.com" } }, ] })}
resource "aws_iam_role_policy" "cur_crawler" { name = "AWSCURCrawlerComponentFunction" role = aws_iam_role.glue_crawler.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Effect = "Allow" Resource = "arn:aws:logs:*:*:*" }, { Action = [ "glue:UpdateDatabase", "glue:UpdatePartition", "glue:CreateTable", "glue:UpdateTable", "glue:ImportCatalogToGlue" ] Effect = "Allow" Resource = "*" }, { Action = [ "s3:GetObject", "s3:PutObject" ] Effect = "Allow" Resource = "arn:aws:s3:::${aws_s3_bucket.cur.bucket}/billing/example-cur-report/example-cur-report*" } ] })}
resource "aws_iam_role_policy" "kms_decryption" { name = "AWSCURKMSDecryption" role = aws_iam_role.glue_crawler.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = [ "kms:Decrypt" ] Effect = "Allow" Resource = "*" } ] })}
$ terraform plan$ terraform apply -auto-approve
terraform apply
を実行すると、IAM ロールは以下のようになります。
クローラーを実行してみる
手動でクローラーを実行してみます。 ドキュメント通りならテーブルが作成されるはずです。
クローラーは 1 回の実行で複数のデータストアをクロールできます。完了すると、クローラーはデータカタログで 1 つ以上のテーブルを作成または更新します
成功後、Glue と Athena のページでテーブルを確認できました。
試しにクエリを実行してみると、データをとれていそうです。
データカタログの自動更新設定
S3のPutイベントでクローラーを起動する
先ほどクローラーを手動実行することで、Athena からデータを確認することができました。
しかし、これでは最新の情報を取得することができません。
定期実行でクローラーが起動されるようにします。
ここからは S3 のイベント通知を利用して、クローラーを実行できるように設定します。
SQSのキューとデッドレターキューを作成し、クローラーが S3 イベントによって起動するように設定を変更します。
main.tf
resource "aws_glue_crawler" "cur" { ... recrawl_policy { recrawl_behavior = "CRAWL_EVERYTHING" recrawl_behavior = "CRAWL_EVENT_MODE" }
s3_target { path = "s3://${aws_s3_bucket.cur.bucket}/billing/kono-test-report/kono-test-report" exclusions = [ ] event_queue_arn = aws_sqs_queue.s3_event.arn dlq_event_queue_arn = aws_sqs_queue.s3_event_deadletter.arn } ...
depends_on = [aws_sqs_queue.s3_event, aws_sqs_queue.s3_event_deadletter]}
...
locals { s3_event_queue_name = "s3-notification-event-queue"}
resource "aws_sqs_queue" "s3_event" { name = "s3-notification-event-queue" delay_seconds = 90 max_message_size = 2048 message_retention_seconds = 86400 receive_wait_time_seconds = 10 redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.s3_event_deadletter.arn maxReceiveCount = 4 })}
resource "aws_sqs_queue" "s3_event_deadletter" { name = "s3-event-deadletter-queue" redrive_allow_policy = jsonencode({ redrivePermission = "byQueue", sourceQueueArns = [ "arn:aws:sqs:${data.aws_region.current.name}:${local.account_id}:${local.s3_event_queue_name}" ] })}
$ terraform plan$ terraform apply -auto-approve
参考:https://github.com/hashicorp/terraform-provider-aws/issues/22577#issuecomment-1086122047
設定完了したらマネジメントコンソールから設定を確認してみます。
Subsequent crawler runs
の項目が Crawl based on events
になっています。
SQS も設定されていることが確認できます。
S3 にイベント通知を設定する
最後に S3 のイベント通知を SQS に送信できるように設定します。
SQS にはキューにアクセスできるポリシーを設定する必要があるので、ポリシーを作成し、キューに設定します。
resource "aws_iam_role_policy" "cur_crawler" { name = "AWSCURCrawlerComponentFunction" role = aws_iam_role.glue_crawler.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { ... }, { "Effect" : "Allow", "Action" : [ "sqs:DeleteMessage", "sqs:GetQueueUrl", "sqs:ListDeadLetterSourceQueues", "sqs:DeleteMessageBatch", "sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:ListQueueTags", "sqs:SetQueueAttributes", "sqs:PurgeQueue" ], "Resource" : "*" } ] })}
...
resource "aws_sqs_queue" "s3_event" { ...+ policy = data.aws_iam_policy_document.s3_event.json}
...
data "aws_iam_policy_document" "s3_event" { statement { effect = "Allow"
principals { type = "Service" identifiers = ["s3.amazonaws.com"] }
actions = ["sqs:SendMessage"] resources = ["arn:aws:sqs:*:*:${local.s3_event_queue_name}"]
condition { test = "ArnEquals" variable = "aws:SourceArn" values = [aws_s3_bucket.cur.arn] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [local.account_id] } }}
resource "aws_s3_bucket_notification" "cur" { bucket = aws_s3_bucket.cur.id
queue { queue_arn = aws_sqs_queue.s3_event.arn events = ["s3:ObjectCreated:*"] filter_suffix = ".parquet" }}
Resource: aws_s3_bucket_notification
$ terraform plan$ terraform apply -auto-approve
s3 バケットのプロパティをクリックします。
ページを下にスクロールするとイベント通知の設定を見ることができます。
参考:
- Amazon S3 イベント通知 - AWS Documentation
- ステップ 1: Amazon SQS キューを作成する - AWS Documentation
- Amazon S3 イベント通知を使用した加速クロール - AWS Documentation
クローラーを定期実行させる
クローラーがすべてのファイルをクローリングするなら、クローリングの間隔を短くすればするほどコストがかかるはずです。
しかし、今回はAmazon S3 イベント通知を使用した加速クロールを利用し、クロールコストが削減されるようにしたのでクローリングの間隔は短くても問題なさそうです。
今回は、3 時間に 1 回定期実行しようと思います。以下の設定では、午前 0時から3時間毎に実行します。
resource "aws_glue_crawler" "cur" { database_name = aws_glue_catalog_database.cur.name schedule = "cron(0 0/3 * * ? *)" name = "example-cur-crawler" role = aws_iam_role.glue_crawler.arn tags = local.tags ...}
これで完成です。
$ terraform plan$ terraform apply -auto-approve
動作確認
最後にS3にオブジェクトが作成されたらクローラーが動作するのか確認します。(すでに CUR が配信されており、S3 にデータがあるとします。)
S3 イベント作成後、手動で実行する
動作確認方法
- S3 から CUR のデータをダウンロードする
- S3 から CUR のデータを削除する
- 削除したデータが存在していたディレクトリに、ダウンロードしたデータをアップロードする
SQS にメッセージが入っていることを確認できます。
手動でクローラーを実行すると、問題なく実行されました。
SQS にデータがない状態で、クローラーを手動で実行する
SQS にデータがない状態で手動実行してみると、Status = Stopped
になりました。
ドキュメント通り、キューにイベントがない場合は、実行されませんでした。
Amazon S3 イベントクロールは、クローラーのスケジュールに基づいて SQS キューから Amazon S3 イベントを使うことで実行します。キューにイベントがない場合、費用はかかりません
最後に
作成したファイルはこんな感じです。実装の参考になれば幸いです。
main.tf
data "aws_caller_identity" "current" {}data "aws_region" "current" {}
locals { s3_bucket_name = "example-cur-report" account_id = data.aws_caller_identity.current.account_id}
resource "aws_s3_bucket" "cur" { bucket = local.s3_bucket_name force_destroy = true}
resource "aws_s3_bucket_policy" "cur" { bucket = aws_s3_bucket.cur.id policy = data.aws_iam_policy_document.s3_cur.json}
data "aws_iam_policy_document" "s3_cur" { statement { principals { type = "Service" identifiers = ["billingreports.amazonaws.com"] } actions = [ "s3:GetBucketAcl", "s3:GetBucketPolicy", ] resources = [aws_s3_bucket.cur.arn] condition { test = "StringEquals" variable = "aws:SourceArn" values = ["arn:aws:cur:us-east-1:${local.account_id}:definition/*"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = ["${local.account_id}"] } }
statement { principals { type = "Service" identifiers = ["billingreports.amazonaws.com"] } actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.cur.arn}/*"] condition { test = "StringEquals" variable = "aws:SourceArn" values = ["arn:aws:cur:us-east-1:${local.account_id}:definition/*"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = ["${local.account_id}"] } }}
resource "aws_cur_report_definition" "cur_report" { provider = aws.virginia # CURはバージニア北部しかない
report_name = "example-cur-report" time_unit = "HOURLY" format = "Parquet" # "Parquetに設定するなら"compression"も必ず"Perquet" compression = "Parquet" additional_schema_elements = ["RESOURCES"] s3_bucket = local.s3_bucket_name s3_prefix = "billing" s3_region = aws_s3_bucket.cur.region additional_artifacts = ["ATHENA"] # ここがATHENAである場合、他のartifactsは設定できない report_versioning = "OVERWRITE_REPORT" # additional_artifacts = "ATHENA"である場合、ここは必ず"OVERWRITE_REPORT"
depends_on = [ aws_s3_bucket_policy.cur, ]}
# -------------------------------------
locals { catalog_db_name = "example_report"}
resource "aws_glue_catalog_database" "cur" { name = local.catalog_db_name create_table_default_permission { permissions = ["ALL"]
principal { data_lake_principal_identifier = "IAM_ALLOWED_PRINCIPALS" } }}
resource "aws_athena_workgroup" "cur" { name = "example"
force_destroy = true configuration { publish_cloudwatch_metrics_enabled = false result_configuration { output_location = "s3://${aws_s3_bucket.cur.id}/query_log" } }}
# ---------------------------
resource "aws_glue_crawler" "cur" { database_name = aws_glue_catalog_database.cur.name schedule = "cron(0 0/3 * * ? *)" name = "example-cur-crawler" role = aws_iam_role.glue_crawler.arn lake_formation_configuration { use_lake_formation_credentials = false } lineage_configuration { crawler_lineage_settings = "DISABLE" } recrawl_policy { recrawl_behavior = "CRAWL_EVENT_MODE" }
s3_target { path = "s3://${aws_s3_bucket.cur.bucket}/billing/example-cur-report/example-cur-report" exclusions = [ "**.json", "**.yml", "**.sql", "**.csv", "**.gz", "**.zip" ] event_queue_arn = aws_sqs_queue.s3_event.arn dlq_event_queue_arn = aws_sqs_queue.s3_event_deadletter.arn } schema_change_policy { update_behavior = "UPDATE_IN_DATABASE" delete_behavior = "DELETE_FROM_DATABASE" }
depends_on = [aws_sqs_queue.s3_event, aws_sqs_queue.s3_event_deadletter]}
resource "aws_iam_role" "glue_crawler" { name = "exampleCURAthenaCrawler" managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole"] assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Sid = "" Principal = { Service = "glue.amazonaws.com" } }, ] })}
resource "aws_iam_role_policy" "cur_crawler" { name = "AWSCURCrawlerComponentFunction" role = aws_iam_role.glue_crawler.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Effect = "Allow" Resource = "arn:aws:logs:*:*:*" }, { Action = [ "glue:UpdateDatabase", "glue:UpdatePartition", "glue:CreateTable", "glue:UpdateTable", "glue:ImportCatalogToGlue" ] Effect = "Allow" Resource = "*" }, { Action = [ "s3:GetObject", "s3:PutObject" ] Effect = "Allow" Resource = "arn:aws:s3:::${aws_s3_bucket.cur.bucket}/billing/example-cur-report/example-cur-report*" }, { Action = [ "sqs:DeleteMessage", "sqs:GetQueueUrl", "sqs:ListDeadLetterSourceQueues", "sqs:DeleteMessageBatch", "sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:ListQueueTags", "sqs:SetQueueAttributes", "sqs:PurgeQueue" ], Effect = "Allow", Resource = "*" } ] })}
resource "aws_iam_role_policy" "kms_decryption" { name = "AWSCURKMSDecryption" role = aws_iam_role.glue_crawler.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = [ "kms:Decrypt" ] Effect = "Allow" Resource = "*" } ] })}
# --------------------------
locals { s3_event_queue_name = "s3-notification-event-queue"}
resource "aws_sqs_queue" "s3_event" { name = "s3-notification-event-queue" delay_seconds = 90 max_message_size = 2048 message_retention_seconds = 86400 receive_wait_time_seconds = 10 redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.s3_event_deadletter.arn maxReceiveCount = 4 }) policy = data.aws_iam_policy_document.s3_event.json}
resource "aws_sqs_queue" "s3_event_deadletter" { name = "s3-event-deadletter-queue" redrive_allow_policy = jsonencode({ redrivePermission = "byQueue", sourceQueueArns = [ "arn:aws:sqs:${data.aws_region.current.name}:${local.account_id}:${local.s3_event_queue_name}" ] })}
# --------------------------
data "aws_iam_policy_document" "s3_event" { statement { effect = "Allow"
principals { type = "Service" identifiers = ["s3.amazonaws.com"] }
actions = ["sqs:SendMessage"] resources = ["arn:aws:sqs:*:*:${local.s3_event_queue_name}"]
condition { test = "ArnEquals" variable = "aws:SourceArn" values = [aws_s3_bucket.cur.arn] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [local.account_id] } }}
resource "aws_s3_bucket_notification" "cur" { bucket = aws_s3_bucket.cur.id
queue { queue_arn = aws_sqs_queue.s3_event.arn events = ["s3:ObjectCreated:*"] filter_suffix = ".parquet" }}