CloudWatch Events から Lambda (Python スクリプト) 経由で S3 から請求情報を取得して、SNS でメールを送ってみる

# ０．はじめに

コスト配分タグ毎の AWS の毎月のコストレポートが欲しくて、試してみました。

大枠の流れは、以下。

![名称未設定.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/019e8297-05a4-cdda-ecc9-9f41095f9643.jpeg)

# １．請求情報のコスト配分タグを付与して、S3 へ出力する。

基本的に、以下のページの手順に従って設定します。

* [AWS Billingにおけるコスト配分レポートの設定 - 匠クラウドBlog AWSに関する技術・トレンドを発信します](https://takumicloud.jp/blog/2015/11/20/aws-billing-configurecostallocreport/)

1. S3 を作成します。設定は、以下。
    * バケット名  : ※任意
    * バケットポリシー : ※以下参考。
    * <pre><code>{
    "Version": "2008-10-17",
    "Id": "Policy0000000000000",
    "Statement": [
        {
            "Sid": "Stmt0000000000000",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::0000000000000:root"
            },
            "Action": [
                "s3:GetBucketAcl",
                "s3:GetBucketPolicy"
            ],
            "Resource": "arn:aws:s3:::\*\*\*\*-billing"
        },
        {
            "Sid": "Stmt0000000000000",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::0000000000000:root"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::\*\*\*\*-billing/\*"
        }
    ]
}</code></pre>
    * ![FireShot Capture 097 - S3 Management Console - s3.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/eb0f98b6-ee45-1019-7e11-ddc0f181fa63.png)
    * ※参考
        * [AWS のコストと使用状況レポート の Amazon S3 バケットの設定 \- AWS 請求情報とコスト管理](https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/billing-reports-gettingstarted-s3.html)
<br><br>
1. 作成した S3 へ、コスト配分タグを付与した請求データを出力します。設定は、以下。
    * コスト配分タグ
        * ユーザー定義のコスト配分タグ : ※任意のタグをコスト配分タグとして、有効化。
            * ※ここでは、Biz をコスト配分タグとして設定しました。
        * ※参考
            * [S3 バケットタグでのコスト配分タグの使用 \- Amazon Simple Storage Service](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/CostAllocTagging.html)
    * ![FireShot Capture 096 - Billing Management Console - console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/2d18cd47-0b0a-eb7d-c642-b0c793239a58.png)
    * Billing の設定
        * レガシー化した請求明細レポート機能を使って、AWS の料金に関する継続的なレポートを受け取る。 : ☑︎
        * S3 バケットに保存 : 作成した S3 バケット
        * リソースとタグを含む詳細な請求レポート : ☑︎
    * ![FireShot Capture 095 - Billing Management Console - console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/dbc5e183-0d19-6807-4a5a-a700f0602555.png)
<br><br>
1. 作成した S3 へ、コスト配分タグを付与した請求データが出力されているか確認します。
    * ![FireShot Capture 098 - S3 Management Console - s3.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/a7cb9bea-c85d-b151-dbe6-c181d42438c0.png)
    * ※参考
        * [請求明細レポート \- AWS 請求情報とコスト管理](https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/DetailedBillingReport.html)

# ２．SNS のトピックを作成し、所定のメールアドレスを登録する。

基本的に、以下のページの手順に従って設定します。

* [\[基本操作\]Amazon SNSでメールを送信する ｜ DevelopersIO](https://dev.classmethod.jp/cloud/aws/amazon-sns-2017/)

1. SNS のトピックを作成します。設定は、以下。
    * トピック名  : ※任意
    * アクセスポリシー : ※以下参考。
    * <pre><code>{
  "Version": "2008-10-17",
  "Id": "__default_policy_ID",
  "Statement": [
    {
      "Sid": "__default_statement_ID",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": [
        "SNS:GetTopicAttributes",
        "SNS:SetTopicAttributes",
        "SNS:AddPermission",
        "SNS:RemovePermission",
        "SNS:DeleteTopic",
        "SNS:Subscribe",
        "SNS:ListSubscriptionsByTopic",
        "SNS:Publish",
        "SNS:Receive"
      ],
      "Resource": "arn:aws:sns:ap-northeast-1:XXXXXXXXXXXX:SysOps-Lambda-Mnt",
      "Condition": {
        "StringEquals": {
          "AWS:SourceOwner": "XXXXXXXXXXXX"
        }
      }
    }
  ]
}</code></pre>
    * ![FireShot Capture 110 - Simple Notification Service - ap-northeast-1.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/0cedc12e-385a-d238-4eb7-49bdd6812e02.png)
