こんにちは、つくぼし(tsukuboshi0755)です!
みなさんは、利用中の AWS 料金を逐一把握されていますでしょうか?
リソースの消し忘れ等で、いつのまにか AWS からの請求額がとんでもない事になっていた...という体験談を持つ方もいらっしゃるかと思います。(私もその一人です)
上記の対策として、以下の記事のように、AWS の請求額を毎日通知するシステムを構築し、確認する方法が挙げられます。
こちらのシステムは非常に便利なのですが、 Slack への通知が前提となるため、普段 Slack を利用していない方からすると多少扱いづらいかもしれません。
そこで今回は、上記のシステムを少しいじり、大半の方がプライベートで利用しているであろう LINE に対して、月初からの AWS サービス毎の請求額を毎日通知するシステムを構築してみます!
前提条件
今回は下記のソフトウェアの使用を前提としています。
足りない方は個別で導入/設定をご実施ください。
項目 | バージョン |
---|---|
AWS CLI | 2.4 |
AWS SAM CLI | 1.75 |
Python | 3.9 |
事前準備
以下で、対象システムを構築するために必要な準備を実施します。
Cost Explorer の有効化
もし Cost Explorer がまだ有効になっていない場合は、この段階で有効にしておきます。
ルートアカウントでログインし、設定画面から Cost Explorer を有効にします。
なおAWS公式にもCost Explorer の有効化手順の記載がありますので、ご参照ください。
LINE Notify の Access Token 発行
今回は LINE Notify で Access Token を発行し、こちらを用いて LINE に通知を行います。
初めにLINE Notify 公式ページにアクセスし、右上の"ログイン"を押して、自身の LINE アカウントにログインします。
ログイン後、右上のペインから"マイページ"をクリックします。
マイページから、"トークンの発行"ボタンを押します。
任意のトークン名を入力し、通知を送信するトークルームを選択します。
今回は"1:1からLINE Notifyから通知を受け取る"を選択していますが、特定の LINE グループを選択する事も可能です。
なお LINE グループに通知する場合は、LINE Notify アカウントを該当グループのメンバーとして追加する必要があるためご注意ください。
トークン名の入力とトークルームの選択が完了したら、"発行する"ボタンを押してください。
"コピー"ボタンを押して、発行された Access Token をコピーします。
こちらは SAM アプリデプロイ時に使用するため、忘れずにメモしておいてください。
システム構築
以下で、対象システムの構築の流れについて説明します。
なお通知先が Slack ではなく LINE である事以外は、AWSサービス毎の請求額を毎日Slackに通知してみたブログとシステム構成はほぼ同じです。
SAM プロジェクトの作成
AWS SAM CLI でプロジェクトを作成します。
なお今回は SAM 初期化コマンドに対して事前にオプションを指定し、一発でプロジェクトが作成されるようにします。
sam init \
--runtime python3.9 \
--app-template hello-world \
--name notify-line-to-aws-billing \
--no-application-insights \
--no-tracing
作成後、プロジェクトディレクトリに移動します。
cd notify-line-to-aws-billing
なお SAM 初期化後のプロジェクトディレクトリの中身は、下記の通りです。
$ tree
.
├── README.md
├── __init__.py
├── events
│ └── event.json
├── hello_world
│ ├── __init__.py
│ ├── app.py
│ └── requirements.txt
├── samconfig.toml
├── template.yaml
└── tests
├── __init__.py
├── integration
│ ├── __init__.py
│ └── test_api_gateway.py
├── requirements.txt
└── unit
├── __init__.py
└── test_handler.py
6 directories, 14 files
SAM テンプレートファイルの変更
プロジェクトフォルダ内のtemplate.yaml
を、下記の内容に変更します。
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Notify Line every day of AWS billing
Globals:
Function:
Timeout: 10
Parameters:
LineAccessToken:
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: "NotifyLineToBillingLambdaPolicy"
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.9
Environment:
Variables:
# このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する
LINE_ACCESS_TOKEN: !Ref LineAccessToken
Role: !GetAtt BillingIamRole.Arn
Events:
NotifyLine:
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 関数コードの変更
プロジェクトフォルダ内のhello_world/app.py
を、下記の内容に変更します。
app.py
import os
import boto3
import requests
from datetime import datetime, timedelta, date
LINE_ACCESS_TOKEN = os.environ["LINE_ACCESS_TOKEN"]
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)
# Line用のメッセージを作成して投げる
(title, detail) = get_message(total_billing, service_billings)
post_line(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_line(title: str, detail: str) -> None:
# https://notify-bot.line.me/doc/ja/
url = "https://notify-api.line.me/api/notify"
headers = {"Authorization": "Bearer %s" % LINE_ACCESS_TOKEN}
data = {'message': f'{title}\n\n{detail}'}
try:
response = requests.post(url, headers=headers, data=data)
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()
S3 バケットの作成
コード等を格納するための S3 バケットを事前に作成します。(バケット名は任意の名前でOKです)
aws s3 mb s3://cm-tsukuboshi-lambda-bucket
SAM アプリのビルド/パッケージング/デプロイ
初めにSAM プロジェクトのルートディレクトリにて、下記コマンドで SAM アプリをビルドします。
なお、もしビルドがローカルの依存関係で失敗してしまう場合は、--use-container
オプションの追加を検討してみてください。
(別途 Docker の導入が必要になります)
sam build
続いて下記コマンドでコード一式を S3 バケットにアップロードし、 SAM アプリをパッケージングします。
sam package \
--output-template-file packaged.yaml \
--s3-bucket cm-tsukuboshi-lambda-bucket
最後に下記コマンドで SAM アプリをデプロイします。
template.yaml
の環境変数を--parameter-overrides
オプションで上書きし、ここで事前準備でメモした LINE Notify の Access Token を設定します。
sam deploy \
--template-file packaged.yaml \
--stack-name NotifyBillingToLine \
--capabilities CAPABILITY_IAM \
--parameter-overrides LineAccessToken=xxxxxxxxxxxx
Lambda 関数のテスト
デプロイ後の Lambda 関数がきちんと動くかテストします。
Lambda コンソールにアクセスし、対象関数を選択した後、テストタブを選択し"テスト"ボタンを押します。
通知先として設定した LINE Notify アカウント(または LINE グループ内)から、以下のような AWS 料金に関するメッセージが届けば、 Lambda の動作としてはOKです!
あとは指定時刻(今回の場合はAM9:00)まで待機し、同じようなメッセージが届けば EventBridge の動作も問題ありません!
本システムでかかる AWS 料金
(2023年3月6日) 本システムの利用時にかかる AWS 料金の詳細を追記しました。
以下では、本システムの利用時にかかる AWS 料金について説明します。
Lambda については、現時点で大量の Lambda リクエストを実施していない限り、下記の Lambda の無料利用枠に収まります。
(私も本システムを利用していますが、無料利用枠内に収まっています)
AWS Lambda の無料利用枠には、1 か月あたり 100 万件の無料リクエストと、1 か月あたり 40 万 GB-s のコンピューティングタイムが含まれており、x86 および Graviton2 プロセッサの両方を搭載した機能を利用できます。
一方で Lambda 関数で呼び出している Cost Explorer API の料金として、1回のリクエストにつき0.01USD発生します。
今回の Lambda 関数を1回実行すると、"合計料金"と"サービス別料金"を取得するために2回APIリクエストを投げる事になるため、0.02USDかかる計算になります。
ページ分割された API リクエストごとに 0.01 USD の料金が発生します
加えて、上記に10%の税金が付与されます。
そのため、毎日こちらのシステムを起動した場合、月額換算(1ヶ月30日)で以下の料金になる事が予測されます。
(0.02USD * 30回) * 1.1 = 0.66USD(≒約90円)
なお上記の料金は、 Lambda 関数のテスト実行数や月の日数、作成したコード保管用バケット等の要因で多少変動します。
そのため、あくまで目安として頂けるとありがたいです!
最後に
今回は、月初からの請求額を毎日 LINE に通知するシステムを構築してみました。
普段から AWS を利用する方は、 AWS の想定外の高額利用を防ぐために、ぜひ構築を検討してみてください。
以上、つくぼし(tsukuboshi0755)でした!