理系学生日記

おまえはいつまで学生気分なのか

AWS SDK + LambdaでAWSの前日コストをSlackに通知する

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までの期間のコストを返却してくれます。 ここでGranularityDAILYに指定すると、当該期間のコストが日毎の集計値として返却されます。

Metricsの指定は少し厄介なのですが、UnblendedCostはキャッシュベースでの金額を返却します。 キャッシュベースではない例としてAmortizedCostがあり、こちらはReserved Instance等「一定期間分」のコストをならしてくれるコストです。 マニュアル上ではほとんど説明されていないのですが、こちらのBlogエントリが非常にわかりやすいのでご一読ください。

GroupByではTypeDIMENSIONKeySERVICEとすることで「サービス毎」の集計を意味します。

こうして取得したサービスごとの日次コストの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が利用可能で、かなり柔軟なレイアウトが指定できます。

ただ、今回は大したデータを載せるわけでもないので、 headersectionを指定するだけのシンプルなレイアウトとしました。 以下のような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;
};