この記事は公開されてから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も消しました。