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

はじめに

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を有効化していない場合は、マイ請求ダッシュボードから有効化します。

Cost Explorer

SNS トピックの作成

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

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

SNS トピックの作成

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

SNS トピックの作成

サブスクリプション作成

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

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

サブスクリプションの作成

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

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

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

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

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

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

サブスクリプションのステータス

Lambda関数の作成

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

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

Lambda関数の作成

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

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

Lambda関数のテスト

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

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。

テストイベントの作成

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

テストメール

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

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

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

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

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管理画面のポリシー一覧を表示し、「ポリシーの作成」を押下。

ポリシーの作成

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

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

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

ポリシーの作成

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

ロールの一覧画面

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

ポリシーをアタッチ

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

ポリシーをアタッチ

Lambda関数の実行

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

Lambda関数の実行

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

メール通知

※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"
  ]
}

トリガーの設定

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

トリガーの設定

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

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

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

トリガーの設定

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

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

参考情報・引用