<br><br>
1. 作成した SNS のトピックへ、所定のメールアドレスをサブスクリプションとして登録する。
    * ![FireShot Capture 111 - Simple Notification Service - ap-northeast-1.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/30e17772-447b-dba7-c6b0-160aeb85aac5.png)
<br><br>
1. 登録後、作成した SNS のトピックへ、パブリッシュして SNS  トピックが正常に設定されているか、確認する。

# ３．Lambda ファンクションを作成する。

1. IAM ロールを作成します。設定は、以下。
    * ロール名  : ※任意
    * ポリシー名  : ※任意
    * ポリシー : ※以下参考。
    * <pre><code>{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "sns:Publish",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:sns:*:XXXXXXXXXXXX:SysOps-Lambda-DLQ",
                "arn:aws:sns:ap-northeast-1:XXXXXXXXXXXX:SysOps-Lambda-Mnt",
                "arn:aws:s3:::gs-sysops-billing/\*",
                "arn:aws:s3:::gs-sysops-billing"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup",
                "logs:PutLogEvents"
            ],
            "Resource": "\*"
        }
    ]
}</code></pre>
    * ![FireShot Capture 107 - IAM Management Console - console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/13e4f2fe-1cec-1b30-a964-aa069e062d05.png)
<br><br>
1. Lambda ファンクションを作成します。設定は、以下。
    * ファンクション名  : ※任意
    * 実行ロール  : ※作成した IAM ロール
    * コード : ※以下参考。

