AWSを使っていると、変な使い方をしてると予算をオーバーしてしまいます。 このため、日次でAWS Cost Explorerを確認しているのですが、これはこれで面倒です。 面倒な行為というのはだいたい能動的な確認が求められるのから面倒であるので、 AWS Lambdaから昨日のコストがSlack通知される仕組みを作りました。
以下のように、サービスごとに前日の課金額Top 10を通知してくれます。
コストの抽出
AWS SDK
コストを抽出するにあたり、今回はじめてAWS SDK for JavaScriptを利用しました。
JavaScriptの理解不足もあって結構ハマりました。
1点目のハマりポイントCost Explorerのエンドポイントはus-east-1
にあるので明示的に指定が必要である点。
// Cost Explorer のエンドポイントは us-east-1 に存在する // ref: https://docs.aws.amazon.com/ja_jp/general/latest/gr/billing.html const ce = new AWS.CostExplorer({region: 'us-east-1'});
また、AWS.Request
にはpromise
メソッドが生えていることに長く気づけなかったことで、
callback使うしかないのか?という点に結構悩みました。
const cost = await ce.getCostAndUsage(params).promise();
正直ぼくはJavaScriptが苦手なので、Go使った方がよかったなぁと後悔しています。
コスト抽出の実装
コストの抽出自体は、AWS SDKのgetCostAndUsage
が使えます。
AWS Cost Explorer上でのグラフ描画に使われているAPIはおそらくこれでしょう。
引数がかなり難解なのですが、今回は以下のようなパラメータを設定しました。
const params = { Granularity: 'DAILY', TimePeriod: { Start: yyyymmdd(yesterday), End: yyyymmdd(today), }, Metrics: ['UnblendedCost'], GroupBy: [{ Type: 'DIMENSION', Key: 'SERVICE', }], };
TimePeriod.Start
からTimePeriod.End
までの期間のコストを返却してくれます。
ここでGranularity
をDAILY
に指定すると、当該期間のコストが日毎の集計値として返却されます。
Metrics
の指定は少し厄介なのですが、UnblendedCost
はキャッシュベースでの金額を返却します。
キャッシュベースではない例としてAmortizedCost
があり、こちらはReserved Instance等「一定期間分」のコストをならしてくれるコストです。
マニュアル上ではほとんど説明されていないのですが、こちらのBlogエントリが非常にわかりやすいのでご一読ください。
GroupBy
ではType
をDIMENSION
、Key
をSERVICE
とすることで「サービス毎」の集計を意味します。
こうして取得したサービスごとの日次コストのTop 10は以下のようにして取得します。
try { const cost = await ce.getCostAndUsage(params).promise() // サービスと課金額のマップを作成 // 前日のみのコストを取得するので、ResultsByTimeのインデックスは0固定 const costmap = cost.ResultsByTime[0].Groups.map((e) => ({ service: e.Keys[0], amount: parseFloat(e.Metrics.UnblendedCost.Amount), })) .filter((e) => e.amount > 0) // $0 のサービスがなぜか含まれるためフィルタ .sort((a, b) => b.amount - a.amount) .slice(0, 10); // Slack の fields は 10 個まで
Slackへの通知
Slackへの通知は、Slack AppのWebhookを利用します。 このWebhookにはBlock Appが利用可能で、かなり柔軟なレイアウトが指定できます。
ただ、今回は大したデータを載せるわけでもないので、
header
とsection
を指定するだけのシンプルなレイアウトとしました。
以下のようなJSONをWebhookのBODYに渡しています。
{ "blocks": [ { "type": "section", "text": "cost on 2021-07-21" }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "サービス名とそのコスト", }, { /* 略 */ }, ] }, ] }
これを作り上げるコードは以下の通り。なにも捻ってません。
const slackNotificationContent = (date, costmap) => { const header = { type: 'header', text: { type: 'plain_text', text: `COST on ${date}`, }, }; const section = { type: 'section', fields: costmap.map((e) => ({ type: 'mrkdwn', text: `*${e.service}*\n\$${e.amount.toFixed(3)}`, })), }; const content = { blocks: [header, section], }; return content; };
Lambda
AWSレイヤでも特別凝ったことをしているわけではありません。 Cloudwatch Eventsのcron式で定期的にLambda関数実行イベントを発火させているだけです。
Lambda関数からgetCostAndUsage
を利用するためには、Lambda関数にce:GetCostAndUsage
を許可する必要があります。ぼくはこんなポリシーをLambda関数にアタッチしています。
data "aws_iam_policy_document" "getcostandusage" { statement { sid = "GetCostAndUsage" actions = [ "ce:GetCostAndUsage", ] resources = [ "*", ] } }
Nodeのコード全体
const AWS = require('aws-sdk'); const {IncomingWebhook} = require('@slack/webhook'); // Cost Explorer のエンドポイントは us-east-1 に存在する // ref: https://docs.aws.amazon.com/ja_jp/general/latest/gr/billing.html const ce = new AWS.CostExplorer({region: 'us-east-1'}); // Date を YYYYMMDD 形式に変更する const yyyymmdd = (d) => d.toISOString().split('T')[0]; exports.handler = async function(event, context) { const today = new Date(); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); const params = { Granularity: 'DAILY', TimePeriod: { Start: yyyymmdd(yesterday), End: yyyymmdd(today), }, Metrics: ['UnblendedCost'], GroupBy: [{ Type: 'DIMENSION', Key: 'SERVICE', }], }; const run = async () => { try { const cost = await ce.getCostAndUsage(params).promise(); // サービスと課金額のマップを作成 const costmap = cost.ResultsByTime[0].Groups.map((e) => ({ service: e.Keys[0], amount: parseFloat(e.Metrics.UnblendedCost.Amount), })) .filter((e) => e.amount > 0) // $0 のサービスがなぜか含まれるためフィルタ .sort((a, b) => b.amount - a.amount) .slice(0, 10); // Slack の fields は 10 個まで const webhook = new IncomingWebhook(process.env['WEBHOOK_ENDPOINT']); const content = slackNotificationContent( process.env['ACCOUNT_ID'], yyyymmdd(yesterday), costmap); await webhook.send(content); } catch (e) { console.error(JSON.stringify(e)); } }; await run(); }; const slackNotificationContent = (accountId, date, costmap) => { const header = { type: 'header', text: { type: 'plain_text', text: `${accountId} COST on ${date}`, }, }; const section = { type: 'section', fields: costmap.map((e) => ({ type: 'mrkdwn', text: `*${e.service}*\n\$${e.amount.toFixed(3)}`, })), }; const content = { blocks: [header, section], }; return content; };