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

日々のAWS請求額をグラフ付きでSlackに通知する

More than 1 year has passed since last update.

はじめに

以前、CloudWatch の GetMetricWidgetImage API を利用して
CloudWatchアラームと対応するグラフを一緒にSlackへ通知する記事を投稿しました。

CloudWatchアラームとグラフを一緒にSlack通知する
https://qiita.com/hayao_k/items/026e704b5fad3037aea0

上記の応用で、Slackに日々通知しているAWSの利用金額について
前日との差分をグラフ付きでPostすれば1日あたりどれくらい増えているか把握しやすいのでは
と考えて作ってみました。

結果イメージ

CloudWatchから指定した条件のグラフを取得してSlackに通知します。
グラフには注釈として当日と前日の金額をプロットします。
image.png
画像のみ拡大。
グラフタイトルに概算合計請求額と、増分を記載しています。
image.png

ざっくり構成

image.png

CloudWatch EventsでLambdaを毎日指定時刻に起動します。
Lambda関数はCloudWatchのGetMetricStatisticsでBillingから当日と前日の概算合計請求額
を取得します。更にGetMetricWidgetImageで請求額情報をプロットしたグラフを取得します。
取得したイメージは、Slack APIのfiles.uploadメソッドで指定したチャンネルにPostします。

Lambda関数

ひよコードかもしれませんが、ご容赦ください:hatched_chick:
ランタイムはpython 3.6です。

lambda_function.py
import base64
import boto3
import datetime
import json
import logging
import os
import requests
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

client = None

def get_cloudwatch_client():
    global client
    client = boto3.client('cloudwatch', region_name='us-east-1')
    return client

def get_params():
    token = os.environ['TOKEN']
    channel = os.environ['CHANNEL']
    title = 'AWS EstimatedCharges'
    message = '本日の利用金額は...'
    slack_params = {
        'token': token, 
        'channels': channel,
        'initial_comment': message,
        'title': title
    }
    return slack_params

def get_metrics(client=None):
    if client is None:
        client = get_cloudwatch_client()

    try: 
        response = client.get_metric_statistics(
            Namespace = 'AWS/Billing',
            MetricName = 'EstimatedCharges',
            Dimensions =[
                {
                    'Name': 'Currency',
                    'Value': 'USD'
                }
            ],
            StartTime = datetime.datetime.now() - datetime.timedelta(days=1.5),
            EndTime = datetime.datetime.now(),
            Period = 21600,
            Statistics = ['Maximum']
        )
    except ClientError as e:
        logger.error("Request failed: %s", e.response['Error']['Message'])
    else:
        return response

def get_image(metrics, client=None):
    if client is None:
        client = get_cloudwatch_client()

    sorted_data = sorted(metrics['Datapoints'], key=lambda x: x['Timestamp'])
    logger.info("Sorted Data: %s", sorted_data)
    today = sorted_data[-1]['Maximum']
    yesterday = sorted_data[0]['Maximum']
    diff = round(today - yesterday, 2) 
    if diff > 0:
        title = ('Estimated Chages: $' + str(today) +
                 ' / Increased $' + str(diff) + ' from Yesterday')
    else:
        title = ('Estimated Chages: $' + str(today))

    widget_definition = json.dumps(
        {
            "width": 600,
            "height": 400,
            "start": "-PT72H",
            "end": "PT0H",
            "timezone": "+0900",
            "view": "timeSeries",
            "stacked": True,
            "stat": "Maximum",
            "title": title,
            "metrics": [ 
                ["AWS/Billing", "EstimatedCharges", "Currency", "USD" ]
            ],
            "period": 21600,
            "annotations": {
                "horizontal": [
                    {
                        "label": "Today",
                        "value": today
                    },
                    {
                        "label": "Yesterday",
                        "value": yesterday
                    }
                ]
            },
            "yAxis": {
                "left": {
                    "label": "$",
                    "min": (yesterday * 0.75),
                    "max": (today * 1.05)
                }
            },
        }
    )

    try:
        response = client.get_metric_widget_image(
            MetricWidget = widget_definition
        )
    except ClientError as e:
        logger.error("Request failed: %s", e.response['Error']['Message'])
    else:
        image = {'file': response['MetricWidgetImage']}
        logger.info("Get Image succeeded.")
        return image 

def lambda_handler(event, context):
    params = get_params()
    metrics = get_metrics()
    files = get_image(metrics)
    upload_url = 'https://slack.com/api/files.upload'

    req = requests.post(upload_url, params=params, files=files)
    try:
        req.raise_for_status()
        logger.info("Message posted.")
        return req.text
    except requests.RequestException as e:
        logger.error("Request failed: %s", e)
    return req

グラフの注釈に金額をプロットするために、get_metric_statisticsメソッドで
概算合計請求額を取得しています。

get_metric_statistics
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudwatch.html#CloudWatch.Client.get_metric_statistics

datetimeで実行時刻から、StartTimeとEndTimeを指定します。
days=1だと、6時間毎のDatapointが3つしか取得できなかった(最新のデータポイントから
18時間前の金額しか取得できなかった)ため、1.5としています。

StartTime=datetime.datetime.now() - datetime.timedelta(days=1.5),
EndTime=datetime.datetime.now(),

 
widget_definition でGetMetricWidgetImageの入力パラメータを定義しています。
"annotations" が注釈です。get_metric_statisticsで取得した金額を指定します。
軸の指定(yAxis)で、グラフの表示幅を調整しています。

            "yAxis": {
                "left": {
                    "label": "$",
                    "min": (yesterday * 0.75),
                    "max": (today * 1.05)
                }
            },

その他全体の構造については以下のドキュメントを参照してください。

GetMetricWidgetImage: Metric Widget Structure and Syntax
https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/CloudWatch-Metric-Widget-Structure.html

上記を元に get_metric_widget_image メソッドでグラフを取得します。

get_metric_widget_image
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudwatch.html#CloudWatch.Client.get_metric_widget_image

2018/11/12 時点で、東京リージョンのLambda実行環境に含まれるboto3では
get_metric_widget_image を使用できませんでした。いずれアップグレードされると思いますが、
現時点ではデプロイパッケージ(zip)に最新版のboto3を含めるようにしてください。
また通信ライブラリにrequestsを使用しているのでこちらもデプロイパッケージに含めます。

デプロイパッケージの作成 (Python)
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-python-how-to-create-deployment-package.html

設定補足

Slack

Slack Botを使用してるため、Lambda関数の設定には通知先のチャンネルIDと
BotユーザのTokenが必要です。
プライベートチャンネルに通知する場合は、Botユーザをinviteしてください。

チャンネルIDはURLに含まれる以下の部分です。

https://<your_team_name>.slack.com/messages/<channel_id>

Token取得については以下の記事が参考になります。
https://qiita.com/ykhirao/items/3b19ee6a1458cfb4ba21

Lambda

環境変数 CHANNEL にチャンネルIDを、TOKENにBotユーザのTokenを設定します。
image.png
実行ロールはメトリクスデータを取得できるようポリシーを設定してください。
(CloudWatchReadOnlyAccessなど)
タイムアウトはデフォルトの3秒ではタイムアウトする可能性があるので10秒に設定します。
image.png

CloudWatch Events

ルールの作成でイベントソースをスケジュールとし、Cron式を入力します。
UTC で設定しますので、日本標準時との時差9時間を考慮する必要があります。
以下の例は平日の17時に起動する場合の設定です。

0 0 ? * MON-FRI *

ターゲットに作成したLambda関数を指定して、ルールを保存します。
image.png

以上です。
参考になれば幸いです。

Why do not you register as a user and use Qiita more conveniently?
  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