27
32

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-03-29

🔶 はじめに

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を使い倒す

🔶 Lambdaの実装

🔷 Lambda関数作成

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

API Gateway + LambdaでREST API開発を体験しよう [10分で完成編]

🔷 ポリシーの設定

料金情報の取得には、Cost Explorer Serviceの、GetCostAndUsageを利用します。
そのため、Lambda関数に以下のポリシーを追加します。

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

🔷 実装

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

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

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

メイン処理

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

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の値の意味
  • 設定の仕様については、以下を参照してください。
    AWS Cost Explorer Service
    ただ、1点注意したいのが、AWS Cost Explorer Serviceに短期間で大量のリクエスト(具体的な数値は非公開)を送ると、LimitExceededExceptionがレスポンスで返るようになります。こうなると、解除されるまで待つしかありません。(上限緩和未対応)
/* 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週間分の料金の合計を出します。
    今回は、よく使うサービスのみ計算します。

/**
 * 使用料金の計算処理
 * 
 * @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です。

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に以下のように投稿されます。

スクリーンショット 2019-03-29 14.07.36.png

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

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

🔶 まとめ

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

🔶 おまけ

こちらにも挑戦して、Slackに投稿する内容に一手間加えてみてください。
LambdaでTrusted Advisorの節約可能な月額料金を取得する(Node.js)

🔶 参考

27
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
32