AWS Organizationsの各アカウントの料金急増をSlackに通知する

## 端書
AWSは非常に便利なサービスで、数分で新しいWebサーバやDBサーバを立てることができ、柔軟かつ高速にサービスの環境を用意することができます。
しかし便利であるがゆえに、恐ろしいのがその料金です。

権限さえ持っていれば誰でも超高性能（高額）なサーバーをいくらでも立てることができる為、会社のアカウントの料金が知らないうちに膨れ上がっており、月額請求時になって発覚するなんて事態も十分にあり得る話でしょう。

以上を踏まえ、料金急増等の検知と通知、および監視が必要だ、となりました。

その方法はさまざまあると思いますが、今回は**CloudWatch Events** と**AWS Lambda**を利用しました。

## AWS Organizations
 AWSの公式ページ：https://aws.amazon.com/jp/organizations/

企業ではAWSアカウントをサービス毎チーム毎に分けて運用している、と言う話も珍しくはなく、そんな時にアカウントの一元管理で便利なのがAWS Organizationsです。

請求情報をルートアカウントに集約できる為、支払いを簡素化できるのも魅力の一つでしょう。

今回はAWS Organizationsを利用しており、料金が急増したアカウントを検知するための方法を記載します。

## 方法
### 全体像
下記のClassmethodの記事が大変参考になります。~~というかほぼ丸パクリ~~
https://dev.classmethod.jp/articles/notify-slack-aws-billing/

流れとしては下記の様になります。

- CloudWatch Eventsにより定時でLambdaを起動
- Lambdaで請求のAPIを叩く
- アカウントごとの請求情報を整形し、条件次第でSlackに投下

### 事前準備
上記のClassmethodの記事にあるので割愛。
（自分は偶然にも別件で全て対応済みでしたので、特に何もしてません。）

### Lambda関数の作成
#### コンソールで関数を作成
「一から作成」で、関数名は適当につけます。（今回はAWSBillingMonitorとしました。）

言語を選択できますが、今回は一度Pythonを触ってみたかったこともあり、Pythonを選択。


#### Lmbda関数のコードを書く
※普段プログラミングしてない＋Python初めて触る人間の書いたものなので、丸パクリダメ、絶対。

