LoginSignup
18

More than 5 years have passed since last update.

一括請求で紐づいているAWSアカウントごとのコストをSlackで確認する

Posted at

目的

請求情報をSlackから簡単に確認したいと思いました。

実現する機能

任意のSlackチャンネルで「/billing」を実行すると、一括請求で紐付いているアカウントごとの料金を取得する。

構成図

1.Slackの任意のチャンネルからスラッシュコマンドを実行
2.API Gatewayでリクエストを受け取り、リクエスト実行用Lambda呼び出し
3.Lambdaでスラッシュコマンドのトークンをチェック
 ※AWS KMSを利用
4.S3の請求情報CSVから必要な情報を抽出しSNS呼び出し
5.SNSからSlack返却用のレスポンス実行用Lambdaを呼び出し
6.Slackに情報を通知

image

SNSを使う理由は、Slash Commandsが3秒以内にレスポンスを返さないといけなかったからです。
連携させてみたかったからです。

一括請求(コンソリデーティッドビリング)とは?

複数のAWSアカウントを所有している場合支払いを1つのアカウントにまとめることができます。

一括請求を使用した複数アカウントの料金の支払い

一括請求機能を使って、1 つを支払いアカウントとして指定することで、組織内の複数のアマゾン ウェブ サービス (AWS) アカウントや複数の Amazon International Services Pvt. Ltd (AISPL) アカウントの支払いを統合できます。

請求レポートの設定

事前に請求レポートを特定のバケットに出力するよう設定をしておく必要があります。
設定方法については、以下を参考にしてください。

請求レポートで使用量を確認する

今回の対象ファイルは以下となります。
(AWS account number)-aws-billing-csv-yyyy-mm.csv

SNSの設定

東京リージョンで新しいトピックを作成します。
今回TopicNameは「billing-lambda」としています。

AWS_SNS.png

IAMの設定

暗号化キーの作成

Slackからリクエストする際のTokenを暗号化するためのKMSキーを作成します。
リージョンは東京リージョンで作成してください。
今回、キー管理者には自身のIAMユーザー、キーユーザーにはまだ何も設定しなくて大丈夫です。

IAM_Management_Console.png

IAM_Management_Console.png

リクエスト実行用Lambdaのロール作成

API Gateway経由で実行されるリクエスト実行用Lambdaのロールを作成します。
以下の権限を持ったロールを作成します。

  • 一括請求用S3バケットから請求レポートを取得する
  • CloudWatch Logsにログを出力する
  • レスポンス用Lambdaの呼び出しSNSにPublishを行う
  • 暗号化されたTokenを復号する

今回は、yamanaka_acceptBillingRequestというロールを作成し、以下ポリシーを設定しました。
※S3のResourceの最後にアスタリスク(*)が必要!

acceptBillingRequestPolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1473241089000",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::<請求レポートが格納されているバケットのバケット名>/*"
            ]
        },
        {
            "Sid": "Stmt1473241213000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<作成した暗号化キーのARN>"
            ]
        },
        {
            "Sid": "Stmt1473241313000",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Sid": "Stmt1473241406000",
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": [
                "<レスポンス用Lambda呼び出しSNSのARN>"
            ]
        }
    ]
}

レスポンス実行用Lambdaのロール作成

SNS経由で実行されるレスポンス実行用Lambdaのロールを作成します。
以下の権限を持ったロールを作成します。

  • CloudWatch Logsにログを出力する

今回は、yamanaka_acceptBillingResponseというロールを作成し、以下ポリシーを設定しました。

acceptBillingResponsePolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1473241745000",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

KMSのキーユーザー設定

KMSの設定画面に戻りキーユーザーとしてリクエスト実行用のロールを設定します。

IAM_Management_Console.png

これでIAMでの設定は完了です。

リクエスト実行用Lambdaファンクションの作成

リクエスト実行用のLambdaファンクションyamanaka_lambda_acceptBillingRequestを作成します。

各種設定は以下を選んでください。
 Runtime : Node.js 4.3
 Role : リクエスト実行用のロール(yamanaka_acceptBillingRequest)

また、今回は
 Memory : 192MB
 Timeout : 3 sec
としています。

コード

