AWSサービス毎の請求額を毎日Slackに通知してみた
はじめに
サーバーレス開発部の藤井元貴です。今日は雨が降る前に帰りたかったです。
プライベートでも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も消しました。