Cost Explorer API でアカウントおよびサービスプロバイダ毎に請求額を取得する

## モチベーション
2022 年 2 月 1 日以降、AWS Inc. に代わり AWS Japan が日本国内の AWS サービスの契約当事者となりました。(請求書払いアカウントは 2021 年 11 月 1 日 以降)

https://aws.amazon.com/jp/legal/awsjp/

そのためほとんどのサービス利用料については現在 AWS Japan が請求元になっているのですが、AWS Marketplace および、Amazon SES, SNS, Connect などの AMCS LLC が販売する一部のサービスについては引き続き請求元は AWS Inc. となっています。

Cost Explorer API を使用してアカウントおよび、請求元の法人 (サービスプロバイダ) 毎に請求額を取得し、以下のような CSV で出力する機会がありましたので紹介します。

|Account Id|Account Name|Legal Entity|Amount (USD)|
|---|---|---|---|
|000000000000|account-0000|Amazon Web Services Japan G.K.|42.7927165287|
|111111111111|account-1111|Amazon Web Services Japan G.K.|32.2633798094|
|222222222222|account-2222|Amazon Web Services Japan G.K.|751.7103483954|
|222222222222|account-2222|Amazon Web Services, Inc.|386.7895005956|
|333333333333|account-3333|Amazon Web Services Japan G.K.|4.6428|
|444444444444|account-4444|Amazon Web Services Japan G.K.|407.74542118|
|444444444444|account-4444|Datadog Inc.|18.03|
|555555555555|account-5555|Amazon Web Services Japan G.K.|4.7548|
|...|

## API リクエストの例
AWS Lambda を使用して前月分の請求額を取得する最小限の例です。

```py
import datetime
import boto3

def lambda_handler(event, context):
    today = datetime.date.today()
    start = today.replace(month=today.month-1, day=1).strftime('%Y-%m-%d')
    end = today.replace(day=1).strftime('%Y-%m-%d')
    ce = boto3.client('ce')
    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': start,
            'End' :  end,
        },
        Granularity='MONTHLY',
        Metrics=[
            'NetUnblendedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'LINKED_ACCOUNT'
            },
            {
                'Type': 'DIMENSION',
                'Key': 'LEGAL_ENTITY_NAME'
            }           
        ]
    )
    return response['ResultsByTime'][0]['Groups']
```

Cost Explorer コンソールではグループ化条件は 1 つまでしか適用できませんが、Cost Explorer API では最大 2 つのグループ化条件を指定することができます。上記の例では GroupBy で Linked Account ごと、請求元法人ごとに請求額を取得しています。

## Pandas で結果を加工する
前述の例で得られるレスポンスはネストされた json になっているため、Pandas を使用して CSV に加工する例を考えます。AWS Lambda で Pandas を使用する際には、Lambda Layers を使用する必要がある点についてご注意ください。