```python
#!/usr/bin/env python
# -*- coding: utf-8-unix; -*-
"""AWS Lambda Function - Maintenance EC2 Images
"""
from __future__ import print_function

import logging
import boto3
import os
import csv
import zipfile
import locale

# 「python - aws lambda Unable to import module 'lambda_function': No module named 'requests' - Stack Overflow」
# https://stackoverflow.com/questions/48912253/aws-lambda-unable-to-import-module-lambda-function-no-module-named-requests
from botocore.vendored import requests

# 「Pythonで翌日や翌月みたいな日付の計算をする - Qiita」
# https://qiita.com/dkugi/items/8c32cc481b365c277ec2
from dateutil.relativedelta import relativedelta

# 「Python 3 で少しだけ便利になった datetime の新機能 - Qiita」
# <https://qiita.com/methane/items/d7ac3c9af5a2c659bc51>
from datetime import datetime, timezone, timedelta
TimeZone = timezone(timedelta(hours=+9), 'JST')

# 「Lambdaの本番業務利用を考える① - ログ出力とエラーハンドリング | ナレコムAWSレシピ」
# <https://recipe.kc-cloud.jp/archives/9968>
logger = logging.getLogger()
logLevelTable={'DEBUG':logging.DEBUG,'INFO':logging.INFO,'WARNING':logging.WARNING,'ERROR':logging.ERROR,'CRITICAL':logging.CRITICAL}
if os.environ.get('logging_level') in logLevelTable :
    logger.setLevel(logLevelTable[os.environ['logging_level']])
else:
    logger.setLevel(logging.WARNING)

#
StartDateTime = datetime.now(TimeZone)

# Csv
BillingFieldNames = (
    "InvoiceID",
    "PayerAccountId",
    "LinkedAccountId",
    "RecordType",
    "RecordId",
    "ProductName",
    "RateId",
    "SubscriptionId",
    "PricingPlanId",
    "UsageType",
    "Operation",
    "AvailabilityZone",
    "ReservedInstance",
    "ItemDescription",
    "UsageStartDate",
    "UsageEndDate",
    "UsageQuantity",
    "Rate",
    "Cost",
    "ResourceId",
    "user:Biz")

# Aws
BucketName = "\*\*\*\*-billing"
ZipRearFormat = "-aws-billing-detailed-line-items-with-resources-and-tags-{0}-{1}.csv.zip"
CsvRearFormat = "-aws-billing-detailed-line-items-with-resources-and-tags-{0}-{1}.csv"

# Mail
MailSubjectTemplate = "[{0}.{1}] AWS Monthly Cost Report"

MailMessageTemplate00 = "\
\n\
★{0}\n\
\n\
　■処理時間 : {1:%Y/%m/%d %H:%M:%S} ～ {2:%Y/%m/%d %H:%M:%S}\n\
\n\
　■総額\n\
        ${3:>8}    [約 ¥{4:>8}]\n\
\n\
　■内訳\
"

MailMessageTemplate01 = "{0}\n\
　　○{1}\n\
        ${2:>8}    [約 ¥{3:>8}]\
"

MailMessageTemplate02 = "{0}\n\
\n\
----\n\
"

MailMessageTemplate03 = "{0}\n\
　■{1:<10} : ${2:>8}    [約 ¥{3:>8}]\
"

MailMessageTemplate04 = "{0}\n\
\n\
----\n\
\n\
※ $1 = ¥{1:>6}\n\
※\n\
\n\
"

# ------------------------------------------------------------------------------
# Billing Data Get
# ------------------------------------------------------------------------------
def BillingDataGet(prmYear, prmMonth, prmBillingList):
    logging.info("prmYear:[%s]", prmYear)
    logging.info("prmMonth:[%s]", prmMonth)
    result = 0
    try:
        s3 = boto3.resource('s3')
        bucket = s3.Bucket(BucketName)
        # Object Key
        key = None
        for object in bucket.objects.all():
            if ZipRearFormat.format(prmYear, prmMonth) != object.key[-72:]:
                continue
            key = object.key
            break
        logging.info("key:[%s]", key)
        if key is None:
            raise Exception("No such key was found. key={}".format(key))
        tmpZipPath = "/tmp/_tmp.zip"
        tmpCsvPath = "/tmp/_tmp.csv"
        # Download
        logging.info("tmpZipPath:[%s]", tmpZipPath)
        s3.Object(BucketName, key).download_file(tmpZipPath)
        # UnZip
        logging.info("UnZip")
        zf = zipfile.ZipFile(tmpZipPath, 'r')
        for f in zf.namelist():
            if CsvRearFormat.format(prmYear, prmMonth) != f[-68:]:
                continue
            uzf = open(tmpCsvPath, 'wb')
            uzf.write(zf.read(f))
            uzf.close()
            break
        zf.close()
        # Csv
        with open(tmpCsvPath) as csvfile:
            for row in csv.DictReader(csvfile, BillingFieldNames):
                if row["LinkedAccountId"] == "LinkedAccountId":
                    continue
                prmBillingList.append(row)
    except Exception as e:
        logger.exception('Error dosomething: %s', e)
        result = 1
        raise
    else:
        pass
    finally:
        pass
    return result
    
# 「[小ネタ]為替レートを簡単に取得する - Qiita」
# <https://qiita.com/chromabox/items/a1323225bae146c80bec>
def RateGet():
    result = -1
    try:
        Url = "https://www.gaitameonline.com/rateaj/getrate"
        r = requests.get(Url)
        r_quotes = r.json()["quotes"]
        for i in range(len(r_quotes)):
            if r_quotes[i].get("currencyPairCode") == "USDJPY":
                result = float(r_quotes[i].get("open"))
                break
    except Exception as e:
        logger.exception('Error dosomething: %s', e)
        result = -1
        raise
    else:
        pass
    finally:
        pass
    return result

# 「【Python】Lambdaからメールを送信 | ハックノート」
# https://hacknote.jp/archives/35679/
def MailSend(prmSubject, prmMessage):
    result = -1
    try:
        sns = boto3.client('sns')
        response = sns.publish(
            TopicArn = 'arn:aws:sns:ap-northeast-1:454930157265:SysOps-Lambda-Mnt',
            Message = prmMessage,
            Subject = prmSubject
        )
    except Exception as e:
        logger.exception('Error dosomething: %s', e)
        result = -1
        raise
    else:
        pass
    finally:
        pass
    return result

def lambda_handler(event, context):
    try:
        global StartDateTime
        StartDateTime = datetime.now(TimeZone)
        logger.info("StartDateTime:[%s]", StartDateTime)
        #
        logger.info("event:[%s]", event)
        logger.info("context:[%s]", context)
        #
        try:
            yyyymm = event.get("YYYYMM", "")
            tdatetime = datetime.strptime(
                yyyymm[0:4] + "-" + yyyymm[4:6] + "-01 01:00:00", '%Y-%m-%d %H:%M:%S')
        except ValueError:
            tdatetime = StartDateTime - relativedelta(months=1)
        logger.info("tdatetime:[%s]", tdatetime)
        yyyy = tdatetime.year
        mm = "{0:0>2}".format(tdatetime.month)
        logger.info("yyyy:[%s] mm:[%s]", yyyy, mm)
        
        # Billing Data Get
        tmpBliingList = []
        BillingDataGet(yyyy, mm, tmpBliingList)

        # Rate Get
        rate = RateGet()
        logging.info("Rate:[%f]", rate)
        
        # ----------------------------------------------------------------------
        #  Summary
        # ----------------------------------------------------------------------

        # Summary ProductName
        dicSummary = {}
        dicSummarySub = {}
        for i in range(len(tmpBliingList)):
            if "Sign up charge for subscription:" in tmpBliingList[i]["ItemDescription"]:
                logging.info("Sign up charge for subscription - tmpBliingList:[%s]", tmpBliingList[i])
                dicSummarySub[tmpBliingList[i]["InvoiceID"]] = {
                    "InvoiceID": tmpBliingList[i]["InvoiceID"],
                    "ProductName": tmpBliingList[i]["ProductName"],
                    "UsageType": tmpBliingList[i]["UsageType"],
                    "UsageQuantity": tmpBliingList[i]["UsageQuantity"],
                    tmpBliingList[i]["ItemDescription"] : {
                        "RecordType": tmpBliingList[i]["RecordType"],
                        "Cost": tmpBliingList[i]["Cost"],
                    },
                }
                continue
            if "Tax of type CT" in tmpBliingList[i]["ItemDescription"]:
                logging.info("Tax of type CT - tmpBliingList:[%s]", tmpBliingList[i])
                if tmpBliingList[i]["InvoiceID"] in dicSummarySub:
                    dicSummarySub[tmpBliingList[i]["InvoiceID"]][tmpBliingList[i]["ItemDescription"]] = {
                        "RecordType": tmpBliingList[i]["RecordType"],
                        "Cost": tmpBliingList[i]["Cost"],
                    }
                    continue
            if "Total amount for invoice" in tmpBliingList[i]["ItemDescription"]:
                if tmpBliingList[i]["InvoiceID"] in dicSummarySub:
                    dicSummarySub[tmpBliingList[i]["InvoiceID"]][tmpBliingList[i]["ItemDescription"]] = {
                        "RecordType": tmpBliingList[i]["RecordType"],
                        "Cost": tmpBliingList[i]["Cost"],
                    }
                    continue
            #
            tmpDict = dicSummary
            tmpKey = tmpBliingList[i]["RecordType"]
            if not tmpKey in tmpDict:
                tmpDict[tmpKey] = {}
            #
            tmpDict = tmpDict[tmpKey]
            tmpKey = tmpBliingList[i]["ProductName"]
            if not tmpKey:
                tmpKey = tmpBliingList[i]["ItemDescription"]
            elif tmpKey == "Amazon Elastic Compute Cloud":
                if "EBS" in tmpBliingList[i]["UsageType"]:
                    tmpKey = tmpKey + " - EBS"
            if not tmpKey in tmpDict:
                tmpDict[tmpKey] = {}
                tmpDict[tmpKey]["SumCost"] = 0.0
            #
            tmpDict = tmpDict[tmpKey]
            tmpKey = tmpBliingList[i]["user:Biz"]
            if not tmpKey in tmpDict:
                tmpDict[tmpKey] = {}
            #
            tmpCost = float(tmpBliingList[i]["Cost"]) if tmpBliingList[i]["Cost"] else 0.0
            if not "Cost" in tmpDict[tmpKey]:
                #tmpDict[tmpKey]["Count"] = 1
                tmpDict[tmpKey]["Cost"] = tmpCost
            else:
                #tmpDict[tmpKey]["Count"] += 1
                tmpDict[tmpKey]["Cost"] += tmpCost
            tmpDict["SumCost"] += tmpCost
        
        # Summary Biz
        tmpSumCostAllToRedistribute = 0.0
        tmpSumCostAllNotToRedistribute = 0.0
        dicSummaryBiz = {}
        for k1 in sorted(dicSummary["LineItem"]):
            for k2 in sorted(dicSummary["LineItem"][k1]):
                if not isinstance(dicSummary["LineItem"][k1][k2], dict):
                    continue
                if not k2 in dicSummaryBiz:
                    dicSummaryBiz[k2] = {}
                    dicSummaryBiz[k2]["SumCost"] = 0.0
                    dicSummaryBiz[k2]["SumCostRedistributed"] = 0.0
                if not k1 in dicSummaryBiz[k2]:
                    dicSummaryBiz[k2][k1] = {}

                dicSummaryBiz[k2][k1]["Cost"] = dicSummary["LineItem"][k1][k2]["Cost"]
                dicSummaryBiz[k2]["SumCost"] += dicSummary["LineItem"][k1][k2]["Cost"]

                if not k2:
                    tmpSumCostAllToRedistribute += dicSummary["LineItem"][k1][k2]["Cost"]
                    continue
                if k2 == "Share":
                    tmpSumCostAllToRedistribute += dicSummary["LineItem"][k1][k2]["Cost"]
                    continue
                tmpSumCostAllNotToRedistribute += dicSummary["LineItem"][k1][k2]["Cost"]

        for k1 in sorted(dicSummaryBiz):
            if not k1:
                continue
            if k1 == "Share":
                continue
            dicSummaryBiz[k1]["SumCostRedistributed"] = dicSummaryBiz[k1]["SumCost"] + (
                tmpSumCostAllToRedistribute * dicSummaryBiz[k1]["SumCost"]) / tmpSumCostAllNotToRedistribute
        logging.info("dicSummaryBiz:[%s]", dicSummaryBiz)
        
        # ----------------------------------
        # Mail Create Message
        # ----------------------------------
        locale.setlocale(locale.LC_NUMERIC, 'ja_JP')

        # Subject
        tmpSubject = MailSubjectTemplate.format(yyyy, mm)

        # Message
        tmpCost = list(dicSummary["InvoiceTotal"].values())[0][""]["Cost"]
        tmpMessage = MailMessageTemplate00.format(tmpSubject, StartDateTime, datetime.now(TimeZone)
            , locale.format('%.2f', tmpCost, True), locale.format('%.0f', tmpCost*rate, True))

        for k1 in sorted(dicSummary["LineItem"]):
            tmpCost = dicSummary["LineItem"][k1]["SumCost"]
            if tmpCost == 0:
                continue
            tmpMessage = MailMessageTemplate01.format(tmpMessage, k1
                , locale.format('%.2f', tmpCost, True), locale.format('%.0f', tmpCost*rate, True))

        tmpMessage = MailMessageTemplate02.format(tmpMessage)

        for k1 in sorted(dicSummaryBiz):
            if not k1:
                continue
            if k1 == "Share":
                continue
            tmpCost = dicSummaryBiz[k1]["SumCostRedistributed"]
            tmpMessage = MailMessageTemplate03.format(tmpMessage, k1
                , locale.format('%.2f', tmpCost, True), locale.format('%.0f', tmpCost*rate, True))

        tmpMessage = MailMessageTemplate04.format(tmpMessage, locale.format('%.2f', rate, True))
        
        # Mail Send
        logging.info("tmpSubject:[%s]", tmpSubject)
        logging.info("tmpMessage:[%s]", tmpMessage)
        MailSend(tmpSubject, tmpMessage)

    except Exception as e:
        logger.exception('Error dosomething: %s', e)
    else:
        pass
    finally:
        pass
    #
    return "normal end"
```

