AWSの料金を Slack に通知する

みなさんこんにちは!
Technology & Design Department インフラチーム所属の髙橋です。
今回は、AWSの料金を Slack に通知するシステムを2つ実装したのでそちらについて書いていきます。

1. S3 使用料金アラート通知

やりたいこと

S3の月の使用料金が、月の予算の60%を超えた時に Slack に通知を行う。

使用するAWSサービス

  • AWS Budgets
  • AWS SNS
  • AWS Chatbot

サービスの関係図

f:id:Takahashi_Blt:20211004144429p:plain

手順

①SNS トピック作成

AWS SNSコンソールを開き "トピックの作成" を選択。 image.png (30.7 kB)

"名前" に任意のものを入力し、"トピックの作成" を選択。 スクリーンショット 2020-08-04 18.18.35.png (169.5 kB)

"編集" を選択し、アクセスポリシーへ追記を行います。 スクリーンショット 2020-08-04 18.24.21.png (49.5 kB)

ポリシーのテキストフィールドで、"Statement": [ の後に以下のテキストを追加します。
"your topic ARN " の箇所は、作成したトピックのARNに書き換えてください。

{
  "Sid": "E.g., AWSBudgetsSNSPublishingPermissions",
  "Effect": "Allow",
  "Principal": {
    "Service": "budgets.amazonaws.com"
  },
  "Action": "SNS:Publish",
  "Resource": "your topic ARN"
},

そして、[トピックの作成] をクリックすると、SNSトピックが作成されます。

SNSでの作業は以上です!

②AWS Chatbot の設定

次に、AWS Chatbot のコンソールを開きます。

"新しいクライアントを作成" を選択します。 スクリーンショット 2020-08-04 18.49.25.png (19.8 kB)

"新しいクライアントを設定" と出てくるので "Slack" を選択します。 スクリーンショット 2020-08-04 18.50.19.png (32.5 kB)

ワークスペースへサインインしたのち、下記のような画面となるので "許可する" を選択します。 スクリーンショット 2020-08-04 18.53.01.png (76.8 kB)

追加されたワークスペースから "新しいチャネルを設定" をクリックします。 スクリーンショット 2020-08-04 19.13.41.png (57.0 kB)

Slack チャネルの設定をしていきます。

"設定名" に任意の名前を記入をします。
"Slackチャネル" から通知先の Slack チャネルを選択します。 f:id:Takahashi_Blt:20210924102553p:plain

"アクセス許可" で、「テンプレートを使用してIAMロールを作成する」を選び、任意のロール名を記入します。
"通知 -オプション" で先ほど作成したSNSのリージョンとトピックを選択し、最後に"設定"をクリックします。 f:id:Takahashi_Blt:20210924103515p:plain

AWS Chatbot での作業は以上です!

③コストの予算の作成

次に、Billing and Cost Management コンソールを開き、ナビゲーションペインの [Budgets] を選択します。

ページの上部で、[予算を作成] を選択します。 スクリーンショット 2020-08-04 18.29.52.png (59.2 kB)

[予算タイプの選択] で、[コスト予算] を選択し、"予算の設定"をクリックします。

[予算の設定] で予算の詳細を決定していきます。
今回は[間隔] は "月別" 、 [予算額] は "固定" で任意の予算を設定しました。
記入ができたら、"アラートの設定" をクリックします。 スクリーンショット 2020-08-04 18.35.23.png (100.8 kB)

[アラートの設定] で "実際のコスト" を選択し、アラートのしきい値を "60" に設定します。
"Amazon Simple Notification Service (SNS) トピックを通じて通知" にチェックを入れると、"SNSトピックのARN" を入力する箇所が出てくるのでそこに先ほど作成したトピックのARNを入れます。
全て終わったら "予算の確認" をクリックします。 スクリーンショット 2020-08-04 19.21.09.png (153.3 kB)

最後に、確認画面を表示されるので "作成" をクリックしてください。

お疲れ様でした、以上で全作業が終了です!
今後はS3の使用料金が設定予算の60%の超えたとき、下記の形で Slack に通知がきます。 スクリーンショット 2020-08-04 19.24.27.png (29.8 kB)

参考

https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/sns-alert-chime.html

2. Athena 実行による発生金額を通知

やりたいこと

Athena でクエリを実行した際に、実行日時、スキャン量、料金、実行SQLを Slack に通知する。

利用するAWSサービス

  • Amazon Athena
  • Amazon CloudWatch
  • AWS Lambda

サービスの関係図

f:id:Takahashi_Blt:20211004144747p:plain

手順

CloudWatch Events で Athena クエリのモニタリングができるので、それを使って Athena 実行時に Lambda の発火を行います。

①Lambdaの設定

では、Lambda の設定から始めていきます。
CloudWatch Events から Athena のクエリIDを取得し、それを元に必要な情報を取得し Slack に通知する関数を作成します。

Lambda のダッシュボードから "関数の作成" をクリックします。
オプションで "一から作成" を選択し、関数名を記入、ランタイムで任意の言語を選択してください。
今回は Python を選択しました。 その後、"関数の作成" をクリックします。
スクリーンショット 2020-08-05 10.50.16.png (163.8 kB)

作成すると、下記のように関数コードを記述できるようになります。 スクリーンショット 2020-08-05 10.53.22.png (72.4 kB)

あとは、

1. CloudWatch Events からクエリIDを取得
2. クエリIDを元に GetQueryExecution APIを叩いて DataScannedInBytes を取得
3. 取得したスキャン量を元に金額を算出
4. 算出した金額を Slack に通知 (直接 webhook で通知させます)

を実行できるプログラムを作成します。

例として、Python でのコードを記載します。

import boto3
import urllib.request
import json
import datetime
from decimal import Decimal

athena_client = boto3.client('athena')
sns_client = boto3.client('sns')
slack_url = '****************'
MAX_TEXT_LENGTH = 1400 

def lambda_handler(event, context):
    response = athena_client.get_query_execution(
        QueryExecutionId=event['detail']['queryExecutionId']
    )

    submission_date_time = response['QueryExecution']['Status']['SubmissionDateTime']
    jst = submission_date_time.strftime("%Y/%m/%d %H:%M:%S")

    scanned_bytes = response['QueryExecution']['Statistics']['DataScannedInBytes']
    print(scanned_bytes)
    display_scanned_bytes = scanned_bytes / 1024 / 1024
    cost = 0
    if scanned_bytes < 10 * 1024 * 1024:
        scanned_bytes = 10 * 1024 * 1024
    print(scanned_bytes)
    cost = round(scanned_bytes / 1024 / 1024 / 1024 / 1024 * 5, 10)

    sql = response['QueryExecution']['Query']
    print(sql)

    return post_slack(jst, display_scanned_bytes, cost, sql)

def post_slack(jst, display_scanned_bytes, cost, sql):
    sql_len = len(sql)
    if sql_len > MAX_TEXT_LENGTH:
        sql = sql[0:int(MAX_TEXT_LENGTH/2)] + '\n\n...省略...\n\n' + sql[sql_len-int(MAX_TEXT_LENGTH/2):sql_len]
    send_data = {
        "text": "クエリ実行結果",
        "attachments": [
                {
                    "color": "#00ffff",
                    "blocks": [
                        {
                            "type": "section",
                            "fields": [
                                    {
                                        "type": "mrkdwn",
                                        "text": f'実行日時 \n `{jst}`'
                                    },
                                {
                                        "type": "mrkdwn",
                                        "text": f'スキャン量 \n`{"{0:.10f}".format(Decimal(display_scanned_bytes))}MB`'
                                    }
                            ]
                        },
                        {
                            "type": "section",
                            "fields": [
                                    {
                                        "type": "mrkdwn",
                                        "text": f'料金 \n`${"{:.10f}".format(cost)}`'
                                    },
                                {
                                        "type": "mrkdwn",
                                        "text": f"SQL \n ```{sql}```"
                                    }
                            ]
                        }
                    ]
                }
        ]
    }
    print(send_data)
    send_text = "payload=" + json.dumps(send_data)
    print(send_text)
    request = urllib.request.Request(
        slack_url,
        data=send_text.encode("utf-8"),
        method="POST"
    )
    print(request)
    try:
        with urllib.request.urlopen(request) as response:
            if response.status == 200:
                print (response.reason)
                return True
            else:
                print (response.reason)
                return False
    except Exception as e:
        print (e.message)
        print ("slackへの送信失敗")
        return False

このコードのこだわりポイントとして、MAX_TEXT_LENGTHに指定している1400字を超えたクエリであった場合、下記の箇所でクエリの中間を省略するようにしています。
これは約1500字以上で Slack 投稿しようとした場合、投稿ができない不具合が発生してしまうのでそれを防ぐ役割をしています。
またクエリの中間を省略した意図しては、先頭もしくは後方を省略してしまうとSELECTFROMの対象が分からず確認のしやすさが低下してしまうので、それを避けるため中間を省略しています。

sql_len = len(sql)
    if sql_len > MAX_TEXT_LENGTH:
        sql = sql[0:int(MAX_TEXT_LENGTH/2)] + '\n\n...省略...\n\n' + sql[sql_len-int(MAX_TEXT_LENGTH/2):sql_len]

では、コード記述後 "保存" をクリックしてください。
Lambda の作業は以上です。

②CloudWatch Events の設定

次に、CloudWatch Events の設定をしていきます。
CloudWatchのメニューから イベント > ルール を開き、"ルールの作成" を選択します。 スクリーンショット 2020-08-05 10.31.12.png (110.2 kB)

ルールの作成画面となるので、イベントパターンのプレビューから "編集" をクリックし、下記の内容を記載します。
ターゲットに先ほど作成したLambda関数を選択し、"設定の詳細" をクリックします。
遷移先で、作成するルールの名前を入力し、ルールの作成をクリックします。

{
    "source":[
        "aws.athena"
    ],
    "detail-type":[
        "Athena Query State Change"
    ],
    "detail":{
        "currentState":[
            "SUCCEEDED"
        ]
    }
}

もう一度 Lambda に戻り、作成した関数のコンソール上部にあるデザイナーのボードを開くと CloudWatch Events がトリガーに追加されることがわかります。
スクリーンショット 2020-08-05 11.17.44.png (71.9 kB)

これで、CloudWatch Events の設定も完了です!
これにより Athena を実行するごとに、下記の形で Slack に通知を飛ばすことができます! スクリーンショット 2020-08-05 11.19.49.png (28.6 kB)

参考

CloudWatch イベントを使用した Athena クエリのモニタリング
Amazon Athena Documentation

最後に

このようにAWSでは比較的簡単に、コンソールからぽちぽちするだけで実装できる便利機能がたくさんあります。
今回の予算のアラート通知に関しても、より目につきやすい Slack に通知を飛ばせることによってより一層予算への意識が高まり、予算オーバーを防ぐことが期待できます!
今後さらに知見を広げると共に、使えそうなサービスを導入し社内のシステムの最適化に役立てていきたいです!