本日も乙

ただの自己満足な備忘録。

AWS Cost and Usage Reportを分割してメンバーアカウントに共有する(2)

前回の記事の続きです。

blog.jicoman.info

AWSリソース

本記事に登場する AWS リソースを紹介します。この記事を読んでやってみたい人は各自の環境に沿った値に置き換えてください。

  • リージョン:東京(ap-northeast-1
  • AWSアカウント
    • 管理アカウント
      • 組織内のAWSアカウントの利用料金をまとめて支払う一括請求アカウント。CUR が設定されている
      • アカウント ID:111111111111
    • メンバーアカウント
      • 組織内で作成された AWS アカウント
      • アカウント ID:222222222222
  • S3バケット
    • cur-billing:CURデータの出力先S3バケット
    • linkedsubacct:Athenaクエリの出力結果を保存するS3バケット
      • 出力先のフォルダ:billing
  • AWS Lambda
    • S3LinkedPutACL:S3バケット(linkedsubacct)にファイルアップロードされたイベントをトリガーにオブジェクト ACL を書き換える
    • SubAcctSplit:CUR の Glue クローラーの実行成功をトリガーにメンバーアカウントのコストデータを CTAS クエリでS3バケット(linkedsubacct)にアップロードする
  • IAMロール
    • LambdaPutLinkedS3ACL:Lambda関数 S3LinkedPutACL 用 IAM ロール
    • LambdaSubAcctSplit:Lambda関数 SubAcctSplit用 IAM ロール

CURの作成

はじめに管理アカウントで CUR を作成する必要があります。以下のワークショップか、自著「Amazon Web Servicesコスト最適化入門」9章を参考に設定してください。

Level 300: Automated CUR Updates and Ingestion :: AWS Well-Architected Labs

メンバーアカウント共有用S3バケットの作成

Athena クエリの出力結果を保存する S3 バケット(linkedsubacct)を管理アカウントに作成します。この S3 バケットに対して、メンバーアカウントの Athena と Glue が実行できるようにバケットポリシーを設定します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowListingOfFolders",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111111111111:root"
            },
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::linkedsubacct"
        },
        {
            "Sid": "AllowAllS3ActionsInSubFolder",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111111111111:root"
            },
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::linkedsubacct/*"
        },
        {
            "Sid": "AllowSubAccountAthenaGlueAccess",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::222222222222:root"
            },
            "Action": [
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:ListMultipartUploadParts",
                "s3:AbortMultipartUpload",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::linkedsubacct",
                "arn:aws:s3:::linkedsubacct/*"
            ]
        }
    ]
}

オブジェクトACLを書き換えるLambda関数用の作成

先ほど作成した S3 バケット(linkedsubacct)にアップロードされた S3 オブジェクトは CUR と管理アカウントしか取得することができません。バケットポリシー上はメンバーアカウントからも取得できるのですが、オブジェクトに付与されている ACL でメンバーアカウントが許可されていないのでこのままではアクセスすることができません。そこで、管理アカウントとメンバーアカウントが取得できるようにオブジェクト ACL を書き換える Lambda 関数(S3LinkedPutACL)を作成します。

IAMポリシーの作成

Lambda 関数(S3LinkedPutACL)で使用する IAM ロールにアタッチする、IAM ポリシーを作成します。IAM ポリシー名は Lambda_S3Linked_PutACL とします。権限は以下のとおりです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObjectVersionAcl",
                "s3:PutObjectAcl"
            ],
            "Resource": "arn:aws:s3:::linkedsubacct/*"
        }
    ]
}

IAM ロールの作成

Lambda 関数(S3LinkedPutACL)に付与する IAM ロール(LambdaPutLinkedS3ACL)を作成します。先ほど作成した IAM ポリシー Lambda_S3Linked_PutACL と CloudWatch Logs にログ出力させるための IAM ポリシー AWSLambdaBasicExecutionRole をアタッチします。

Lambda 関数の作成

S3LinkedPutACL という名前で Lambda 関数を作成します。ソースコードは AWS が提供されているものを使います。

https://github.com/aws-samples/aws-map-linkedaccounts/blob/main/lambda/MAP_s3outputbucketpermissionslambda.js

項目グループ 項目 備考
コード ランタイム Node.js 14.x
一般設定 メモリ 160MB AWS Compute Optimizerのレコメンドから
タイムアウト 10秒
ロール名 LambdaPutLinkedS3ACL
環境変数 payeraccountid 111111111111 管理アカウントのAWSアカウントID
linkedaccountid 222222222222 メンバーアカウントのAWSアカウントID
canonicalidpayer xxxxxxxxxxxxxx 管理アカウントのカノニカルID(正規ユーザーID)
canonicalidlinked yyyyyyyyyyyyyy メンバーアカウントのカノニカルID(正規ユーザーID)

カノニカル ID(正規ユーザー ID )は以下のコマンドで調べることができます。

aws s3api list-buckets --query Owner.ID --output text

S3 バケット通知の設定

s3://linkedsubacct/billing/ に CUR データファイルがアップロードされる度に、Lambda 関数(S3LinkedPutACL)が実行されるイベントを設定します。 S3 バケット linkedsubacct の「プロパティ」タブから「イベント通知」の「イベント通知を作成」を選択します。

項目
イベント名 LambdaPutLinkedS3ACL
プレフィックス billing
イベントタイプ すべてのオブジェクト作成イベント(s3:ObjectCreated:*
送信先 Lambda 関数
Lambda 関数を特定 S3LinkedPutACL を選択

設定したら s3://linkedsubacct/billing/ に適当なファイルをアップロードして正しく動作していることを確認します。アップロードした S3 オブジェクトの「アクセス許可」タブから「外部アカウント」にメンバーアカウントが追加されていることを確認します。もし付与されていなければ、CloudWatch Logs に出力されているログから原因を調査してください。設定が確認できたらアップロードした S3 オブジェクトを削除してください。

過去の CUR データを S3 バケットに出力する

今まで貯めてきた CUR データがある場合は、s3://linkedsubacct/billing/ にデータをアップロードします。これは一度きりの作業です。管理アカウントの Athena コンソールに移動し、以下のクエリを実行して一時テーブルを作成します。

CREATE TABLE <CURデータベース名>.temp_table
WITH (
      format = 'Parquet',
      parquet_compression = 'GZIP',
      external_location = 's3://linkedsubacct/billing',
      partitioned_by=ARRAY['year_1','month_1'])
AS SELECT *, year as year_1, month as month_1
FROM "<CURデータベース名>"."<CURテーブル名>"
WHERE line_item_usage_account_id IN ('222222222222', 'xxxxxxxxxx' ...);

WHERE 句で抽出したいメンバーアカウントの AWS アカウント ID を指定します。前回の記事で述べた部門単位でデータを抽出したい場合は、IN 句に抽出したい AWS アカウント ID をすべて含めてください。

また、CUR すべてのカラムを持ってくるとデータ量が多くなり、S3 のストレージコストが増大します。あらかじめ分析に利用するカラムが明確であれば、そのカラムのみを指定することでストレージコストをおさえることができます。以下のクエリはその例です。各カラムの意味は自著「Amazon Web Servicesコスト最適化入門」の「9.6 よく使うカラム」をご参照ください。

CREATE TABLE <CURデータベース名>.temp_table
WITH (
      format = 'Parquet',
      parquet_compression = 'GZIP',
      external_location = 's3://linkedsubacct/billing',
      partitioned_by=ARRAY['year_1','month_1'])
AS SELECT line_item_usage_account_id, line_item_product_code, line_item_resource_id, line_item_operation, line_item_usage_type, line_item_line_item_description, line_item_line_item_type, bill_bill_type, line_item_unblended_cost, line_item_net_unblended_cost, line_item_usage_start_date, resource_tags_user_xxxx, cost_category_xxx, year as year_1, month as month_1
FROM "<CURデータベース名>"."<CURテーブル名>"
WHERE line_item_usage_account_id IN ('222222222222', 'xxxxxxxxxx' ...);

クエリを実行すると s3://linkedsubacct/billing/ にパーティションで区切られた CUR データファイルが作成されます。各ファイルに管理アカウントとメンバーアカウントのカノニカル ID が付与されていることを確認します。

問題なくデータ抽出ができたら、以下のクエリで一時テーブルを削除します。

DROP TABLE "<CURデータベース名>"."temp_table";

定期実行用 Athena クエリの作成

先程の作業は過去の CUR データをコピーする作業でした。今度は新しい CUR データファイルがアップロードされる度に s3://linkedsubacct/billing/ に出力する Athena クエリを作成します。このクエリは後述の Lambda 関数から呼ばれます。SELECT 文で取得するカラムは先ほど実行したクエリに揃えてください。create_linked_billing というクエリ名で保存します。

CREATE TABLE <CURデータベース名>.temp_table
WITH (
      format = 'Parquet',
      parquet_compression = 'GZIP',
      external_location = 's3://linkedsubacct/billing/__subfolder__')
AS SELECT *
FROM "<CURデータベース名>"."<CURテーブル名>"
WHERE line_item_usage_account_id IN ('222222222222', 'xxxxxxxxxx')
  AND year=CAST(year(current_date - INTERVAL '__interval__' MONTH) AS VARCHAR)
  AND month=CAST(month(current_date - INTERVAL '__interval__' MONTH) AS VARCHAR)

__subfolder____interval__ は Lambda 関数で置換されるプレースホルダです。すべての CUR データを毎回洗い替えすると Athena クエリコストが増えてしまうため、先月・当月分の CUR データのみを洗い替えします。

Athena クエリを実行する Lambda 関数の作成

先ほど保存した Athena クエリ(create_linked_billing)を実行するための Lambda 関数(SubAcctSplit)を設定します。

IAM ポリシーの作成

Lambda 関数(SubAcctSplit)で使用する IAM ロールにアタッチする、IAM ポリシーを作成します。IAM ポリシーの名前は LambdaSubAcctSplit とします。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "athena:GetNamedQuery",
                "athena:GetQueryExecution",
                "athena:GetQueryResults",
                "athena:ListNamedQueries",
                "athena:ListQueryExecutions",
                "athena:StartQueryExecution",
                "s3:GetObject",
                "s3:DeleteObject",
                "s3:DeleteObjectVersion",
                "s3:ListBucket",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::linkedsubacct/*",
                "arn:aws:athena:*:*:workgroup/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::*"
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": [
                "glue:GetDatabase",
                "glue:CreateTable",
                "glue:GetPartitions",
                "glue:GetPartition",
                "glue:DeleteTable",
                "glue:GetTable"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor3",
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:ListMultipartUploadParts",
                "s3:AbortMultipartUpload",
                "s3:CreateBucket",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::aws-athena-query-results-*"
            ]
        },
        {
            "Sid": "VisualEditor4",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": "arn:aws:s3:::cur-billing/*"
        },
        {
            "Sid": "VisualEditor5",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}

IAMロールの作成

Lambda 関数(SubAcctSplit)に付与する IAM ロール(LambdaSubAcctSplit)を作成します。先ほど作成した IAM ポリシー LambdaSubAcctSplit をアタッチします。

Lambda 関数の作成

SubAcctSplit という名前で Lambda 関数を作成します。ソースコードは GitHub Gist に載せました。

項目グループ 項目 備考
コード ランタイム Python 3.8
一般設定 メモリ 128MB
タイムアウト 180秒 Athenaクエリの実行時間に応じて設定
ロール名 LambdaSubAcctSplit
環境変数 athena_database athenacurcfn_billing CUR データベース名
athena_output_location s3://aws-athena-query-results-111111111111-ap-northeast-1/ Athena クエリ結果の保存先
bucket_name linkedsubacct
subfolder billing
extraction_query_name create_linked_billing Athena クエリ名

作成したらテストボタンをクリックして動作確認します。アップロード先 S3 バケット(s3://linkedsubacct/billing/)に先月分と当月分の CUR ファイルがアップロードされている(すでにファイルがある場合はタイムスタンプが更新される)ことを確認します。2021年5月に実行したとすると、以下のフォルダが作成されて、抽出した CUR データファイルが保存されているはずです。

s3://linkedsubacct/billing/year_1=2021/month_1=4/
s3://linkedsubacct/billing/year_1=2021/month_1=5/

Lambda 関数の実行トリガーの設定

先ほど作成した Lambda 関数(SubAcctSplit)を実行するためのトリガーを Amazon EventBridge(以下、EventBridge)で設定します。実行タイミングは、CUR を更新する Glue クローラーが完了した後です。EventBridge のマネジメントコンソールに移動し、イベント→ルールを選択、「ルールを作成」をクリックします。

以下の設定で EventBridge ルールを作成します。

項目
名前 PayerGlueCrawlerRule
説明 Rules triggered by Payer Glue Crawler
説明 イベントパターン
イベント一致パターン カスタムパターン
イベントパターン 下記に後述
ターゲット Lambda関数
機能 SubAcctSplit

イベントパターン。AWSCURCrawler-billing は CUR 設定時に自動作成された Glue Crawler 名に置き換えてください。

{
  "detail-type": [
    "Glue Crawler State Change"
  ],
  "source": [
    "aws.glue"
  ],
  "detail": {
    "crawlerName": [
      "AWSCURCrawler-billing"
    ],
    "state": [
      "Succeeded"
    ]
  }
}

設定後、Glue のマネジメントコンソールに行き、「クローラーの実行」を選択して Glue クローラーを手動実行します。Lambda 関数 SubAcctSplit が実行されて正常に動作できていることを確認します。

メンバーアカウントの Glue クローラー設定

メンバーアカウントでマネジメントコンソールにログインし、Glue クローラーを作成します。この Glue クローラーはデータ抽出先(s3://linkedsubacct/billing/)を定期的にクロールし、データカタログを更新します。Glue のマネジメントコンソールに行き、「Crawler」から「Add crawler」を選択します。

項目
クローラーの名前 SubAcct-Crawler
Crawler source type Data stores
Repeat crawls of S3 data stores Crawl all folders
データストアの選択 S3
クロールするデータの場所 指定されたパス
インクルードパス s3://linkedsubacct/billing/
エクスクルードパターン .json, .yml, .sql, .csv, .gz, .zip
別のデータストアの追加 いいえ
IAM ロールの選択 IAM ロールを作成する
IAM ロール AWSGlueServiceRole-subacct-crawler
頻度 毎日
Start Hour (UTC) 00
Start Minute 10
データベース sub-acct( 「データベースの追加」を選択して入力)

作成後、「クローラーの実行」を選択してクローラーを手動実行します。Athena のマネジメントコンソールに行き、Database(sub-acct)に billing というテーブルが作成されていることを確認します。もし、作成されていなければ、CloudWatch Logs のロググループ /aws-glue/crawlers からクローラのログを見て原因を調査してください。また、クエリを実行できることを確認します。もし、Access Denied になったら S3 バケット(linkedsubacct)のバケットポリシーと S3 オブジェクトの ACL の設定を確認してください。

execute-athena-query-for-split-cur

最後に

やや複雑な構成になりましたが、CUR のデータの一部を抽出してメンバーアカウントに共有して Athena クエリを実行する方法を書きました。ここまでの構成にすることはあまりないかもしれませんが、きちんとコスト分析と可視化をしたい人たちにとって役に立てれば嬉しいです。