【AWSやってみた】 Line通知でAWS請求金額確認

2023/06/29に公開

こんな方におすすめ!!

  • 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

前提条件

手順

  1. CloudFormationスタック作成
  2. Lambdaレイヤー作成し、一部のLambda関数に付与
  3. 後は時間を待つのみ

使用するリソース

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レイヤーが必要だと気づく

https://sebenkyo.com/2021/05/21/post-1979/

エラー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

https://repost.aws/questions/QUqD8nfai4Tly2jSpzNOo2AQ/setting-up-aws-cloud9-with-python3-9-runtime-and-urllib3

エラー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

https://stackoverflow.com/questions/76414514/cannot-import-name-default-ciphers-from-urllib3-util-ssl-on-aws-lambda-us
https://qiita.com/SatoshiGachiFujimoto/items/437b0ccaba817903fb72
https://github.com/psf/requests/issues/6443

参考にさせていただいた資料

https://dev.classmethod.jp/articles/notify-line-aws-billing/
https://awstut.com/2022/06/18/introduction-to-step-functions-with-cfn/
https://cloud5.jp/aws-lambda_line-api/

Discussion