コードは以下の通りです。
簡単に説明すると以下のようなことを行っています。

  1. SlackのパラメタからTokenを取得し、トークンが正しいかチェック
  2. 該当月の請求レポートをS3バケットから取得
  3. 請求レポートから各アカウントの請求金額を取得及び配列に格納
  4. 格納した情報をメッセージとしてSNSを発行する
yamanaka_lambda_acceptBillingRequest.js
/*jshint esversion: 6 */
/*jshint strict: true */
/* jshint node: true */
'use strict';

console.log('Loading function');
const AWS = require("aws-sdk");
const querystring = require('querystring');
const region = 'ap-northeast-1';
const now = new Date();

exports.handler = function(event, context, callback) {

    const generator  = (function *() {

        try {
            // FunctionArnからaccountIdを取得
            const accountId = context.invokedFunctionArn.match(/\d{3,}/)[0];
            const commandParams = querystring.parse(event["body-json"]);

            let targetMonth;
            if(!isNaN(parseInt(commandParams.text, 10))) {
                // 月が指定されている場合、引数を設定
                targetMonth = commandParams.text;
            } else {
                // 月が指定されていない場合、実行月を設定
                targetMonth = now.getUTCMonth() + 1;
            }

            // Slackのトークンをチェック
            yield tokenCheck(commandParams.token, generator);

            // S3からファイルを取得
            const billingCsvFile = yield getBillingFile(accountId, targetMonth, generator);

            // 必要な情報を取得
            const costList = pickUpCostList(billingCsvFile);
            // 通知元チャンネル名を格納
            costList[costList.length - 1].channelName = commandParams.channel_name;
            costList[costList.length - 1].month = targetMonth;

            // SNSパブリッシュ
            yield responseCall(accountId, costList, generator);

            callback(null,'Request is accepted');

        } catch (e) {
            callback(e.message);
        }
    })();

    /* 処理開始 */
    generator.next();
};

// トークンチェック
function tokenCheck(token, generator) {
    const kms = new AWS.KMS({'region':region});
    const encryptedToken = '';
    const encryptedBuf = new Buffer(encryptedToken, 'base64');

    const kmsParams = {
        CiphertextBlob: encryptedBuf
    };

    kms.decrypt(kmsParams, function(err, data) {
      if (err) {
          console.log(err, err.stack);
          generator.throw(new Error('Kms Decrypt Error'));
          return;
      } else {
          if (data.Plaintext.toString() !== token) {
              generator.throw(new Error('Slash Commands Token Error'));
          }
          console.log('token is OK');
          generator.next();
      }
    });
}

function getBillingFile(accountId, targetMonth, generator) {

    const s3 = new AWS.S3();
    const bucketName = '<請求レポートが格納されているバケットのバケット名>';
    const key = accountId + '-aws-billing-csv-' +
        now.getUTCFullYear() + '-' + ('0' + targetMonth).substr(-2) + '.csv';

    console.log('getting S3 file %s ..', key);

    const params = {
        Bucket: bucketName,
        Key: key
    };

    s3.getObject(params, function(err, data) {
        if (err) {
            console.log(err, err.stack);
            generator.throw(new Error('S3 Error'));
            return;
        }
        console.log('got S3 stream ..');
        generator.next(data.Body.toString());
    });
}

