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

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

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

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

## 結果イメージ
CloudWatchから指定した条件のグラフを取得してSlackに通知します。
グラフには注釈として当日と前日の金額をプロットします。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/b7214e9c-f2ed-97c6-8ff3-6a4f947db29c.png)
画像のみ拡大。
グラフタイトルに概算合計請求額と、増分を記載しています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/cf8755be-3f14-63e6-44a1-3e3475f7ccf1.png)

## ざっくり構成
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/4f0a4c09-5a41-8f70-8984-644ee12e225e.png)

CloudWatch EventsでLambdaを毎日指定時刻に起動します。
Lambda関数はCloudWatchの[GetMetricStatistics](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/APIReference/API_GetMetricStatistics.html)でBillingから当日と前日の概算合計請求額
を取得します。更に[GetMetricWidgetImage](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/APIReference/API_GetMetricWidgetImage.html)で請求額情報をプロットしたグラフを取得します。
取得したイメージは、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としています。

```python
StartTime=datetime.datetime.now() - datetime.timedelta(days=1.5),
EndTime=datetime.datetime.now(),
```
　
widget_definition でGetMetricWidgetImageの入力パラメータを定義しています。
"annotations" が注釈です。get_metric_statisticsで取得した金額を指定します。
軸の指定（yAxis）で、グラフの表示幅を調整しています。

```python
            "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](https://qiita-image-store.s3.amazonaws.com/0/125105/4d172d02-3a41-5ad9-eebe-c1345bb0b70c.png)
実行ロールはメトリクスデータを取得できるようポリシーを設定してください。
（CloudWatchReadOnlyAccessなど）
タイムアウトはデフォルトの3秒ではタイムアウトする可能性があるので10秒に設定します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/929ec1a8-30b7-10f7-b8da-3d835d474cba.png)

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

```
0 0 ? * MON-FRI *
```

ターゲットに作成したLambda関数を指定して、ルールを保存します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/125105/1f00931c-403d-2d71-1c9d-4389f884ee3a.png)

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