* ![FireShot Capture 103 - Lambda Management Console - ap-northeast-1.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/d84678d1-dd8b-d762-cd53-59c3634bbe2b.png)

# ４．CloudWatch Events のルールを作成する。

1. CloudWatch Events のルールを作成します。設定は、以下。
    * イベントソース  : ※今回は、毎月のコストレポートなので以下。
        * スケジュール : ☑︎
        * Cron 式 : 0 21 3 \* ? \*
    * ターゲット
        * Lambda 関数
             * ※作成した Lambda ファンクション
             * バージョンのエイリアス
                 * ※任意
             * 入力の設定
                 * 定数 (JSON テキスト) : {"YYYYMM", ""}
    * ![FireShot Capture 100 - CloudWatch Management Console - ap-northeast-1.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/d3b836fa-fea2-f2ec-61e3-cf620a9498ec.png)
    * ルールの定義
        * 名前 : ※任意
        * 説明 : ※任意
        * 状態 : ☑︎ 有効「」
    * ![FireShot Capture 101 - CloudWatch Management Console - ap-northeast-1.console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/0075b445-6eb4-dc7d-1b19-c59754accd90.png)

# ９９．ハマりポイント

* うーん…。随分前に作成したので、覚えてない…。

# ＸＸ．まとめ

今回、とりあえず残しておこうかなぁ、と思って記事書いたんですが、

![FireShot Capture 113 - Billing Management Console - console.aws.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161939/f5546d82-a194-3841-9f93-b45682f4b571.png)

こういう注意事項が表示されて…、

今後は、「AWS Cost and Usage Report」を使わないといけないのかなぁ。

うーん、また新しいやり方を考えなないといけないのだろうか…。

[AWS Cost and Usage Report \- AWS 請求情報とコスト管理](https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/billing-reports-costusage.html)