16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

NTTテクノクロスAdvent Calendar 2021

Day 3

【AWS】利用料金の詳細通知&自動化

Last updated at Posted at 2021-12-02

はじめに

この記事は NTT テクノクロス Advent Calendar 2021 の 3日目の記事です。

NTT テクノクロスの井上です。
普段は AWS 関連の業務をしています。

この記事では、
AWS の利用料金通知システム の作成についてご紹介します。

元々、AWS の利用料金を通知する Lambda を動かしていたのですが、
「今月の費用、予算内におさまりそう?」と尋ねられたり、
「どのサービスにいくら費用がかかっているのかも通知してほしい!」
などの要望があり、拡張機能を作成しました。

また、
最近 AWS へ移行するプロジェクトも増えてきたので、
これを機に料金通知システム作成の 自動化 を含めて、記事にまとめることにしました。

概要

通知内容は下記の4つです。
・ 今月1日から前日までの請求額
・ 今月1日から前日までの請求額の内訳(サービスごとの請求額)
・ 今月の予測請求額
・ 前日1日分の利用額

通知内容は、今月1日から前日までの請求額 をデフォルトとして、
他3つは必要に応じて拡張ができるよう作成します。

拡張機能を使うと、
予算内におさまりそうか であったり、どのサービスに費用が集中しているのか
可視化され、把握しやすくなるので、ぜひ取り入れてみてください。

目次

  1. 前提条件
  2. 構成
  3. Lambda 関数のコード
  4. CloudFormation
  5. 通知の確認

1. 前提条件

  • Cost Explorer が有効化されている
  • SNS トピック、サブスクリプションの設定が完了している

2. 構成

今回作成する機能の アーキテクチャ図 はこちらです。
CloudWatch Events で設定した cron をトリガーに、AWS 利用料金を取得・加工する Lambda を実行させ、SNS でメールを送信する流れとなります。
architecture.png

3. Lambda 関数のコード

コードは、前日までの請求額を通知するメインファイル(index.py)と、その他拡張機能を持ったファイル(detail.py)の2つを作成します。
Lambda 関数は、後ほど CloudFormation で作成します。

index.py

  • 前日までの請求額を通知する基本的なコード
index.py
import boto3
import os
import time
from datetime import datetime, timedelta, date
import detail

# 設定日時
def billing_date():
    # 月初の日付取得
    first_date = date.today().replace(day=1).isoformat()
    # 当日の日付取得
    last_date = date.today().isoformat()

    # 今日が1日なら「先月1日から今月1日(今日)」までの範囲にする
    if first_date == last_date:
        last_month_last_date = datetime.strptime(first_date, '%Y-%m-%d') - timedelta(days=1)
        last_month_first_date = last_month_last_date.replace(day=1)
        first_date = last_month_first_date.strftime('%Y-%m-%d')  
    return first_date, last_date

# 請求額取得
def get_billing(ce):
    first_date, last_date = billing_date()

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': first_date,
            'End': last_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 create_message(total_billing):
    total = round(float(total_billing['billing']), 2)
    sts = boto3.client('sts')
    id_info = sts.get_caller_identity()
    account_id = id_info['Account']
    subject = f'利用料金:${total} AccountID:{account_id}'

    today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
    yesterday = (today - timedelta(days=1)).strftime('%Y/%m/%d')
    message = []
    message.append(f'{yesterday}時点の請求額】\n  ${total:.2f}')
    return subject, message

# メッセージ送信
def send_message(subject, message_list):
    sns = boto3.client('sns')
    message = '\n'.join(message_list)

    retry_num = 3
    for i in range(retry_num):
        try:
            response = sns.publish(
                TopicArn = os.environ['Topic'],
                Subject = subject,
                Message = message
            )
            break
        # 送信失敗時は2秒後リトライ
        except Exception as e:
            print("送信に失敗しました。リトライ{}/{}".format(i+1, retry_num))
            time.sleep(2)
    return response

# main
def lambda_handler(event, context):
    ce = boto3.client('ce')
    # 請求額取得
    total_billing = get_billing(ce)
    # メッセージ作成
    subject, message = create_message(total_billing)

    ## 拡張用
    ## 今月の予測請求額取得
    # message = detail.get_estimated_billing(ce, message)
    ## 1日の請求額(前日からの増加額)
    # message = detail.get_daily_billing(ce, total_billing, message)
    ## サービス毎の請求額
    # message = detail.get_service_billings(ce, message)

    # メッセージ送信
    send_message(subject, message)

detail.py

  • 請求額の内訳(サービスごとの請求額)、今月の予測請求額、前日1日分の利用額を通知する拡張用コード
    ※ 拡張機能を使用する場合は、下記に注意してください。
    • 拡張する機能に応じて index.py の lambda_handler 内 対応箇所をコメントインする。
detail.py
import index
from datetime import timedelta, date
from dateutil.relativedelta import relativedelta

# 今月の予測請求額
def get_estimated_billing(ce, message):
    # 翌日から来月1日までを設定し予測請求額を取得
    next_date = date.today() + timedelta(days=1)
    next_month = date.today() + relativedelta(months=1)
    next_month_first_date = next_month.replace(day=1)

    response = ce.get_cost_forecast(
        TimePeriod={
        'Start': str(next_date),
        'End': str(next_month_first_date)
        },
        Granularity='MONTHLY',
        Metric='UNBLENDED_COST'
    )
    estimated_billing = round(float(response['Total']['Amount']), 2)

    # メッセージ追加
    message.append(f'【今月の予測請求額】\n  ${estimated_billing}')
    return message

