LambdaでSlackにAWSの利用料金を投稿する（Node.js）

## 🔶 はじめに
AWSの利用料金を、あまり気にせず使っていたのですが、謎の料金増加を指摘され、確認したら、意図せず有効になったサービスや、試しに使ってそのまま消し忘れたサービス等がいくつかあったようで、さすがに猛省。

そこで、利用料金の増加に気付けるように、毎週Slackに利用料金を投稿する仕組みを作りました。

## 🔶 使うサービス
### 🔷 Lambda
* 様々な言語のコードをデプロイできる実行環境。
* コードは、様々なAWSプロダクトで発生したアクションをトリガーに実行する事ができます。
* https://aws.amazon.com/jp/lambda/

### 🔷 Slack Incoming Webhooks  
 * 外部サービスからのリクエストを受けて、Slackのワークスペースに投稿してくれるサービス。
 * 簡単に言うと、Slackに投稿できる、POSTメソッドのAPIです。
 * https://get.slack.help/hc/ja/articles/115005265063 



## 🔶 Slack Incoming Webhooks  の設定

以下を参考にしてください。
[SlackのIncoming Webhooksを使い倒す](https://qiita.com/ik-fib/items/b4a502d173a22b3947a0)

## 🔶 Lambdaの実装

### 🔷 Lambda関数作成
Lambda関数の基本的な作り方は、以下を参考にしてください。

[API Gateway + LambdaでREST API開発を体験しよう [10分で完成編]](https://qiita.com/tamura_CD/items/46ba8a2f3bfd5484843f)

### 🔷 ポリシーの設定

料金情報の取得には、Cost Explorer Serviceの、[GetCostAndUsage](https://docs.aws.amazon.com/ja_jp/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html)を利用します。
そのため、Lambda関数に以下のポリシーを追加します。

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

### 🔷 実装

今回作る関数は、以下の処理を行うものです。

1. 一週間分の料金情報を取得
1. 任意のサービスの料金を計算
1. SlackのAPIに料金情報を投げる

3ステップを行うだけの、特に難しいソースではありません。
ソースは以下に紹介しますが、コピーしてつなげれば、動きます。

#### メイン処理

* 最初に、`const moment = require('moment')`で[moment.js](https://momentjs.com/)を読み込んでいますが、これは日付けの書式設定が簡単なので使っているだけで、`new Date()`を使う場合は不要です。
* moment.jsを使う場合は、モジュールのインポートが必要なので、以下を参考に行ってください。  
[Lambda関数にNode.jsモジュールを入れるなら、Cloud9が簡単でおすすめ](https://qiita.com/tamura_CD/items/7a51536b38973fb7497b)

```js
/**
 * 指定範囲の料金の集計サービス
 *
 */

const AWS = require('aws-sdk');
const costexplorer = new AWS.CostExplorer();
const moment = require('moment');
const https = require ('https');
const url = require('url');


/**
 * メイン処理
 * 
 */
exports.handler = async (event) => {
  
  let costRaw  = await getCost();
  let costJson = await costCalc(costRaw);
  let result   = await requestToAPI(costJson);

  return result;
};
```

#### 一週間分の料金情報を取得

* `PARAMS`の値を、`getCostAndUsage`に渡すことで、料金情報を取得しています。
* `"Granularity": 'DAILY'`は、1日の料金を出力する設定です。
* `"TimePeriod"`は、料金を出力する範囲を設定します。今回は1週間分なので、`"Start"`8日前から、`"End"`1日前の範囲にします。  
1点注意したいのが、**`"End"`の日の料金は含まれないという事です。**
* `"Metrics": [ 'UnblendedCost' ]`は、ディスカウント適用前の金額を出力する設定です。値の意味については以下を参照してください。  
[AWS Cost Explorerに渡す、Metricsの値の意味](https://qiita.com/tamura_CD/items/4a9a412faf379b334986)
* 設定の仕様については、以下を参照してください。  
[AWS Cost Explorer Service](https://docs.aws.amazon.com/ja_jp/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html)  
ただ、1点注意したいのが、AWS Cost Explorer Serviceに短期間で大量のリクエスト（具体的な数値は非公開）を送ると、`LimitExceededException`がレスポンスで返るようになります。こうなると、解除されるまで待つしかありません。（上限緩和未対応）


```js
/* yyyy-mm-ddの書式で、集計の開始日と終了日をセット*/
const yesterday =  moment().subtract(1, 'days').format('YYYY-MM-DD');
const weekAgo = moment().subtract(8, 'days').format('YYYY-MM-DD');
  
/* request body　*/
const PARAMS = {
   "Granularity": 'DAILY',
   "GroupBy": [
     {
       "Type": 'DIMENSION',
       "Key": 'SERVICE',
     }
   ],
   "Metrics": [ 'UnblendedCost' ],
   "TimePeriod": { 
      "Start": weekAgo,
      "End": yesterday
   },
};

/**
 * Cost Management APIsから料金情報の取得処理
 * 
 * @return AWSから取得した料金情報
 */
function getCost() {
  console.log(`■□■□ Cost Management APIsから料金情報の取得処理 ■□■□`);
  return new Promise(
    (resolve) => {  
      costexplorer.getCostAndUsage(PARAMS, (err, data) => {
        if (err) {
          console.error(err, err.stack);
          return;
        }
        resolve(data);
      });
    }
  );
}
```
#### 任意のサービスの料金を計算

* `getCostAndUsage`から返ってくる料金情報は、1日毎に、サービス別に料金が記載されているので、これを見やすいように、サービス別に、1週間分の料金の合計を出します。  
今回は、よく使うサービスのみ計算します。

```js

/**
 * 使用料金の計算処理
 * 
 * @param data AWSから取得した料金情報
 * @return 料金集計結果のJSON
 */
function costCalc(data) {
  console.log(`■□■□ 使用料金の計算処理 ■□■□`);
  return new Promise(
    (resolve) => {
      let ec2Cost = 0, rdsCost = 0, ec2OtherCost = 0, elbCost = 0, cwCost = 0, other = 0;
      // let totalDays = moment(yesterday).diff(weekAgo, 'day'); // 集計範囲の日数
      let services, cost;
      /* 期間の料金の集計 */
      for(let i=0; i<data.ResultsByTime.length; i++) { // 日毎チェック
        services = data.ResultsByTime[i].Groups;
        for(let j=0; j<services.length; j++) { // サービス毎チェック
          cost = services[j].Metrics.UnblendedCost.Amount; // ディスカウント適用前(EDP割引等)のコスト
          switch(services[j].Keys[0]) {
            case "Amazon Elastic Compute Cloud - Compute":
              ec2Cost = ec2Cost + Number(cost);
              break;
            case "EC2 - Other":
              ec2OtherCost = ec2OtherCost + Number(cost);
              break;
            case "Amazon Relational Database Service":
              rdsCost = rdsCost + Number(cost);
              break;
            case "Amazon Elastic Load Balancing":
              elbCost = elbCost + Number(cost);
              break;
            case "AmazonCloudWatch":
              cwCost = cwCost + Number(cost);
              break;
            default:
              other = other + Number(cost);
              break;
          }
        }
      }
      /* 料金の集計結果 */
      let result = {
        "range": {
          "startDate": weekAgo,
          "endDate": yesterday
        },
        "services": { 
          "ec2": ec2Cost,
          "ec2other": ec2OtherCost,
          "rds": rdsCost,
          "elb": elbCost,
          "cw": cwCost,
          "other" : other
        },
        "total" : ec2Cost + ec2OtherCost + rdsCost + elbCost + cwCost + other
      };
      resolve(result);
    }
  );
}
```

#### SlackのAPIに料金情報を投げる

* Slack Incoming Webhooksのエンドポイントに、投稿したいメッセージをセットして、POSTリクエストを投げます。
* `PAYLOAD`の値がリクエストbodyになります。
  * `channel` : 投稿先のチャンネル
  * `username` : 投稿時の投稿者名
  * `text` : 投稿内容
  * `icon_url` : 投稿時のアイコンのURL
* ちなみに、`process.env.○○`はLambdaの環境変数です。Slackに渡すパラメータは、環境変数にした方が管理が楽ですが、必要無ければ直接書き込んでもOKです。

```js

const ENDPOINT = process.env.Endpoint_url;

const PAYLOAD = {
  "channel": process.env.channel,
  "username": process.env.username,
  "text": "",
  "icon_url": process.env.Icon_url
  };

/**
 * Slackへ料金情報を投稿処理
 * 
 * @param data 料金集計結果のJSON
 * @return Slackへ投稿リクエストを投げた結果のレスポンス
 */
function requestToAPI(data) {
  console.log(`■□■□ Slackへ料金情報を投稿処理 ■□■□`);
  return new Promise(
    (resolve, reject) => { 
      PAYLOAD.text = `先週（${data.range.startDate}〜${data.range.endDate} 00:00まで）のAWSの使用料は以下の通りです。\n\n`
                    + "```"
                    + `・EC2        : ${Math.round((data.services.ec2 + data.services.ec2other) * 10) / 10}$\n`
                    + `・RDS        : ${Math.round(data.services.rds * 10) / 10}$\n`
                    + `・ELB        : ${Math.round(data.services.elb * 10) / 10}$\n`
                    + `・CloudWatch : ${Math.round(data.services.cw  * 10) / 10}$\n`
                    + `・other      : ${Math.round(data.services.other * 10) / 10}$\n`
                    + `--------------------------------\n`
                    + `・total      : ${Math.round(data.total * 10) / 10}$\n\n`
                    + `※ 金額は、ディスカウント適用前で、端数切捨てです。\n`
                    + "```";
      
      console.log('■□■□■□ SlackへPOSTリクエスト:\n' + JSON.stringify(data));
    
      const parser = url.parse(ENDPOINT);
      let req, body = '';
      
      let options = {
        host : parser.host,
        port : 443,
        path : parser.path,
        method : 'POST',
        headers : {
          'Content-Type' : 'application/json'
        }
      };
    
      /* APIへのリクエスト */
      req = https.request(options, (res) => {
        res.setEncoding('utf8');
        /* レスポンスボディのデータ読み込み処理（dataイベント発生）*/
        res.on('data', (chunk) => {
          body += chunk;
        });
        /* 読み込むデータが無くなった時の処理（endイベント発生） */
        res.on('end', () => {
          if(res.statusCode == 200) {
            console.log('■□■□ レスポンス情報 ');
            console.log(body);
            resolve(body);
          } else {
            console.log('■□■□ レスポンス情報なし ');
          }
        });
      });
      
      req.write(JSON.stringify(PAYLOAD)); 
      req.on('error', (e) => {
        console.error(`problem with request: ${e.message}`);
        reject(e);
      });
      req.end();
  });
}

```

### 🔷 テスト

ここまで上手く設定できていたら、Lambda関数を実行すると、Slackに以下のように投稿されます。

<img width="653" alt="スクリーンショット 2019-03-29 14.07.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/38099/c227e1bb-84e3-978d-3fcf-3843b065851b.png">


これを、CloudWatch Eventで、毎週Lambda関数を実行するように設定したら、金額の変動に気付けるようになります。

ただ1点注意として、**`getCostAndUsage`に料金情報を要求するのも料金が発生します。**やり過ぎには注意してください。

## 🔶 まとめ
このように料金計算が面倒なだけで、内容は難しく無いので、ぜひ試してみてください。

## 🔶 おまけ

こちらにも挑戦して、Slackに投稿する内容に一手間加えてみてください。
[LambdaでTrusted Advisorの節約可能な月額料金を取得する（Node.js）](https://qiita.com/tamura_CD/items/9b832ffb14f3674d1052)

## 🔶 参考
* [SlackのIncoming Webhooksを使い倒す](https://qiita.com/ik-fib/items/b4a502d173a22b3947a0)
* [AWS Cost Explorer Service](https://docs.aws.amazon.com/ja_jp/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html)