⚽
【AWSやってみた】 Line通知でAWS請求金額確認
こんな方におすすめ!!
- AWSアカウントでハンズオン形式で学んでいく際に、請求金額が気になってなかなか触る勇気が出ない
- 請求金額は毎日確認しているが、めんどくさいな
これをやればどうなる?
- Lineで請求金額を毎日確認できる!
作成ポイントと得た知識
- StepFunctions::StateMachine、Lambda::Functionのそれぞれに同じCloudFormationテンプレートでLogs::LogGroupを作成すること
- "logs:CreateLogGroup"の権限を付与してリソース自身がLogs::LogGroupを作成させてログを格納することもできるが、それだとCloudFormationスタックを消した後にLogs::LogGroupが残ってしまいます。
- CloudFormationスタックを消してもログは残しておきたい場合は、DeletionPolicyを設定する必要があります。https://repost.aws/ja/knowledge-center/delete-cf-stack-retain-resources
- StepFanctionベースで処理内容を作成していくこと
- 実行テストのしやすさ、処理内容とエラー箇所が分かりやすくなりました。
- ユーザー好みの値にしたい箇所+アップデートが必要な箇所はパラメータ化
- 早朝の請求メッセージは自分の好きな請求メッセージにしてみてください。 - リソース名にCloudFormationstack名を入れること
- どのCloudFormationstackから作成したかわかりやすくしました。
使用するAWSサービス
- AWS CloudFormationテンプレートで作成するリソース/数
- AWS::Events::Rule/1
- AWS::StepFunctions::StateMachine/1
- AWS::Lambda::Function/4
- AWS::Logs::LogGroup/5
- AWS::IAM::Role/3
- 手動で作成するリソース/数
- Lambdaレイヤー/1
前提条件
- AWS Cost Explorer有効化済み
- LineNotifyのトークン取得済み https://notify-bot.line.me/ja/
手順
- CloudFormationスタック作成
- Lambdaレイヤー作成し、一部のLambda関数に付与
- 後は時間を待つのみ
使用するリソース
CloudFormationテンプレート
入力パラメータ
- (必須)LineAccessToken:取得したLineNotifyのトークン
- (必須)MorningMessage:早朝の請求メッセージ
- ちなみに私は「なんで請求されたか、明日までに考えておいてください」です。(本田圭佑選手の煽り名言)
- (必須)ScheduleTime:cron式記述で時間指定
- (必須)StockLogDays:LogGroupのログ保管期間(day)
- (必須)LambdaRuntime:pythonのバージョン
AWSTemplateFormatVersion: '2010-09-09'
Description: Notify Line every day of AWS billing
Parameters:
LineAccessToken:
Type: String
Default: hoge
MorningMessage:
Type: String
ScheduleTime:
Type: String
Default: cron(00 21 * * ? *)
StockLogDays:
Type: Number
Default: 7
LambdaRuntime:
Type: String
Default: python3.8
Resources:
EventBridgeRule:
Type: AWS::Events::Rule
Properties:
EventBusName: default
Name: !Sub "${AWS::StackName}-EventBrige"
ScheduleExpression: !Ref ScheduleTime
State: ENABLED
Targets:
- Arn: !GetAtt StateMachine.Arn
Id: "StateMachine"
RoleArn: !GetAtt EventBrigeIamRole.Arn
StateMachine:
Type: AWS::StepFunctions::StateMachine
Properties:
Definition:
Comment: "StateMachine"
StartAt: HellowWorldState
States:
HellowWorldState:
Type: Task
Resource: !GetAtt HellowWorldLambda.Arn
Next: GetDateRangeState
GetDateRangeState:
Type: Task
Resource: !GetAtt GetDateRangeLambda.Arn
Next: GetBillingState
GetBillingState:
Type: Task
Resource: !GetAtt GetBillingLambda.Arn
Next: MessageState
MessageState:
Type: Task
Resource: !GetAtt MessageLambda.Arn
End: true
LoggingConfiguration:
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt StateMachineLogGroup.Arn
IncludeExecutionData: true
Level: ALL
RoleArn: !GetAtt StateMachineRole.Arn
StateMachineName: !Sub "${AWS::StackName}-StateMachine"
StateMachineType: STANDARD
StateMachineLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/StepFunctions/${AWS::StackName}-StateMachine"
RetentionInDays: !Ref StockLogDays
HellowWorldLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-HellowWorldLambda"
Role: !GetAtt LambdaIamRole.Arn
Runtime: !Ref LambdaRuntime
Timeout : 3
Environment:
Variables:
LINE_ACCESS_TOKEN: !Ref LineAccessToken
MORNING_MESSAGE: !Ref MorningMessage
Handler: index.lambda_handler
Code:
ZipFile: !Sub |
import os
import requests
LINE_ACCESS_TOKEN = os.environ["LINE_ACCESS_TOKEN"]
MORNING_MESSAGE = str(os.environ["MORNING_MESSAGE"])
def lambda_handler(event, context) -> None:
url = "https://notify-api.line.me/api/notify"
headers = {"Authorization": "Bearer %s" % LINE_ACCESS_TOKEN}
data = {'message': f'\n{MORNING_MESSAGE}'}
try:
response = requests.post(url, headers=headers, data=data)
except requests.exceptions.RequestException as e:
print(e)
else:
print(response.status_code)
HellowWorldLambdaLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-HellowWorldLambda"
RetentionInDays: !Ref StockLogDays
GetDateRangeLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-GetDateRangeLambda"
Role: !GetAtt LambdaIamRole.Arn
Runtime: !Ref LambdaRuntime
Timeout : 3
Handler: index.lambda_handler
Code:
ZipFile: !Sub |
from datetime import datetime, timedelta, date
def lambda_handler(event, context) -> (dict):
date_range={}
date_range["start_date"] = date.today().replace(day=1).isoformat()
date_range["end_date"] = date.today().isoformat()
# 「今日が1日」なら、「先月1日から今月1日(今日)」までの範囲にする
if date_range["start_date"] == date_range["end_date"]:
end_of_month = datetime.strptime(date_range["start_date"], '%Y-%m-%d') + timedelta(days=-1)
begin_of_month = end_of_month.replace(day=1)
return begin_of_month.date().isoformat(), date_range["end_date"]
return date_range
GetDateRangeLambdaLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-GetDateRangeLambda"
RetentionInDays: !Ref StockLogDays
GetBillingLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-GetBillingLambda"
Role: !GetAtt LambdaIamRole.Arn
Runtime: !Ref LambdaRuntime
Timeout : 3
Handler: index.lambda_handler
Code:
ZipFile: !Sub |
import boto3
#期間の合計請求額,サービス請求額を返す
def lambda_handler(event, context) -> dict:
client = boto3.client('ce', region_name='us-east-1')
start_date = event["start_date"]
end_date = event["end_date"]
billing = {}
billing["total_billing"] = get_total_billings(client,start_date,end_date)
billing["service_billings"] = get_service_billings(client,start_date,end_date)
return billing
#期間の合計請求額を取得
def get_total_billings(client,start_date,end_date) -> dict:
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,start_date,end_date) -> list:
response = client.get_cost_and_usage(
TimePeriod={
'Start': start_date,
'End': end_date
},
Granularity='MONTHLY',
Metrics=[
'AmortizedCost'
],
GroupBy=[
{
'Type': 'DIMENSION',
'Key': 'SERVICE'
}
]
)
service_billings = []
for item in response['ResultsByTime'][0]['Groups']:
service_billings.append({
'service_name': item['Keys'][0],
'billing': item['Metrics']['AmortizedCost']['Amount']
})
return service_billings
GetBillingLambdaLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-GetBillingLambdaLambda"
RetentionInDays: !Ref StockLogDays
MessageLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-MessageLambda"
Role: !GetAtt LambdaIamRole.Arn
Runtime: !Ref LambdaRuntime
Timeout : 3
Environment:
Variables:
LINE_ACCESS_TOKEN: !Ref LineAccessToken
Handler: index.lambda_handler
Code:
ZipFile: !Sub |
import os
import requests
LINE_ACCESS_TOKEN = os.environ["LINE_ACCESS_TOKEN"]
def lambda_handler(event, context) -> None:
total_billing = event["total_billing"]
service_billings = event["service_billings"]
# Line用のメッセージを作成
(title, detail) = get_message(total_billing, service_billings)
LineNotify_Alert(title, detail)
def LineNotify_Alert(title, detail) -> None:
# LineNotifyのアクセストークンでBearer認証して通知させる
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_message(total_billing: dict, service_billings: list) -> (str, str):
total = round(float(total_billing['billing']), 2)
title = f'\n現時点の請求額:{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)
MessageLambdaLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-MessageLambda"
RetentionInDays: !Ref StockLogDays
EventBrigeIamRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-EventBrigeIamRole"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: events.amazonaws.com
Action: "sts:AssumeRole"
Policies:
- PolicyName: !Sub "${AWS::StackName}-StateStateEventBrigePolicy"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- "states:StartExecution"
Resource: !GetAtt StateMachine.Arn
StateMachineRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-StateMachineRole"
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service:
- states.amazonaws.com
Policies:
- PolicyName: !Sub "${AWS::StackName}-InvokeTaskFunctions"
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource: "*"
- PolicyName: !Sub "${AWS::StackName}-DeliverToCloudWatchLogPolicy"
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- logs:CreateLogDelivery
- logs:GetLogDelivery
- logs:UpdateLogDelivery
- logs:DeleteLogDelivery
- logs:ListLogDeliveries
- logs:PutLogEvents
- logs:PutResourcePolicy
- logs:DescribeResourcePolicies
- logs:DescribeLogGroups
Resource: "*"
LambdaIamRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-LambdaIamRole"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: "sts:AssumeRole"
Policies:
- PolicyName: !Sub "${AWS::StackName}-NotifyLineToBillingLambdaPolicy"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- "logs:CreateLogStream"
- "logs:PutLogEvents"
- "ce:GetCostAndUsage"
Resource: "*"
Lambdaレイヤー
コマンド
$ mkdir python
$ pip install -t python requests "urllib3<2"
$ zip -r9 layer.zip python
詰まった部分
Lambdaレイヤー作成時のコマンドで詰まった
解消前のコマンド
$ mkdir python
$ pip install -t python requests
$ zip -r9 layer.zip python
解消後
$ mkdir python
$ pip install -t python requests "urllib3<2"
$ zip -r9 layer.zip python
エラー1
{
"errorMessage": "Unable to import module 'lambda_function': No module named 'requests'",
"errorType": "Runtime.ImportModuleError"
}
→Lambdaレイヤーが必要だと気づく
エラー2
{
"errorMessage": "Unable to import module 'index': urllib3 v2.0 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'OpenSSL 1.0.2k-fips 26 Jan 2017'. See: https://github.com/urllib3/urllib3/issues/2168",
"errorType": "Runtime.ImportModuleError",
"stackTrace": []
}
→Lambdaのランタイムを最新まで上げる必要があった
python3.8 →python3.10
エラー3
{
"errorMessage": "Unable to import module 'index': cannot import name 'DEFAULT_CIPHERS' from 'urllib3.util.ssl_' (/opt/python/urllib3/util/ssl_.py)",
"errorType": "Runtime.ImportModuleError",
"requestId": "f923ed92-9297-404c-a0af-74811fc79858",
"stackTrace": []
}
→バージョン変更
pip install -t python requests "urllib3<2"
✖️;pip install -t python request==2.25.0
参考にさせていただいた資料
Discussion