```python
import boto3
import json
import os
import requests
from datetime import datetime, timedelta, date

SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
INCREMENTAL_COST_THRESHOLD = os.environ['INCREMENTAL_COST_THRESHOLD']


def lambda_handler(event, context) -> None:
    client = boto3.client('ce', region_name='ap-northeast-1')

    # アカウント毎の一昨日分、昨日分の請求額を取得
    account_billings = get_account_daily_billings(client)

    # 一昨日の料金と昨日分の料金を比較して、増分が閾値を超えているアカウントを取り出す
    rapid_increase_cost_account_billings = get_rapid_increase_cost_account_billings(account_billings)

    # Slack用のメッセージを作成して投稿
    if rapid_increase_cost_account_billings:
        (title, detail) = get_message(rapid_increase_cost_account_billings)
        post_slack(title, detail)

def get_account_daily_billings(client) -> tuple:
    yesterday = (date.today() - timedelta(days=1)).isoformat()
    before_yesterday = (date.today() - timedelta(days=3)).isoformat()

    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': before_yesterday,
            'End': yesterday
        },
        Granularity='DAILY',
        Filter={
            'Not': {
                'Dimensions': {
                    'Key': 'SERVICE', 
                    'Values': ['Tax']
                }
            }
        },
        Metrics=[
            'AmortizedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'LINKED_ACCOUNT'
            }
        ]
    )

    billings_before_yesterday_dict = {}
    for item in response['ResultsByTime'][0]['Groups']:
        account = item['Keys'][0]
        before_yesterday_billing = item['Metrics']['AmortizedCost']['Amount']
        billings_before_yesterday_dict[account] = before_yesterday_billing

    billings_yesterday_dict = {}
    for item in response['ResultsByTime'][1]['Groups']:
        account = item['Keys'][0]
        yesterday_billing = item['Metrics']['AmortizedCost']['Amount']
        billings_yesterday_dict[account] = yesterday_billing
    
    billings = (billings_before_yesterday_dict, billings_yesterday_dict)

    return billings


def get_rapid_increase_cost_account_billings(billings: tuple) -> tuple:
    before_yesterday_billings, yesterday_billings = billings
    
    # 増分が閾値を超えていなかったり、データがないアカウントを取り除く
    rapid_increase_cost_dict = {}
    for account in list(yesterday_billings):
        if (account in before_yesterday_billings):
            increase_cost = float(yesterday_billings[account]) - float(before_yesterday_billings[account])
            if increase_cost > float(INCREMENTAL_COST_THRESHOLD):
                rapid_increase_cost_dict[account] = increase_cost
            else:
                continue
            continue

    rapid_increase_cost_account_billings = (yesterday_billings, before_yesterday_billings, rapid_increase_cost_dict)

    return rapid_increase_cost_account_billings


def get_message(rapid_increase_cost_account_billings: tuple) -> (str, str):
    yesterday_billings, before_yesterday_billings, increase_cost_dict = rapid_increase_cost_account_billings
    
    title = f'一昨日から昨日にかけて、料金が {INCREMENTAL_COST_THRESHOLD} USD以上増加したアカウントがあります。'
    details = []
    for account in increase_cost_dict:
        account_name = get_account_name(account)
        yesterday_billing = round(float(yesterday_billings[account]), 2)
        before_yesterday_billing = round(float(before_yesterday_billings[account]), 2)
        increase = round(float(increase_cost_dict[account]), 2)

        details.append(f'　・{account_name} ： {before_yesterday_billing} USD -> {yesterday_billing} USD (↑ {increase} USD )')

    return title, '\n'.join(details)


def post_slack(title: str, detail: str) -> None:
    payload = {
        'attachments': [
            {
                'color': '#36164f',
                'pretext': title,
                'text': detail
            }
        ]
    }
    
    print(json.dumps(payload))

    try:
        response = requests.post(SLACK_WEBHOOK_URL, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print(response.status_code)

def get_account_name(accountId: str) -> str:
    
    account = {
        '111111111111': 'account-root',
        '222222222222': 'account-A',
        '333333333333': 'account-B',
    }
    
    if accountId in account:
        return account[accountId]
    else:
        return str(accountId)
```

 **⇒ 動きませんでした。**
外部モジュールを読み込ませる場合はデプロイパッケージとしてzipで固めてものをアップロードしないといけないらしい。
こちらで無事動きました。

参考）
https://hacknote.jp/archives/48083/

また、WebhookURLと増加料金の閾値は環境変数として持たせています。
![ryoukin2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546332/e54bd1ca-8be3-49ad-60d7-6ab2f3e2877d.jpeg)


### CloudWatch Eventsで定時実行
画像の様に設定します。
時刻はUTCなので、日本時間12時に通知したい場合は3時としなければいけません。

![ryoukin1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546332/a537f807-8d5e-3cb3-9c17-357a961cfde7.jpeg)


### 結果確認
無事届きました。

![ryoukin3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546332/5545fdc4-e9c6-a2a5-d038-84e45990e18b.jpeg)

### 課題
- 今回はとりあえずで5.0USDを閾値としたが、使用状況は当然アカウントごとに違うので、条件はまだまだ見直しが必要。
  - 何日連続増加中とか、割合での閾値とかが良いかも。
- ~~途中で面倒になって~~アカウント情報をソース上にべた書きしてしまっているので、そこもOrganizationsから取ってきたりできそう。
- 増加したアカウントがなくても「アカウントがあります」と言ってSlackに通知している…。（バグですね）
- ~~SAMとかCFnの利用は、なさらないんですか？~~

## 感想
予算を決めてAWS Budgetsを利用するなどの方法もありそうでしたが、pythonを触ったことがなく、またLambdaも全く使い慣れてはいなかったので、今回は本記事のような検知体制を試みました。

また、予算を組んで縛ると現場のスピード感を損なうのでは、という懸念もありました。
現場にはある程度好きにやらせて異常な料金を検知したら事情聴取、という形でしばらくは運用していく予定です。

まだまだブラッシュアップの余地があって弄り甲斐がありそうですし、やはり新しいものに触るのは楽しいですね。