ちょっと話題の記事

AWSサービス毎の請求額を毎日LINEに通知してみた

2023.03.05

こんにちは、つくぼし(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)でした!