AWSコストエクスプローラーAPIと気軽につきあう(2019)

この記事は[はてなエンジニアAdvent Calendar 2019](https://qiita.com/advent-calendar/2019/hatena)の5日目のエントリーです。


- [続編が2020年に](https://qiita.com/ma2saka/items/6d6052ea7e87c67a89a0)書かれました。2020年も定期的にコストエクスプローラーAPIとつきあっています。

## はじめに

AWSのコストエクスプローラのデータは12ヶ月しか保存されないので、なるべく外部で保存したいですよね。幸い、コストエクスプローラのデータにアクセスするためのAPIがちゃんと提供されているので、気軽に利用することができます。特に難しいこともないのですが、これから「よーしAWS費用の集計をシステム化しちゃうぞー」という人のために、かんたんに足場を作るための手順を整理しておくことにします。

[API更新時の説明において](https://aws.amazon.com/jp/blogs/news/new-interactive-aws-cost-explorer-api/)以下のように言及されている通り、コンソール経由よりも詳細なデータアクセスが可能なので、ぜひ面倒くさがらずコスト管理のパイプライン構築を進めていきましょう。

> グループ化 – コストエクスプローラーウェブアプリケーションで提供されるグループ化は 1 レベルですが、API では 2 レベルが提供されます。たとえば、まずサービス別にコストまたは RI 使用率をグループ化してから、リージョン別にグループ化できます。

この記事で取り上げているのは2019/12現在の情報であり、API仕様は変更される可能性があります。また気軽ではありますがコストエクスプローラAPIは呼び出し単価高いAPI(リクエストあたり 0.01USD)であったりするのでご注意ください。

さて。

## Cloud9 を起動しましょう

もちろん Cloud9 を使います。そして Typescript です。

特に理由がなければ Amazon Linux で立てるといいでしょう。気をつけるべきなのは、パブリックサブネットで立てるということです。今回は CostExplorerApiDev という名前で立ち上げました。

<img width="893" alt="cloud9.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17911/7aca4f88-afed-662e-fae9-6b37c3c18b17.png">

起動したら、下部ターミナルから `npm` のライブラリをセットアップします。

```
npm init -y
npm install -D typescript ts-node aws-sdk @types/node prettier
npx tsc --init
```

typescript の設定を開き、Format on Save は有効にしておきます。

<img width="498" alt="スクリーンショット 2019-12-05 2.11.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17911/d9458d2d-e9ee-6eff-ed3a-f7bd7fe18e1c.png">

tsconfig は以下のようにしている。最低限。

```tsconfig.json
{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "lib": ["es2018"],
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

さて、 `vi index.ts` して、動作確認をします。

```index.ts
console.log("hello typescript");
```

`npx ts-node index.ts` してちゃんと実行されることを確認したら、次に進みましょう。


## CostExplorer APIを利用する

普段見ているコストエクスプローラのレポート画面に相当するデータ取得は、`GetCostAndUsage` 呼び出しによって行うことができます。公式の情報は薄めなので、必要に応じてAPI情報をサードパーティから参照します。

- https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html (公式)
- https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CostExplorer.html (公式)
- https://any-api.com/amazonaws_com/ce/docs/_X_Amz_Target_AWSInsightsIndexService_GetCostAndUsage/GetCostAndUsage

ここでは、以下のようなデータがほしいとしましょう。

- 月次で
- 2019/06- 2019/11 の範囲で
- 償却されたコスト(AMORTIZED COST)を
- クレジット(Credit), 払い戻し(Refund)、税金(Tax)、前払い金(Upfront)に関するレコードを**除外**し [^1]
- 連結アカウントごとにグループ化する

[^1]: 裏側のデータ構造をある程度想像した上で集計処理を想像する必要があります。どのアカウントの、どんなタグが付与されたどんなリソースを何分/何GB利用したのか、といった履歴テーブルに集計クエリを打つのだと考えるとわかりやすいです。

これは自分が普段利用しているコストエクスプローラのフィルタ条件を整理したものです。

キャッシュフロー上の実際の支払い金額が知りたいのではなく、発生ベースでその月に使用したアカウントごとの費用が知りたいとしましょう。クレジットや払い戻しによる費用の上下、各種の事前チャージのお金の動きは除外したいものとします。簡単のためサンプルでは console に出力しているだけですが、AWSのクレデンシャルがありSDKからAPI叩けているなら、結果を S3 に置くとか SNS に投げるのにとくべつな工夫は必要ないかと思います。

```get_cost.ts
import * as AWS from "aws-sdk";

// Cost Explorer は us-east-1 指定が必要
const costexplorer = new AWS.CostExplorer({ region: "us-east-1" });

const params = {
  TimePeriod: {
    Start: "2019-06-01",
    End: "2019-11-01"
  },
  Filter: {
    Not: {
      Dimensions: {
        Key: "RECORD_TYPE",
        Values: ["Credit", "Refund", "Tax", "Upfront"]
      }
    }
  },
  Granularity: "MONTHLY",
  Metrics: ["AMORTIZED_COST"],
  GroupBy: [{ Type: "DIMENSION", Key: "LINKED_ACCOUNT" }]
};

costexplorer.getCostAndUsage(params, (err, data) => {
  if (err) {
    console.error(err);
  } else {
    console.log(data);
  }
});
```

これを実行します。うまくデータが取得できれば以下のようにコンソールに出力されると思います。なお今回は簡単のためにページングは考慮していません。結果オブジェクトに `NextPageToken` が含まれていたら後続を読み込む処理が必要になります。


```sh
ma2saka:~/environment $ npx ts-node get_cost.ts
{ GroupDefinitions: [ { Type: 'DIMENSION', Key: 'LINKED_ACCOUNT' } ],
  ResultsByTime:
   [ { TimePeriod: [Object],
       Total: {},
       Groups: [Array],
       Estimated: false },
     { TimePeriod: [Object],
       Total: {},
       Groups: [Array],
       Estimated: false },
     { TimePeriod: [Object],
       Total: {},
       Groups: [Array],
       Estimated: false },
     { TimePeriod: [Object],
       Total: {},
       Groups: [Array],
       Estimated: false },
     { TimePeriod: [Object],
       Total: {},
       Groups: [Array],
       Estimated: false } ] }
```

中身を実際に見たければ `console.log(JSON.stringify(data));` とかすると良いです。ここでは事情により[^2]割愛します。

[^2]: 会社のアカウントのデータ出すわけにもいきませんが、個人アカウントのデータは少なすぎて集計のしがいがなかった。

`ResultsByTime` に月ごとのデータが配列として格納されています。`ResultsByTime[].TimePeriod` は `Start,End` が入ってきます。`MONTHLY`なので、Startが対象月の初日（もしくは指定した開始日)、Endが対象月の翌月初日(もしくは指定した終了日)になります。

`ResultsByTime[].Estimated` は推定値が集計範囲に含まれていることを表します。今回のような集計をする場合、まだ締まっていない月のデータが `Estimated: true` になると思えばよさそうです。

`ResultsByTime[].Groups` にその集計期間におけるアカウントごとの集計値が並びます。ここでは税金や前払金などを除いたデータを月次で集計した値が得られます。

今回は償却コスト(Amotized Cost)をメトリクスとして集計対象にしました。これによってRIの費用などが月々に償却されて乗ります。指定できるのはこの他に、

> BLENDED_COST, UNBLENDED_COST, AMORTIZED_COST, NET_UNBLENDED_COST, NET_AMORTIZED_COST, USAGE_QUANTITY, NORMALIZED_USAGE_AMOUNT

などがありますね。 https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/ce-advanced.html などでなんとなく解説されている。[^3]メトリクスは同時に複数指定できます。メトリクス名は Screaming snake case でも Upper Camel Case でも有効みたいだけど、どっちが正なんだろう。

[^3]: 日本語訳で読むと苦しい気持ちになる。純償却コスト、純非ブレンドコストって言われてもエッてなります。設定値との対応関係が読み取りづらいのでドキュメントは大人しく英語で読みましょう。

グループ化のタイプとして `DIMENSION` を、キーとして `LINKED_ACCOUNT` を指定しています。連結アカウント単位です。タイプを `DIMENSION` とした場合、値としては以下のようなものが指定できます。

> AZ, INSTANCE_TYPE, LINKED_ACCOUNT, OPERATION, PURCHASE_TYPE, REGION, SERVICE, USAGE_TYPE, USAGE_TYPE_GROUP, RECORD_TYPE, OPERATING_SYSTEM, TENANCY, SCOPE, PLATFORM, SUBSCRIPTION_ID, LEGAL_ENTITY_NAME, DEPLOYMENT_OPTION, DATABASE_ENGINE, CACHE_ENGINE, INSTANCE_TYPE_FAMILY, BILLING_ENTITY, RESERVATION_ID

なお、`GetCostAndUsage` API 経由ではアカウント情報はIDまでしか取れません。アカウントの一覧を取得して付き合わせるには一度 `Organizations#listAccounts` API を呼び出して辞書を作っておくのが楽だと思います。

グループ化のタイプには `TAG` を指定することもできます。その場合にはキーとしてリソースタグのキー名を指定します。日本語で書くとちょっとわかりづらいところですが、クエリ書いてみればまあわかる。

以下は、「ある特定のアカウントID」に対し、「`OwnerTeam`」というリソースタグの値で月額費用を集計します。変更点は Filter 条件と GroupBy の指定です。

```awesome.ts
const params = {
    TimePeriod: {
        Start: '2019-06-01',
        End: '2019-11-01',
    },
    Filter: {
        And: [{
            Dimensions: {
                Key: "LINKED_ACCOUNT",
                Values: ["特定のアカウントID"]
            }
        }, {
            Not: {
                Dimensions: {
                    Key: "RECORD_TYPE",
                    Values: ["Credit", "Refund", "Tax", "Upfront"]
                }
            }
        }, ]
    },
    Granularity: "MONTHLY",
    Metrics: ["AMORTIZED_COST"],
    GroupBy: [{ Type: "TAG", Key: "OwnerTeam" }]
}
```

Filter はちょっと試行錯誤が必要かもしれませんが、そんなに複雑なものでないのですぐ慣れます。[Expression](https://any-api.com/amazonaws_com/ce/docs/Definitions/Expression)を適宜参照してください。

## Lambda などで動かす場合、`ce:Get*` を許可する

`ce:Get*` を許してあげましょう。[公式のサンプル](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/billing-permissions-ref.html#example-policy-ce-api)では `ce:*` を許可していますが、権限は狭いに越したことはありません。

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "ce:Get*",
            "Resource": "*"
        }
    ]
}
```

# まとめ

Cloud9 は実行環境がついたターミナルとしか使ってなくて、実はぜんぶ vim で書いてました。すみません。Typescript ではあるんですが、Javascript と書いたコードは区別がつきませんでした。これも、そういうもんですね。


6日目は @ne_sachirou さんです。よろしくお願いします。