LambdaでAWS利用料のグラフをS3に保存し、Slackに通知する

## はじめに
AWSアカウントの管理者になってから、ほぼ毎日Cost Explorerで利用料を確認し、料金の急増がないかをチェックしています。ただ、

- 手動での確認はやはり手間がかかり、もっと手軽な方法でやりたい
- 利用料の状況をチームメンバーにも共有し、コストの管理意識を芽生えてもらいたい

というニーズがあり、日々の利用料をSlackに通知するためのLambda関数を実装しました。

## 参考記事
まず、アーキテクチャとソースコードはこちらの記事を参考し、一部カスタマイズして作りました。（@hayao_kさん、ありがとうございました！）
[日々のAWS請求額をグラフ付きでSlackに通知する](https://qiita.com/hayao_k/items/5a1e279ae4e142ca525a)

ただ、弊社のSlackワークスペースにおいては、ファイルアップロードのための `files:write` スコープが規則上認められていないため、アーキテクチャを一部変更し、Slackへの直接アップロードではなく、一度S3に保存したうえ、グラフのオブジェクトURLをリンクとしてSlackメッセージに貼り付けるという形にしました。実際のアーキテクチャは以下となります。

![lambda.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779499/2343a0b8-978a-f22a-005a-16a9e69625a0.png)

1. CloudWatch Eventsによって指定の時間にLambda関数を呼び出す
2. CloudWatchから `EstimatedCharges` のメトリクス情報（データポイント）を取得
3. 取得したデータポイントに基づき、Lambda関数で利用料のグラフを生成
4. 生成したグラフをS3バケットに保存　👈　ここは参考記事からの変更点
5. 利用料に関するメッセージを作成してSlackに送る（メッセージにグラフのURLも含める）

## 成果物のイメージ
Slackには以下のようなメッセージが送られます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779499/d14a82ae-e40d-cf6d-ebec-6a4c56a01173.png)

また、メッセージにあるリンクをクリックすると、ブラウザ上でS3に保存されているグラフが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779499/926e4afe-72a6-ca5f-9df1-16d5547a1e8b.png)

## 実装の事前準備

### Slack Incoming Webhooksを用意
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779499/d37f1e64-1f0b-16c7-21b2-3efcd717b232.png)

### S3バケットを用意
任意の端末からS3に保存されているグラフにアクセスしたい場合は、バケットのパブリックアクセスを有効化する必要がありますが、社外からのアクセスを回避したいので、バケットポリシーに弊社事業所のグローバルIPのみ許可します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779499/58de15b8-ed15-b1ab-b3b9-5f97eb90ebe1.png)

### Lambda関数を作成
ランタイムは `Python 3.8`
実行ロールは基本的な権限（CloudWatchにログ出力するための権限）で新規作成したうえ、再度IAMから当該ロールを編集し、下記2つのポリシーを追加します。

- CloudWatchReadOnlyAccess（手順2でCloudWatchからメトリクス情報を取得するため）
- 上記S3バケットへの操作権限（手順4でグラフをバケットに保存するため）

## Lambda関数の中身（ソースコード）
バケットやWebhooksなどの情報は適宜書き換えるように。

```python: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_metrics(start_time, end_time, 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=start_time,
            EndTime=end_time,
            Period=21600,
            Statistics=['Maximum']
        )
    except ClientError as e:
        logger.error("Request failed: %s", e.response['Error']['Message'])
    else:
        return response


def get_image(today, yesterday, diff, metrics, s3ObjKey, client=None):
    if client is None:
        client = get_cloudwatch_client()

    if diff > 0:
        title = ('Current Charges: $' + str(today) +
                 ' / Increased $' + str(diff) + ' from Yesterday')
        min = yesterday * 0.7
        max = today * 1.05
    else:
        title = ('Current Charges: $' + str(today))
        min = today * 0.7
        max = yesterday * 1.05

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

    try:
        response = client.get_metric_widget_image(
            MetricWidget=widget_definition
        )
    except ClientError as e:
        logger.error("Request failed: %s", e.response['Error']['Message'])
    else:
        s3 = boto3.resource('s3')
        bucket = s3.Bucket('xxxxxxxxxx')
        res = bucket.put_object(Body=(
            response["MetricWidgetImage"]), Key=s3ObjKey, ContentType='image/png')
        logger.info("Upload Image succeeded.")


def build_payload(today_charge, diff, s3ObjUrl):
    payload = {
        "attachments": [
            {
                "color": "good",
                "title": "AWS料金（クリックしてグラフを確認）",
                "title_link": s3ObjUrl,
                "fields": [
                    {
                        "title": "対象AWSアカウント",
                        "value": "`xxxxxxxxxxxx`",
                        "short": False
                    },
                    {
                        "title": "本日まで利用した金額",
                        "value": ":heavy_dollar_sign:" + str(today_charge),
                        "short": True
                    },
                    {
                        "title": "昨日よりの増加量",
                        "value": ":heavy_dollar_sign:" + str(diff),
                        "short": True
                    }
                ]
            }
        ]
    }
    return payload


def lambda_handler(event, context):
    start_time = datetime.datetime.now() - datetime.timedelta(hours=30)
    end_time = datetime.datetime.now()
    logger.info('Metric Start Time: ' + str(start_time))
    logger.info('Metric End Time: ' + str(end_time))

    metrics = get_metrics(start_time, end_time)
    sorted_data = sorted(metrics['Datapoints'], key=lambda x: x['Timestamp'])
    logger.info("Sorted Data: %s", sorted_data)

    today_charge = sorted_data[-1]['Maximum']
    yesterday_charge = sorted_data[0]['Maximum']
    diff = round(today_charge - yesterday_charge, 2)

    targetAccount = 'xxxxxxxxxxxx'
    s3ObjKey = targetAccount + '/charge-graph-' + \
        str((end_time + datetime.timedelta(hours=9)).date())
    s3ObjUrl = 'https://xxxxxxxx.s3-ap-northeast-1.amazonaws.com/' + s3ObjKey
    get_image(today_charge, yesterday_charge, diff, metrics, s3ObjKey)

    payload = build_payload(today_charge, diff, s3ObjUrl)
    req = requests.post(
        'https://hooks.slack.com/services/xxxxxxxxxxxxxxxx', data=json.dumps(payload))
    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

```

## 最後に
Lambdaを実装し、データポイントの取得やグラフ（ウイジェット）生成のためのパラメータをチューニングして簡単なテストを済ませ、CloudWatchのイベントルールを設定したら終わりです。これで毎日Slackで料金のチェックや推移グラフを見ることができるようになりました。かなり便利です！

後日に余裕あったら `get_metric_statistics` と `get_metric_widget_image` の仕様をまとめてみます。