🌊
AWS利用料をSlack通知する
はじめに
こんにちは、DELTAの馬場です。
社内で検証用に利用しているAWSアカウントの利用料が気になってきたので、日次の利用料金をSlackに通知する仕組みを作ったので誰かの役に立たないかなーと思い記事にしました。
何ができるか
任意のSlackチャンネルに以下の内容を投稿します。
- 前日までの月間利用料の合計
- 前日の利用料金
- サービスごとの前日までの月間利用料の合計
- サービスごとの前日の利用料金差分
実装
PythonのLambdaで集計を行い、EventBridgeで定期実行を行います。集計結果はSlackで通知します。
これをCloudFormationのテンプレートとして登録し、パラメータにはSlack webhookと定期実行のcron式を設定します。
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Lambda to send AWS billing summary to Slack via webhook.'
Parameters:
SlackWebhookUrl:
Type: String
Description: Slack Incoming Webhook URL
ScheduleExpression:
Type: String
Description: Cron expression for CloudWatch Events (UTC)
Default: 'cron(0 0 * * ? *)' # 毎日 9:00 JST.
Resources:
LambdaBillingRole:
Type: AWS::IAM::Role
Properties:
RoleName: lambda-send-billing-to-slack-role
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: LambdaBillingPermissions
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ce:GetCostAndUsage
- sts:GetCallerIdentity
Resource: "*"
BillingSlackFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: send-aws-billing-to-slack
Description: Sends daily AWS billing summary to Slack
Handler: index.lambda_handler
Role: !GetAtt LambdaBillingRole.Arn
Runtime: python3.8
Timeout: 60
Environment:
Variables:
SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
Code:
ZipFile: |
import boto3
import os
import json
import urllib3
from datetime import datetime, timedelta, timezone, date
JST = timezone(timedelta(hours=9))
def fetch_cost_and_usage(ce_client, start_date, end_date, group_by=None, granularity='DAILY'):
params = {
'TimePeriod': {'Start': start_date, 'End': end_date},
'Granularity': granularity,
'Metrics': ['AmortizedCost']
}
if group_by:
params['GroupBy'] = group_by
return ce_client.get_cost_and_usage(**params)
def get_total_billing(ce_client, start_str, end_str, today_jst):
start_date = datetime.strptime(start_str, '%Y-%m-%d').date()
if today_jst.day == 1:
start_date = (start_date - timedelta(days=1)).replace(day=1)
start_str = start_date.strftime('%Y-%m-%d')
response = fetch_cost_and_usage(ce_client, start_str, end_str)
total = sum(float(day['Total']['AmortizedCost']['Amount']) for day in response['ResultsByTime'])
return round(total, 2)
def get_daily_billing(ce_client, start_str, end_str, messages):
response = fetch_cost_and_usage(ce_client, start_str, end_str)
amount = float(response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'])
daily = round(amount, 2)
if daily > 0:
messages.append(f'【{start_str}のAWS利用料】\n ${daily:.2f}')
return messages
def get_service_breakdown(ce_client, start_str, end_str, messages):
start = datetime.strptime(start_str, '%Y-%m-%d').date()
end = datetime.strptime(end_str, '%Y-%m-%d').date()
if start == end:
first_day_prev_month = (start - timedelta(days=1)).replace(day=1)
last_day_prev_month = start - timedelta(days=1)
start = first_day_prev_month
end = last_day_prev_month
response = fetch_cost_and_usage(
ce_client,
start.strftime('%Y-%m-%d'),
end.strftime('%Y-%m-%d'),
group_by=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}],
granularity='MONTHLY'
)
groups = response['ResultsByTime'][0]['Groups']
sorted_groups = sorted(
groups,
key=lambda g: float(g['Metrics']['AmortizedCost']['Amount']),
reverse=True
)
messages.append(f'\n【サービスごとのAWS利用料】')
for item in sorted_groups:
service = item['Keys'][0]
billing = round(float(item['Metrics']['AmortizedCost']['Amount']), 2)
if billing > 0:
messages.append(f' ・{service}: ${billing}')
return messages
def get_service_daily_diff(ce_client, yesterday_str, messages):
two_days_ago = (datetime.strptime(yesterday_str, '%Y-%m-%d') - timedelta(days=1))
two_days_ago_str = two_days_ago.strftime('%Y-%m-%d')
end_str = (datetime.strptime(yesterday_str, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
response = fetch_cost_and_usage(
ce_client,
two_days_ago_str,
end_str,
group_by=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}],
granularity='DAILY'
)
results = {r['TimePeriod']['Start']: {
g['Keys'][0]: float(g['Metrics']['AmortizedCost']['Amount'])
for g in r.get('Groups', [])
} for r in response['ResultsByTime']}
before = results.get(two_days_ago_str, {})
after = results.get(yesterday_str, {})
diffs = [
(svc, round(after.get(svc, 0) - before.get(svc, 0), 2))
for svc in set(before) | set(after)
if round(after.get(svc, 0) - before.get(svc, 0), 2) != 0
]
if not diffs:
return messages
messages.append("\n【サービスごとの利用料の変化(前日との差分)】")
for svc, diff in sorted(diffs, key=lambda x: abs(x[1]), reverse=True):
sign = '+' if diff > 0 else ''
messages.append(f' ・{svc}: {sign}${diff:.2f}')
return messages
def format_total_amount_message(total, display_date):
account_id = boto3.client('sts').get_caller_identity()['Account']
subject = f'AWS利用料通知({account_id})'
messages = [f'【{display_date}時点の月内請求額】\n ${total:.2f}']
return subject, messages
def send_to_slack(subject, messages):
http = urllib3.PoolManager()
webhook_url = os.environ['SLACK_WEBHOOK_URL']
payload = json.dumps({'text': f'*{subject}*\n' + '\n'.join(messages)}).encode('utf-8')
res = http.request(
'POST',
webhook_url,
body=payload,
headers={'Content-Type': 'application/json'}
)
print(f'Slack送信 status: {res.status}')
def lambda_handler(event, context):
now = datetime.now(JST).date()
yesterday = now - timedelta(days=1)
first_of_month = now.replace(day=1)
now_str = now.strftime('%Y-%m-%d')
yesterday_str = yesterday.strftime('%Y-%m-%d')
first_of_month_str = first_of_month.strftime('%Y-%m-%d')
ce_client = boto3.client('ce')
total_billing = get_total_billing(ce_client, first_of_month_str, now_str, now)
subject, messages = format_total_amount_message(total_billing, yesterday_str)
messages = get_daily_billing(ce_client, yesterday_str, now_str, messages)
messages = get_service_breakdown(ce_client, first_of_month_str, now_str, messages)
messages = get_service_daily_diff(ce_client, yesterday_str, messages)
send_to_slack(subject, messages)
BillingScheduleRule:
Type: AWS::Events::Rule
Properties:
Description: "Daily trigger for billing notification"
ScheduleExpression: !Ref ScheduleExpression
State: ENABLED
Targets:
- Arn: !GetAtt BillingSlackFunction.Arn
Id: "BillingSlackTarget"
LambdaInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref BillingSlackFunction
Action: 'lambda:InvokeFunction'
Principal: 'events.amazonaws.com'
SourceArn: !GetAtt BillingScheduleRule.Arn
さいごに
改善の余地はあるかなと思いつつ、これが誰かの役に立てば幸いです!
Discussion