LoginSignup
4
7

More than 3 years have passed since last update.

Lambda x Amazon SNSで、AWSの請求額を毎日メールで通知する

Last updated at Posted at 2020-05-24

はじめに

CloudWatchで請求アラートを設定する事はできますが、超心配性な自分としては、月初~前日までの請求額を毎日メールで確認しておきたい。
AWS Lambdaとwebhookを使ってSlackのチャンネルに通知する方法は多く見られましたが、メールで通知する方法は意外と多くなかったので、まとめてみました。

概要

  • 請求額はCost Explorerから取得する。
    ※CloudWatchから取得する方法もありますが、双方に差異があり正しい値はCost Explorerであるという情報があったため、Cost Explorerを使う事にしました。
  • Lambda関数のランタイムはPython3.7とし、Python 向けのAWS SDK(Boto3)を利用する。
  • トリガを設定したLambda関数にて、月初~前日までの合計請求額とサービス毎の請求額を取得し、その内容を整形し、メッセージとしてAmazon SNSのトピックに発行する。
  • メッセージを受け取ったSNSトピックは、紐づけたエンドポイント(メールアドレス)宛にメッセージを送信する。

用語の理解

特にAmazon SNSに登場する用語、そしてそれぞれの関係性がややこしかったので、超ざっくりとまとめます。

  • ARN:AWSリソースを一意に識別する名前。

  • トピック:複数のエンドポイント(ここではメールアドレス)をグループにまとめる機能。

  • エンドポイント:配信先。今回はメールアドレスとなります。

  • サブスクリプション:トピックとエンドポイントを紐づける

より深く理解するために、以下の記事の用語説明の箇所がとても参考になりましたので、事前に熟読しておく事をお勧めします。

Amazon SNSでプッシュ通知を送るための基礎知識 | UNITRUST

設定方法

Cost Explorerの有効化

Cost Explorerを有効化していない場合は、マイ請求ダッシュボードから有効化します。

1.Cost Explorer.png

SNS トピックの作成

