
AWSサービス毎の請求額を毎日Slackに通知してみた
この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
サーバーレス開発部の藤井元貴です。今日は雨が降る前に帰りたかったです。
プライベートでもAWSアカウントを保持しているのですが、「月額いくら?」「今どれぐらい請求されてる?」は気になるものです。
そこで、月初からの請求額を毎日Slackに通知するシステムをサーバーレスで構築してみました!

おすすめの方
- AWSの請求額を毎日知りたい
- AWSの請求額の内訳を知りたい
- AWS SAMを使いたい
- AWS SAMで独自Roleを定義したい
- AWS SAMでLambdaの環境変数を使いたい
- サーバーレスに興味がある
全体概要
「毎日、指定時刻になるとLambdaを起動し、Lambdaが請求額を取得&SlackにPostする」というサーバーレスな構成です。 デプロイ等にAWS SAMを使用します。

注意
下記などの要因によって、Slackに通知される請求額は変化します。あくまでも概算である点にご注意ください。
- Lambda関数の内容
- Lambda関数の実行タイミング(日本とバージニア州は、時差が14時間ある)
- 0.01USD未満の小数点の扱い
環境
| 項目 | バージョン | 
|---|---|
| macOS | High Sierra 10.13.6 | 
| AWS CLI | aws-cli/1.16.89 Python/3.6.1 Darwin/17.7.0 botocore/1.12.79 | 
| AWS SAM CLI | 0.10.0 | 
| Python | 3.6 | 
事前準備
下記の設定を行います。
- AWS請求の設定
- Slackの設定
AWS請求の設定
ルートアカウントでログインし、設定画面から「コストエクスプローラ」を有効にします。

Slackの設定
チャンネルの作成
通知先のチャンネルを作成します。ここでは、チャンネル名を#aws-billingとしています。

Incoming Webhookの追加
Incoming Webhookの設定を行います。
通知先チャンネルから「アプリを追加する」を選択します。

アプリとしてIncoming Webhookを検索します。

「設定を追加」を選択します。初回であれば画面は違うかもしれません。

投稿先のチャンネルを選択し、「incomming Webhookインテグレーションの追加」を選択します。

作成されたWebhook URLをメモしておきます。このURLに対して、特定フォーマットでPOSTすれば、Slackのチャンネルに投稿されます。

投稿時のアイコンなども設定できるので、必要に応じて設定します。
やってみる
プロジェクトフォルダの作成
AWS SAMでプロジェクトフォルダを作成します。
sam init --runtime python3.6 --name AWS_Billing
templateファイル
AWS SAMのtemplate.yamlは下記です。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Notify Slack every day of AWS billing
Globals:
  Function:
    Timeout: 10
Parameters:
  SlackWebhookUrl:
    Type: String
    Default: hoge
Resources:
  # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html
  # https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-lambda-state-machine-cloudformation.html
  BillingIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "NotifySlackToBillingLambdaPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "ce:GetCostAndUsage"
                Resource: "*"
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.6
      Environment:
        Variables:
          # このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
      Role: !GetAtt BillingIamRole.Arn
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 0 * * ? *) # 日本時間AM9時に毎日通知する
Outputs:
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt BillingIamRole.Arn
Lambda関数の作成
Lambda関数のコードは下記です。
(2019年8月2日追記) 毎月1日の通知が失敗していたので、修正しました。
import os
import boto3
import json
import requests
from datetime import datetime, timedelta, date
SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
def lambda_handler(event, context) -> None:
    client = boto3.client('ce', region_name='us-east-1')
    # 合計とサービス毎の請求額を取得する
    total_billing = get_total_billing(client)
    service_billings = get_service_billings(client)
    # Slack用のメッセージを作成して投げる
    (title, detail) = get_message(total_billing, service_billings)
    post_slack(title, detail)
