🌊

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

さいごに

改善の余地はあるかなと思いつつ、これが誰かの役に立てば幸いです!

DELTAテックブログ

Discussion