Amazon SNSのサービス画面に移動します。
※利用できるリージョンは限られています。(サポートされているリージョンおよび国 - Amazon Simple Notification Service
※今回は、東京リージョン(ap-northeast-1)で設定を進めます。

「トピック」メニューから、「トピックの作成」を押下。
2-1.トピックメニュー.png

「タイプ」は、「スタンダード」を選択。
「名前」と「表示名」を入力し、「トピックの作成」を押下。
※ここで設定した「表示名」が、メールの送信者名となります。
※ちなみに、管理者向けに何か通知するためのトピックとして、今後別の目的での配信にも利用する事を想定し、名前は「sendMailAdmin」、表示名は「管理者通知メール」と汎用的なものしておきました。
2-2.トピックの作成.png

サブスクリプション作成

サブスクリプション(+エンドポイント)を作成します。

「サブスクリプションの作成」を押下。
※画面に表示されているARNは控えておいてください。
3-1.サブスクリプションの作成.png

下記項目を入力し、「サブスクリプションの作成」を押下。
※「トピックARN」は自動入力されるはずですが、されていなければ控えておいたトピックARNを入力してください。

項目名 入力値・選択値
トピックARN 控えておいたトピックのARN
プロトコル Eメール
エンドポイント 受信メールアドレス

3-2.サブスクリプションの作成.png

サブスクリプションの承認

エンドポイントに指定したメールアドレス宛に、「AWS Notification - Subscription Confirmation」という件名で確認メールが送られてくるので、「Confirm subscription」を押下。

4-1.認証メール.png
4-2.認証メール.png

トピックに紐づけたサブスクリプションのステータスが「確認済み」となります。
4-3.認証メール.png

Lambda関数の作成

トピック・サブスクリプション・エンドポイントの作成が完了しました。

トピックのARNに対してメッセージを発行すると、このトピックに紐づいたエンドポイント(メールアドレス)宛にメッセージが配信されるという流れになります。
そのため次に、トピックのARNに対して発行するメッセージを生成するLambda関数を作成します。
まずは、AWS Lambdaのダッシュボードから、「関数の作成」を押下。

5-1.Lambda関数.png

オプションが「一から作成」になっている事を確認し、「基本的な情報」に以下を入力し、「関数の作成」を押下。
※「アクセス権限」の「実行ロールの選択または作成」をクリックし、「AWS ポリシーテンプレートから新しいロールを作成」を選択しておいてください。

項目名 入力値・選択値
関数名 sendCost(好きな名前で)
ランタイム Python 3.7
ロール名 SNSServiceRoleForLambda(好きな名前で)
ポリシーテンプレート Amazon SNS 発行ポリシー

5-2.Lambda関数.png

Lambda関数のテスト

次に、請求情報を取得するコードを書いていく事になりますが、ここまでの設定確認のため、まずはテストメッセージを発行する処理を書いてみます。
関数作成後の下部にある「関数コード」欄に、以下のコードを入力します。
TopicArn には、SNSトピック作成時に控えておいたARNを設定します。

lambda_function.py
import boto3

def lambda_handler(event, context):
    sns = boto3.client('sns')
    subject = 'Lambdaからのテストメール件名です。'
    message = 'Lambdaからのテストメール本文です。'

    response = sns.publish(
        TopicArn = 'arn:aws:sns:*:*:*',
        Subject = subject,
        Message = message
    )

    return response

そして、実際にはトリガーで定期的に実行する事になりますが、手動で送信してみます。

右上の「デプロイ」を押下した後、「テスト」を押下し、「イベント名」に適当な名前を入れ、「作成」を押下。
その他は初期値のままでOK。

5-3.Lambda関数.png

元の画面に戻り、再度右上の「テスト」をクリックすると関数が実行され、指定した受信メールアドレスにメールが届いているはずです。
5-4.Lambda関数.png

届かない場合は、コード入力欄の下部のコンソール(Execution results)にエラーメッセージが表示されていないか、入力したARNに間違いがないか等確認してください。

請求情報通知用のコード作成

いよいよ、Cost Explorerから請求額を取得し、Amazon SNSで通知するコードを書いていきます。
先ほど作成した関数の「関数コード」欄内を、以下のコードに置き換えます。
TopicArn には、前回同様SNSトピック作成時に控えておいたARNを設定します。

※後述しますが、追加設定を行わないとテストしてもエラーとなります!
※コードは、Developers.IOの記事のものをベースとさせていただきました。

lambda_function.py
import boto3
from datetime import datetime, timedelta, date

def lambda_handler(event, context):
    ce = boto3.client('ce')
    sns = boto3.client('sns')

    # 今月の合計請求額を取得
    total_billing = get_total_billing(ce)
    # 今月の合計請求額を取得(サービス毎)
    service_billings = get_service_billings(ce)

    # Amazon SNSトピックに発行するメッセージを生成
    (subject, message) = get_message(total_billing, service_billings)

    response = sns.publish(
        TopicArn = 'arn:aws:sns:*:*:*',
        Subject = subject,
        Message = message
    )

    return response

def get_total_billing(ce):
    (start_date, end_date) = get_total_cost_date_range()

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=[
            'AmortizedCost'
        ]
    )

    return {
        'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
        'end': response['ResultsByTime'][0]['TimePeriod']['End'],
        'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'],
    }

def get_service_billings(ce):
    (start_date, end_date) = get_total_cost_date_range()

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=[
            'AmortizedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'SERVICE'
            }
        ]
    )

    billings = []

    for item in response['ResultsByTime'][0]['Groups']:
        billings.append({
            'service_name': item['Keys'][0],
            'billing': item['Metrics']['AmortizedCost']['Amount']
        })

    return billings


def get_total_cost_date_range():
    start_date = date.today().replace(day=1).isoformat()
    end_date = date.today().isoformat()

    # get_cost_and_usage()のstartとendに同じ日付は指定不可のため、今日が1日なら「先月1日から今月1日(今日)」までの範囲にする
    if start_date == end_date:
        end_of_month = datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=-1)
        begin_of_month = end_of_month.replace(day=1)
        return begin_of_month.date().isoformat(), end_date
    return start_date, end_date


def get_message(total_billing, service_billings):
    start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%Y/%m/%d')

    # Endの日付は結果に含まないため、表示上は前日にしておく
    end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
    end_yesterday = (end_today - timedelta(days=1)).strftime('%Y/%m/%d')

    total = round(float(total_billing['billing']), 2)
    subject = f'{start}{end_yesterday}の請求額:${total:.2f}'

    message = []
    message.append('【内訳】')
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)

        if billing == 0.0:
            # 請求無しの場合は内訳を表示しない
            continue
        message.append(f'・{service_name}: ${billing:.2f}')

    return subject, '\n'.join(message)