# 1日の請求額(前日からの増加額)
def get_daily_billing(ce, today_billing, message):
    first_date = date.today().replace(day=1).isoformat()
    last_date = (date.today() - timedelta(days=1)).isoformat()

    # x月3日~月末のみ前日までの請求額を取得
    if first_date < last_date:
        response = ce.get_cost_and_usage(
            TimePeriod={
                'Start': first_date,
                'End': last_date
            },
            Granularity='MONTHLY',
            Metrics=[
                'AmortizedCost'
            ]
        )
        yesterday_billing = {
            'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
            'end': response['ResultsByTime'][0]['TimePeriod']['End'],
            'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'],
        }

        # 1日の請求額算出
        daily_billing = round(float(today_billing['billing']) - float(yesterday_billing['billing']), 2)

        # メッセージ追加
        message.append(f'【1日の請求額】\n  ${daily_billing}')
    return message

# サービス毎の請求額
def get_service_billings(ce, message):
    first_date, last_date = index.billing_date()

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': first_date,
            'End': last_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']
        })
    
    # メッセージ追加
    yesterday = (date.today() - timedelta(days=1)).strftime('%Y/%m/%d')
    message.append(f'\n{yesterday}時点の請求額 内訳】')
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)

        # 請求額が$0の場合は内訳を表示しない
        if billing == 0.0:
            continue
        message.append(f'{service_name}: ${billing}')
    return message

※ 作成が完了したら、2ファイルを圧縮(zip化)し、S3 の任意のバケットに格納してください。

4. CloudFormation

テンプレート

  • IAM ロール、Lambda 関数、CloudWatch Events を作成するテンプレート
Billing_template.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: "Create Lambda to send billing information."
Parameters:
  Bucketname:
    Type: String
    Description: S3Bucketname
  Filepass:
    Type: String
    Description: S3Filepass
  Topicarn:
    Type: String
    Description: SNSTopicarn
Resources:
  BillingIamRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: Lambda-send-billing-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"
      Path: /
      Policies:
      - PolicyName: Lambda-send-billing-policy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - ce:*
            - sns:CreateTopic
            - sns:Publish
            Resource: '*'
  BillingFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: !Ref Bucketname
        S3Key: !Ref Filepass
      Description: 'send billing information'
      FunctionName: 'send-billing-info'
      Handler: 'index.lambda_handler'
      Runtime: 'python3.8'
      Timeout: 30
      Environment:
        Variables:
          Topic: !Ref Topicarn
      Role: !GetAtt
        - BillingIamRole
        - Arn
  ScheduledRule:
    Type: AWS::Events::Rule
    Properties:
      Description: "ScheduledRule"
      ScheduleExpression: 'cron(0 0 * * ? *)'
      State: ENABLED
      Targets:
      - Arn: !GetAtt
        - 'BillingFunction'
        - 'Arn'
        Id: 'TargetFunctionV1'
  PermissionForEventsToInvokeLambda: 
    Type: AWS::Lambda::Permission
    Properties: 
      FunctionName: !Ref "BillingFunction"
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: 
        Fn::GetAtt: 
          - "ScheduledRule"
          - "Arn"

スタック作成

  1. AWS マネジメントコンソールから CloudFormation を選択
  2. [スタックの作成] から [新しいリソースを使用(標準)] を選択
  3. テンプレートの指定
    • 下部 [ファイルの選択] から先ほど作成した yaml ファイルを選択後、[次へ] を押下
      cfn-1.png
  4. スタックの詳細を指定
    • [Bucketname] :Lambda 関数で実行する zip ファイルを格納したバケット名
    • [Filepass] :zip ファイルまでのファイルパス
    • [Topicarn] :SNS トピックの ARN
    • 全ての項目を入力したら、[次へ] を押下
      cfn-2.png
  5. スタックオプションは特に設定せず、[次へ] を押下
  6. レビュー(最終確認)
    • 入力したものの誤りがないかを確認し、チェックボックスの内容を承認した後、[スタックの作成] を押下
      cfn-3.png

※ スタックが作成されるまでに数分かかります。

これで、CloudFormation を使った、AWS 利用料金通知機構の作成は完了です。

5. 通知の確認

CloudFormation テンプレート内の cron 設定日時に 00:00(GMT) を設定した場合、 日本時間 09:00 にメールが届きます。
※ 変更を加えていなければ 00:00(GMT) です。

  • 通知例
    • 拡張機能を全て使用した場合、このようなメールが届きます。
      message.png

さいごに

料金通知自体はすでにされている方も多いと思います。
実際、サービスごとの請求額も今月の予測請求額も、マネコンからぽちぽちしていくと確認できます。

なので、今回紹介した方法は、
毎日料金の詳細を確認したい方におすすめできる内容となります。

少しでも参考にしていただけたら嬉しいです。

では、NTTテクノクロス Advent Calendar 2021 の 4日目も、お楽しみください!

参考

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?