Help us understand the problem. What is going on with this article?

LambdaでSlackにアカウント毎のAWS利用料金を投稿

はじめに

下記、投稿を元にSlackに利用料金を投稿する仕組みを作成したが、AWSアカウントが増えてきてアカウント毎の料金がしりたくなり改修しました。基本的な流れは下記の投稿を参照してください。
わかりやすく書かれているので非常に参考になりました。

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

注意

AWSの料金を取得するCost ExplorerのAPIはバージニア北部リージョンでしか利用できないので、Lambdaはバージニア北部リージョンで作成する必要がある。

Lambdaのコード

2ヵ所は個別に修正してください。
ENDPOINT :Incoming WebhooksのURL
PAYLOADのchannel:送信先のURL

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

const AWS = require('aws-sdk');
const costexplorer = new AWS.CostExplorer();
const moment = require('moment');
const https = require ('https');
const url = require('url');
const ENDPOINT = "★★★★★SlackのエンドポイントのURL★★★★★";
const PAYLOAD = {
  "channel": "★★★★★送信するチャンネル名★★★★★",
  "username": "AWS使用料",
  "text": ""
  //"icon_url": ""
  };

/**
 * メイン処理
 *
 */
exports.handler = async (event) => {

  // アカウント名の取得
  let accountNameJson = await getAccountName();
  // 合計文字列
  const TOTAL = "total";
  const LINE = "---------------------------------------------------------\n";
  // 出力用の文字列取得
  var outPutStr = `昨日(${yesterday})のAWSの使用料は以下の通りです。\n\n`;
  await requestToAPI(outPutStr);
  // アカウントリスト
  var accountList = [];
  // アカウント毎の合計
  var accountCost = 0;

  for(let i=0; i<accountNameJson.DimensionValues.length; i++) { // アカウント毎
    var accounts = accountNameJson.DimensionValues[i];
    var accountNo = accounts.Value;
    var accountName = accounts.Attributes.description + '(' + accountNo + ')';
    outPutStr = "```\n"
    // アカウント名
    outPutStr = outPutStr + "" + accountName + "\n";
    // アカウント毎の全サービスの料金取得
    SERVICE_FEE_PARAMS.Filter.Dimensions.Values = [accountNo];
    var costServiceRaw  = await getCost(SERVICE_FEE_PARAMS);
    for(let i=0; i<costServiceRaw.ResultsByTime.length; i++) { // 日毎チェック
      var services = costServiceRaw.ResultsByTime[i].Groups;
      for(let j=0; j<services.length; j++) { // サービス毎チェック
        // サービス
        var service = services[j];
        // サービス名
        var serviceName = service.Keys[0];
        // ディスカウント適用前(EDP割引等)のコスト
        var cost = service.Metrics.UnblendedCost.Amount;
        // 0$の場合は出力しない
        if (cost != "0") {
          // サービス毎の料金
          outPutStr = outPutStr + getNot0Fee(serviceName, cost);
          // アカウント毎の合計に加算
          accountCost = accountCost + Number(cost);
        }
      }
    }
    // アカウント毎の合計
    outPutStr = outPutStr + LINE + getNot0Fee(TOTAL, accountCost) + "\n";
    // アカウント毎の情報を格納
    accoutInfo = {};
    accoutInfo.accountName = accountName;
    accoutInfo.accountCost = accountCost;
    accountList.push(accoutInfo);
    accountCost = 0;
    outPutStr = outPutStr + "```\n"
    await requestToAPI(outPutStr);
  }

  // 料金の降順にソート
  accountList.sort(function(a,b){
    if(a.accountCost > b.accountCost) return -1;
    if(a.accountCost < b.accountCost) return 1;
    return 0;
  });

  // アカウント毎の合計出力
  outPutStr = "```\n★総合計(料金の降順)\n";
  // 総合計
  var totalCost = 0;
  for(key in accountList){
    account = accountList[key]
    outPutStr = outPutStr + getNot0Fee(account.accountName, account.accountCost);
    totalCost = totalCost + account.accountCost;
  }
  outPutStr = outPutStr + LINE + getNot0Fee(TOTAL, totalCost) + "\n※ 金額は、ディスカウント適用前で、端数切捨てです。\n```\n";

  // 結果出力
  let result   = await requestToAPI(outPutStr);
  return result;
};