これで完成かと思いきや、Lamdaに割り当てたロールにCost Explorerへアクセスする権限がないので、以下のようなエラーとなります。

"errorMessage": "An error occurred (AccessDeniedException) when calling the GetCostAndUsage operation: User: arn:aws:sts::251745928455:assumed-role/SNSServiceRoleForLambda/sendCost is not authorized to perform: ce:GetCostAndUsage on resource: arn:aws:ce:us-east-1:251745928455:/GetCostAndUsage"

そこで、IAM管理画面にて、Cost Explorerへアクセス出来るポリシーをロールにアタッチします。
※関数の作成時に「カスタムロールを作成」を選択し、jsonでポリシーを一気に割り当てる方法もあるようですが、2020/5時点では選択肢にありませんでした。

ポリシーの作成とアタッチ

まず、IAM管理画面のポリシー一覧を表示し、「ポリシーの作成」を押下。

6-1.ポリシー作成.png

以下の項目を入力し、「ポリシーの確認」を押下。

項目名 入力値・選択値
サービス Cost Explorer Service
アクション 「GetCostAndUsage」と検索しチェックを入れる

6-2ポリシー作成.png

ポリシーの確認画面で、「名前」を入力し、「ポリシーの作成」を押下。
※ここでは名前を「AmazonCostExplorerGetCostAccess」としました。

6-3.ポリシー作成.png

ロールの一覧画面に移動し、Lambdaに割り当てたロールを選択。

6-4.ポリシー作成.png

「ポリシーをアタッチします」を押下。

6-5.ポリシー作成.png

「ポリシーのフィルタ」で、ポリシー作成の際に設定した名前を入力して検索(この記事の例では「AmazonCostExplorerGetCostAccess」)し、ヒットしたものにチェックを入れ、「ポリシーのアタッチ」を押下。

6-6.ポリシー作成.png

Lambda関数の実行

これで関数が正常に実行できる状態になったので、作成した関数の設定画面右上の「テスト」を押下します。

7-1.Lambda関数.png

全て正しく設定できていれば、以下のようなメールが届くはずです。

7-2.メール.png

※Cost Explorerを有効化したばかりの時は、以下のように「まだデータが無いのでしばらく待ってね」というエラーが発生しますので、数日待ってから再度試してみて下さい。

{
  "errorMessage": "An error occurred (DataUnavailableException) when calling the GetCostAndUsage operation: Data is not available. Please try to adjust the time period. If just enabled Cost Explorer, data might not be ingested yet",
  "errorType": "DataUnavailableException",
  "stackTrace": [
    "  File \"/var/task/lambda_function.py\", line 9, in lambda_handler\n    total_billing = get_total_billing(ce)\n",
    "  File \"/var/task/lambda_function.py\", line 34, in get_total_billing\n    'AmortizedCost'\n",
    "  File \"/var/runtime/botocore/client.py\", line 357, in _api_call\n    return self._make_api_call(operation_name, kwargs)\n",
    "  File \"/var/runtime/botocore/client.py\", line 676, in _make_api_call\n    raise error_class(parsed_response, operation_name)\n"
  ]
}

トリガーの設定

最後に、毎日決まった時間にメール通知するためのトリガーを設定します。
関数の設定画面の左側「トリガーを追加」を押下。
8-1.トリガーの設定.png

以下のように設定し、「追加」を押下。

項目名 入力値・選択値
トリガーを選択 CloudWatch Events/EventBridge
ルール 新規ルールの作成
ルール名 sendDailyCost(適当に)
ルールタイプ スケジュール式
スケジュール式 cron(0 14 ? * * *)
トリガーの有効化 チェックする

今回は23時に設定しました。
注意点として、時間はUTCで設定するので、JST(日本標準時)から9時間分減算した時刻を設定します。
8-2.トリガーの設定.png

あとは、毎日指定した時間にメールが届く事を確認してください。

これで、安心して毎日眠れますね!

参考情報・引用

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7