// Totalcostを二次元配列にとして返却
function pickUpCostList(csvFile){
    let estimateStatus = 'exact';
    const costList = [];
    // 一行ずつ配列としてtmpに格納
    const tmp = csvFile.split("\n");

    // 各行ごとにカンマで区切った文字列を要素とした二次元配列を生成
    for(let i = 0, listCount = 0, csvLine; i < tmp.length; ++i){
        csvLine = tmp[i].replace(/\"/g,"").split(',');
        if (csvLine[3] === 'AccountTotal') {
            costList[listCount] = {};
            // 必要な箇所だけ配列に格納
            costList[listCount].accountId   = csvLine[2];
            costList[listCount].accountName = csvLine[9];
            costList[listCount].totalCost   = csvLine[csvLine.length - 1];
            listCount++;
        } else if (csvLine[3] === 'EstimatedDisclaimer') {
            // 最終行にEstimatedDesclaimerが存在する場合、rough(概算)を設定
            estimateStatus = 'rough';
        }
    }

    // accountNameで昇順にソート
    costList.sort(function(a,b) {
       if(a.accountName<b.accountName) {
           return -1;
       }
       if(a.accountName>b.accountName) {
           return 1;
       }
       return 0;
    });

    costList[costList.length] = {};
    // costListの最後の要素に確定・概算のステータスを追加
    costList[costList.length - 1].estimateStatus = estimateStatus;

    return costList;
}

// SNSでLambdaを呼び出し
function responseCall(accountId, costList, generator) {
    const sns = new AWS.SNS({'region':region});
    const topicName = 'billing-lambda';

    const params = {
        Message: JSON.stringify(costList),
        TopicArn: 'arn:aws:sns:' + region + ':' + accountId + ':' + topicName
    };

    console.log('publishing SNS ..');
    sns.publish(params, function(err, data) {
        if (err) {
            console.log(err, err.stack);
            generator.throw(new Error('SNS Error'));
            return;
        } else {
            console.log('published SNS ..');
            console.log(data);
            generator.next();
        }
    });
}

API Gatewayの設定

任意の名前でAPIを新しく作成します。
今回のリソースは以下のように設定しました。
 リソース : /slackcommand
 メソッド : POST

メソッドのセットアップ

以下の通り選択してください。

統合タイプ : Lambda関数
Lambdaリージョン : ap-northeast-1
Lambda関数 : リクエスト実行用Lambda(yamanaka_lambda_acceptBillingRequest)

API_Gateway.png

統合リクエストの設定

リクエスト本文のパススルー : テンプレートが定義されていない場合
Content-Type : application/x-www-form-urlencoded
テンプレートの生成 : メソッドリクエストのパススルー

API_Gateway.png

※Content-Typeについては、以下に記載がありました。
  Slash Commands

Typically, this data will be sent to your URL as a HTTP POST with a content-type header set as application/x-www-form-urlencodedred. If you've chosen to have your slash command's URL receive invocations as a GET request, no explicit content-type header will be set.

保存したら、新しいステージを作成し、デプロイしてください。
※今回はdevステージにデプロイ

API_Gateway.png

これでAPI Gatewayの作成は完了です!

Slash Commandsの設定

Slash Commandsとは?

Slash Commandsとは、任意のSlackチャンネルで/commandsと入力することでカスタムコマンドを実行できる機能です。

注意として、Slash Commandsは実行後、3秒以内にレスポンスを返さないとタイムアウトしてしまいます。

設定手順

  1. Slackにログインした状態でhttps://.slack.com/services/newにアクセス
  2. 検索ボックスに「Slash Commands」と入力し選択する
  3. 「Add Configuration」を選択
  4. Choose a Commandに「/billing」を入力し、「Add Slash Command Integration」を選択する
  5. 以下の通り設定

  Command : /billing
  URL : API GatewayのURLを設定
  Method : POST

  ※Tokenは後ほど使用するのでメモしておく!

Slash_Commands___sks-2sp_Slack.png

トークンの暗号化

作成した暗号化キーでSlash CommandsのTokenを暗号化します。

ローカルのPCで以下コマンドを実行し、Tokenの暗号化を実行します。
(AWS CLIの環境は整っているものとします。)

$ aws kms encrypt --key-id alias/<暗号化キーのalias> --plaintext="<Slash CommandsのToken>"

実行すると以下が出力されるので、CiphertextBlobの値をメモしてください。

{    "KeyId": "arn:aws:kms:ap-northeast-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
     "CiphertextBlob": "BASE64-encoded-string"}

暗号化済みTokenの設定

リクエスト実行用Lambdaファンクション内の以下の箇所を修正します。

acceptBillingRequest.js(修正前)
const encryptedToken = '';
acceptBillingRequest.js(修正後)
const encryptedToken = '<メモしたCiphertextBlobの値>';

Slackのincoming webhookの設定

以下の記事を参考に設定し、Webhook URLを控えておいてください。

Slackにincoming webhook経由でpythonからメッセージをPOSTする

レスポンス実行用Lambdaファンクションの作成

レスポンス実行用のLambdaファンクションyamanaka_lambda_acceptBillingResponseを作成します。

各種設定は以下を選んでください。
 Runtime : Node.js 4.3
 Role : レスポンス実行用のロール(yamanaka_acceptBillingResponse)

また、今回は
 Memory : 256MB
 Timeout : 3 sec
としています。

※このLambdaファンクションにはslack-nodeモジュールを使用しているため、Lambdaのjsファイルと一緒に以下slack-nodeモジュールもzip化しアップロードしてください。
slack-node

コード

コードは以下のとおりです。
こっちも簡単に説明すると以下のようなことを行っています。

  1. SNSから各アカウントの請求金額を取得
  2. 通知先のSlackチャンネルを設定
  3. 通知するメッセージを作成
  4. Slack通知

webhookUriにはメモしておいたWebhook URLを入力してください。

yamanaka_lambda_acceptBillingResponse.js
/*jshint esversion: 6 */
/*jshint strict: true */
/* jshint node: true */
'use strict';

console.log('Loading function');

exports.handler = function(event, context, callback) {
    try {
        const billingInfo = JSON.parse(event.Records[0].Sns.Message);
        // チャンネル名を設定
        const channelName = billingInfo[billingInfo.length - 1].channelName;

        // Slackで通知するメッセージを作成
        const message = createMessage(billingInfo);

        // Slack通知
        postMessage(channelName, message);

        callback(null, 'success');

    } catch (e) {
        callback(e.message);
    }
};

// Slackで通知するメッセージを作成
function createMessage(billingInfo) {

    // 見積もりステータスを設定
    const estimateStatus = billingInfo[billingInfo.length - 1].estimateStatus;
    // 対象月を設定
    const month = billingInfo[billingInfo.length - 1].month;

    const message = {
        text : '',
        messageAttachments : []
    };

    // 金額が確定か概算か
    if (estimateStatus === 'rough') {
        message.text = month + '月分 概算';
    } else if(estimateStatus === 'exact') {
        message.text = month + '月分 確定';
    }

    for (let i = 0, max = billingInfo.length - 1, account; i < max; i++) {

        if (billingInfo[i].accountName) {
            // accountNameが存在する場合はaccountNameを使用
            account = billingInfo[i].accountName;
        } else {
            // accountNameが存在しない場合はaccountIdを使用
            account = billingInfo[i].accountId;
        }

        // 小数点第3位で四捨五入
        const totalCost = Math.round(billingInfo[i].totalCost * 100) / 100;

        message.messageAttachments[i] = {
            color : '#36a64f',
            title : account,
            text  : '$ ' + totalCost
        };
    }

    return message;
}

// Slack通知
function postMessage(channel, message) {
    const Slack = require('slack-node');
    const slack = new Slack();
    // webhook用URI
    const webhookUri = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxx';
    slack.setWebhook(webhookUri);

    const slackParams = {
        channel : channel,
        username : "billingmaster",
        text : message.text,
        icon_emoji : ":aws:",
        attachments : message.messageAttachments
    };

    console.log('Send Message ..');

    slack.webhook(slackParams, function(err, response) {
        if (err) {
            console.log(err, err.stack);
            throw new Error('Could not send message');
        }
        console.log('Sent Message..');
        console.log(response);
    });
}

試してみる

一通りの準備が終わったので、実際に試して見ます。

任意Slackチャンネル内で/billingと入力し実行します。

スクリーンショット_090916_102041_AM.jpg

実行後しばらく経つと、各アカウントごとに当月分の金額が出力されました。
今月はまだ金額が確定していないため概算と表示されています。

Slack 2.png

次は、引数を付けて実行してみます。
/billing [month]という形で指定月の情報を見ることができます。
/billing 8を実行して8月分の金額を確認してみます。

Slack 3.png

ちゃんと8月分の確定金額が表示されました。

Slack 4.png

念のため8月分の請求情報をAWSのマネジメントコンソールから表示させてみると

スクリーンショット_090916_101923_AM.jpg

金額がちゃんと対応していることが確認できました!

備考

S3のトリガーイベントを利用することで、金額が確定した際にだけSlackの通知も行えるなーとは思ったのですが、確定したらAWSからPDFがくるしいいかなと思いやめました。
また、現在のコードは直近の金額確認しか意識していないため、コマンド実行時と同じ年の情報しかもってこれません。
そこらへんは使っていく中で不便に思ったら適宜修正する予定です。

引数チェック等もやってないので、必要であれば追加してください。

参考にさせていただきました

非常に参考にさせていただいたサイトです。ありがとうございます!

SlackのSlash commandの処理をAWS Lambdaで実装

aws-billing

以上

予想以上に長くなってしまいました。おつかれさまでした!

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
18