def get_total_billing(client) -> dict:
    (start_date, end_date) = get_total_cost_date_range()
    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.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(client) -> list:
    (start_date, end_date) = get_total_cost_date_range()
    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.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_message(total_billing: dict, service_billings: list) -> (str, str):
    start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%m/%d')
    # Endの日付は結果に含まないため、表示上は前日にしておく
    end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
    end_yesterday = (end_today - timedelta(days=1)).strftime('%m/%d')
    total = round(float(total_billing['billing']), 2)
    title = f'{start}~{end_yesterday}の請求額は、{total:.2f} USDです。'
    details = []
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)
        if billing == 0.0:
            # 請求無し(0.0 USD)の場合は、内訳を表示しない
            continue
        details.append(f' ・{service_name}: {billing:.2f} USD')
    return title, '\n'.join(details)
def post_slack(title: str, detail: str) -> None:
    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    payload = {
        'attachments': [
            {
                'color': '#36a64f',
                'pretext': title,
                'text': detail
            }
        ]
    }
    # http://requests-docs-ja.readthedocs.io/en/latest/user/quickstart/
    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_total_cost_date_range() -> (str, str):
    start_date = get_begin_of_month()
    end_date = get_today()
    # 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_begin_of_month() -> str:
    return date.today().replace(day=1).isoformat()
def get_prev_day(prev: int) -> str:
    return (date.today() - timedelta(days=prev)).isoformat()
def get_today() -> str:
    return date.today().isoformat()
環境変数
SlackのPOST先のURLをコードに直接記載したくないため、Lambdaの環境変数を利用します。
SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
Lambdaの環境変数を設定するためには、template.yamlで下記を記載します。
      Environment:
        Variables:
          # このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
上記に直接記載しても良いのですが、さらに変数化させています。
template.yamlで下記のようにパラメータの設定を行い、デプロイ時にこの値を上書きしています(後述)。
Parameters:
  SlackWebhookUrl:
    Type: String
    Default: hoge
権限
AWS SAMではいくつかのポリシーが用意されていますが、コストエクスプローラはありませんでした。
そのため、下記のようにIAMロールを作成しています。
  BillingIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "NotifySlackToBillingLambdaPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "ce:GetCostAndUsage"
                Resource: "*"
S3バケットの作成
コード等を格納するためのS3バケットを作成します。作成済みの場合は飛ばします。
aws s3 mb s3://gnk263-lambda-bucket
ビルド
下記コマンドでビルドします。
sam build
package
続いてコード一式をS3バケットにアップロードします。
sam package \
    --output-template-file packaged.yaml \
    --s3-bucket gnk263-lambda-bucket
deploy
最後にデプロイします。template.yamlの環境変数をオーバーライドし、ここでSlackのWebhook URLを設定します。
sam deploy \
    --template-file packaged.yaml \
    --stack-name NotifyBillingToSlack \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides SlackWebhookUrl=https://hooks.slack.com/services/xxxxxxxxxxxxx
あとは時間になればSlackに通知されます。
すぐに試したい場合は、ブラウザでAWSにログインしてLambdaを手動テストするか、AWS SAMでLambda関数をローカル実行すればOKです。 (Lambda関数をローカル実行する場合は、実行時に環境変数でWebhook URLを指定します)
日本時間 AM9時
無事に通知が来ました! (少しコードを修正してから手動実行したので、下記の画像は5分ズレてます)

実際の請求は下記となっています。だいたい合ってる!

費用
コストエクスプローラは、1回のAPI呼出に対して、約1円(0.01 USD)の料金が発生します。 そのため、毎日通知すると月額で約30円の料金が発生します。
その他の料金は、無視できるぐらいに少額だったり、無料枠で収まる範囲です。(他のサービスの利用状況によって変わります)
さいごに
実は、以前から合計額はSlackに通知していました。 今回、サービス毎の詳細も通知してみて、内訳を知りました。
大半はDynamoDBの請求だと思っていたのですが、実はEC2だったことに驚きました。 数ヶ月前に少しだけ使ったあと、インスタンスとElastic IPは消したのですが、VPCは残ってたので、そのせいかもしれません。
思わぬ事実が判明しましたが、サービス毎の内訳が分かって良かったです。VPCも消しました。











