ちょっと話題の記事

AWSサービス毎の請求額を毎日Slackに通知してみた

AWSの料金は、「月額いくら?」や「今どれぐらい?」などと気になるものです。今回は、請求額と内訳を毎日Slackに通知する仕組みを作ってみました。
2019.02.19

この記事は公開されてから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は下記です。

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日の通知が失敗していたので、修正しました。

app.py

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の環境変数を利用します。

app.py

SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']

Lambdaの環境変数を設定するためには、template.yamlで下記を記載します。

template.yaml

      Environment:
        Variables:
          # このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl

上記に直接記載しても良いのですが、さらに変数化させています。 template.yamlで下記のようにパラメータの設定を行い、デプロイ時にこの値を上書きしています(後述)。

template.yaml

Parameters:
  SlackWebhookUrl:
    Type: String
    Default: hoge

権限

AWS SAMではいくつかのポリシーが用意されていますが、コストエクスプローラはありませんでした。

そのため、下記のようにIAMロールを作成しています。

template.yaml

  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も消しました。

参考