/* yyyy-mm-ddの書式で、集計の開始日と終了日をセット*/
const yesterday =  moment().subtract(1, 'days').format('YYYY-MM-DD');
const today = moment().subtract(0, 'days').format('YYYY-MM-DD');

/* サービス毎の料金取得パラメータ*/
const SERVICE_FEE_PARAMS = {
  "Granularity": 'DAILY',
  "Filter": {
    "Dimensions": {
      "Key": "LINKED_ACCOUNT",
      "Values": [""]
    }
  },
  "GroupBy": [
    {
      "Type": 'DIMENSION',
      "Key": 'SERVICE',
    }
  ],
  "Metrics": [ 'UnblendedCost' ],
  "TimePeriod": {
      "Start": yesterday,
      "End": today
  },
};

/* アカウント毎の名称取得パラメータ*/
const ACCOUNT_NAME_PARAMS = {
  "Dimension": 'LINKED_ACCOUNT',
  "Context": 'COST_AND_USAGE',
  "TimePeriod": {
      "Start": yesterday,
      "End": today
  },
};

/**
 * Cost Management APIsから料金情報の取得処理
 *
 * @return AWSから取得した料金情報
 */
function getCost(param) {
  console.log(`■□■□ Cost Management APIsから料金情報の取得処理 ■□■□`);
  return new Promise(
    (resolve) => {
      costexplorer.getCostAndUsage(param, (err, data) => {
        if (err) {
          console.error(err, err.stack);
          return;
        }
        resolve(data);
      });
    }
  );
}

/**
 * Cost Management APIsからアカウント情報の取得処理
 *
 * @return AWSから取得したアカウント情報
 */
function getAccountName() {
  console.log(`■□■□ Cost Management APIsからアカウント情報の取得処理 ■□■□`);
  return new Promise(
    (resolve) => {
      costexplorer.getDimensionValues(ACCOUNT_NAME_PARAMS, (err, data) => {
        if (err) {
          console.error(err, err.stack);
          return;
        }
        resolve(data);
      });
    }
  );
}

/**
 * Slackへ料金情報を投稿処理
 *
 * @param outPutStr Slack出力文字列
 * @return Slackへ投稿リクエストを投げた結果のレスポンス
 */
function requestToAPI(outPutStr) {

  console.log(`■□■□ Slackへ料金情報を投稿処理 ■□■□`);
  console.log(`■□■□ Slackへ料金情報を投稿処理 ■□■□`);
  return new Promise(
    (resolve, reject) => {
      PAYLOAD.text = outPutStr;

      console.log('■□■□■□ SlackへPOSTリクエスト:\n' + PAYLOAD.text);

      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();
  });
}

/**
 * 投稿用の料金名と料金を取得します。
 * 料金が0の場合は、空文字を返します。
 *
 * @param feeName 料金名
 * @param fee 料金
 * @return 投稿する料金
 */
function getNot0Fee(feeName, fee) {
  let roundFee = Math.round(fee  * 10) / 10;
  if (roundFee == 0) {
    return '';
  }
  return paddingright('' + feeName, ' ', 50)+ ' :' + roundFee + '$\n';
}

/**
  右埋めする処理
  指定桁数になるまで対象文字列の右側に
  指定された文字を埋めます。
  @param val 右埋め対象文字列
  @param char 埋める文字
  @param n 指定桁数
  @return 右埋めした文字列
**/
function paddingright(val, char, n){
  for(; val.length < n; val+=char);
  return val;
}

投稿

このように投稿されます。
fee.jpg

※アカウント・使用しているサービスが増えるとSlackの文字数の上限を超えて、ズタズタになってしまったので、アカウント毎にSlackに投稿しています。

その他

作成したLambdaをCloudWatch Eventsで毎日決まった時間にLambdaを起動するようにすれば、毎日Slackに料金が投稿されます。

参考

・LambdaでSlackにAWSの利用料金を投稿する(Node.js)
https://qiita.com/tamura_CD/items/33cceb2eac7f1f2fe221
・APIリファレンス
https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away