[pandas.json_normalize](https://pandas.pydata.org/docs/reference/api/pandas.json_normalize.html) を使用して json をフラット化します。

```py
import pandas

normalized_json = pandas.json_normalize(get_cost_json(start, end))
print(normalized_json)
```
```
                                               Keys Metrics.NetUnblendedCost.Amount Metrics.NetUnblendedCost.Unit
0    [000000000000, Amazon Web Services Japan G.K.]                   42.7927165287                           USD
1    [111111111111, Amazon Web Services Japan G.K.]                   32.2633798094                           USD
2    [222222222222, Amazon Web Services Japan G.K.]                  751.7103483954                           USD
3         [222222222222, Amazon Web Services, Inc.]                  386.7895005956                           USD
4    [333333333333, Amazon Web Services Japan G.K.]                          4.6428                           USD
..                                              ...                             ...                           ...
20   [444444444444, Amazon Web Services Japan G.K.]                    407.74542118                           USD
21                     [444444444444, Datadog Inc.]                           18.03                           USD
22   [555555555555, Amazon Web Services Japan G.K.]                          4.7548                           USD
23   [666666666666, Amazon Web Services Japan G.K.]                  225.1362646108                           USD
24   [777777777777, Amazon Web Services Japan G.K.]                   93.5262513848                           USD

[25 rows x 3 columns]
```

上記結果から Keys をリスト化し、請求額 (今回の例では Metrics.NetUnblendedCost.Amount) と [pandas.concat](https://pandas.pydata.org/docs/reference/api/pandas.concat.html) で連結すると、以下のような出力になります。目的の形に近づきました。

```py
split_keys = pandas.DataFrame(
    normalized_json['Keys'].tolist(),
    columns=['Account Id', 'Legal Entity']
)
cost = pandas.concat([split_keys, normalized_json['Metrics.NetUnblendedCost.Amount']], axis=1)
print(cost)
```

```
       Account Id                    Legal Entity Metrics.NetUnblendedCost.Amount
0    000000000000  Amazon Web Services Japan G.K.                   42.7927165287
1    111111111111  Amazon Web Services Japan G.K.                   32.2633798094
2    222222222222  Amazon Web Services Japan G.K.                  751.7103483954
4    222222222222       Amazon Web Services, Inc.                  386.7895005956
4    333333333333  Amazon Web Services Japan G.K.                          4.6428
..            ...                             ...                             ...
20   444444444444  Amazon Web Services Japan G.K.                    407.74542118
21   444444444444                   Datadog, Inc.                           18.03
22   555555555555  Amazon Web Services Japan G.K.                          4.7548
23   666666666666  Amazon Web Services Japan G.K.                  225.1362646108
24   777777777777  Amazon Web Services Japan G.K.                   93.5262513848

[25 rows x 3 columns]
```

上記の Datadog, Inc. のように AWS Marketplace から購入したものは Amazon Web Services, Inc ではなく提供元の法人名が入ります。


## 最終的な Lambda 関数の例
Cost Explorer API ではアカウント名は取得できないため、AWS Organizations の API で取得した結果をマージし、最終的にアカウント名でソートしています。

```lambda_function.py
from logging import getLogger, INFO
import os
import datetime
import boto3
import pandas
from botocore.exceptions import ClientError

logger = getLogger()
logger.setLevel(INFO)

def upload_s3(output, key, bucket):
    try:
        s3_resource = boto3.resource('s3')
        s3_bucket = s3_resource.Bucket(bucket)
        s3_bucket.upload_file(output, key, ExtraArgs={'ACL': 'bucket-owner-full-control'})
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise

def get_ou_ids(org, parent_id):
    ou_ids = []
    try:
        paginator = org.get_paginator('list_children')
        iterator = paginator.paginate(
            ParentId=parent_id,
            ChildType='ORGANIZATIONAL_UNIT'
        )
        for page in iterator:
            for ou in page['Children']:
                ou_ids.append(ou['Id'])
                ou_ids.extend(get_ou_ids(org, ou['Id']))
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return ou_ids

def list_accounts():
    org = boto3.client('organizations')
    root_id = 'r-xxxx'
    ou_id_list = [root_id]
    ou_id_list.extend(get_ou_ids(org, root_id))
    accounts = []

    try:
        for ou_id in ou_id_list:
            paginator = org.get_paginator('list_accounts_for_parent')
            page_iterator = paginator.paginate(ParentId=ou_id)
            for page in page_iterator:
                for account in page['Accounts']:
                    item = [
                        account['Id'],
                        account['Name'],
                    ]
                    accounts.append(item)
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return accounts

def get_cost_json(start, end):
    """Get Cost Json"""
    ce = boto3.client('ce')
    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': start,
            'End' :  end,
        },
        Granularity='MONTHLY',
        Metrics=[
            'NetUnblendedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'LINKED_ACCOUNT'
            },
            {
                'Type': 'DIMENSION',
                'Key': 'LEGAL_ENTITY_NAME'
            }           
        ]
    )
    return response['ResultsByTime'][0]['Groups']

def lambda_handler(event, context):
    output_file = '/tmp/output.csv'
    bucket = os.environ['BUCKET']

    today = datetime.date.today()
    start = today.replace(month=today.month-1, day=1).strftime('%Y-%m-%d')
    end = today.replace(day=1).strftime('%Y-%m-%d')
    key = 'all-accounts-cost-' + start + '-' + end + '.csv'
    
    normalized_json = pandas.json_normalize(get_cost_json(start, end))
    split_keys = pandas.DataFrame(
        normalized_json['Keys'].tolist(),
        columns=['Account Id', 'Legal Entity']
    )
    cost = pandas.concat([split_keys, normalized_json['Metrics.NetUnblendedCost.Amount']], axis=1)
    account_list = pandas.DataFrame(list_accounts(), columns=['Account Id', 'Account Name'])
    merged_cost = pandas.merge(account_list, cost, on='Account Id', how='right')
    renamed_cost = merged_cost.rename(columns={'Metrics.NetUnblendedCost.Amount': 'Amount (USD)'})
    sorted_cost = renamed_cost.sort_values(by='Account Name')
    sorted_cost.to_csv(output_file, index=False)
    upload_s3(output_file, key, bucket)
```
以上です。
参考になれば幸いです。