Email daily AWS bills on Lambda x Amazon SNS

Introduction

You can set billing alerts on CloudWatch, but as a super-worried person, I would like to check the billing amount from the beginning of the month to the day before by email every day. There were many ways to notify Slack channels using AWS Lambda and webhooks, but there weren't many ways to notify by email, so I've summarized them.

Overview

Understanding of terms

In particular, the terms that appear in Amazon SNS and their relationships were complicated, so I will summarize them very roughly.

For a deeper understanding, the terminology section of the following article was very helpful, so it is recommended that you read it carefully in advance.

Basic knowledge for sending push notifications on Amazon SNS | UNITRUST

Setting method

Enable Cost Explorer

If you haven't enabled Cost Explorer, enable it from My Billing Dashboard (https://console.aws.amazon.com/billing/home#/costexplorer).

1.Cost Explorer.png

Creating SNS topics

Go to the Amazon SNS service screen.

From the "Topic" menu, press "Create Topic". 2-1.トピックメニュー.png

Enter "Name" and "Display name" and press "Create topic".

Subscription creation

Create a subscription (+ endpoint).

Click "Create Subscription".

Enter the following items and press "Create Subscription".

item name Input value / selection value
Topic ARN ARN of the topic you wrote down
protocol Email
end point Received email address

3-2.サブスクリプションの作成.png

Subscription Approval

A confirmation email will be sent to the email address specified for the endpoint with the subject "AWS Notification --Subscription Confirmation", so press "Confirm subscription".

4-1.認証メール.png 4-2.認証メール.png

The status of the subscription associated with the topic will be "Confirmed". 4-3.認証メール.png

Creating a Lambda function

You have created a topic subscription endpoint.

When you issue a message to the ARN of a topic, the message will be delivered to the endpoint (email address) associated with this topic. So next, create a Lambda function that will generate a message to issue to the topic's ARN. First, press "Create Function" from the AWS Lambda dashboard.

5-1.Lambda関数.png

Confirm that the option is "Create from scratch", enter the following in "Basic information", and press "Create function".

item name Input value / selection value
Function name sendCost (with any name you like)
runtime Python 3.7
Role name SNSServiceRoleForLambda (with any name you like)
Policy template Amazon SNS Issuance Policy

5-2.Lambda関数.png

Lambda function test

Next, we will write the code to acquire the billing information, but to confirm the settings so far, first we will write the process to issue a test message. Enter the following code in the "Function Code" field at the bottom after creating the function.

lambda_function.py


import boto3

def lambda_handler(event, context):
	sns = boto3.client('sns')
	subject = 'The subject of the test email from Lambda.'
	message = 'This is the body of the test email from Lambda.'

	response = sns.publish(
    	TopicArn = 'arn:aws:sns:*:*:*',
    	Subject = subject,
    	Message = message
	)

	return response

And, in reality, it will be executed periodically with a trigger, but I will try to send it manually.

After pressing "Save" at the top right of the screen, press "Test", enter an appropriate name in "Event Name", and press "Create". Others can be left at their default values.

5-3.Lambda関数.png

When you return to the original screen and click "Test" on the upper right again, the function will be executed and the email should have arrived at the specified incoming email address. 5-4.Lambda関数.png

If you do not receive it, check whether an error message is displayed on the console (Execution results) at the bottom of the code entry field, and whether the entered ARN is correct.

Code creation for billing information notification

Finally, I will write the code to get the billing amount from Cost Explorer and notify it on Amazon SNS. For TopicArn, set the ARN that you wrote down when creating the SNS topic as before.

** * As will be described later, an error will occur even if you test without making additional settings! ** **

lambda_function.py


import boto3
from datetime import datetime, timedelta, date

def lambda_handler(event, context):
    ce = boto3.client('ce')
    sns = boto3.client('sns')

    #Get the total bill for this month
    total_billing = get_total_billing(ce)
    #Get the total billing amount for this month (for each service)
    service_billings = get_service_billings(ce)

    #Generate a message to publish to an Amazon SNS topic
    (subject, message) = get_message(total_billing, service_billings)

    response = sns.publish(
        TopicArn = 'arn:aws:sns:*:*:*',
        Subject = subject,
        Message = message
    )

    return response

def get_total_billing(ce):
    (start_date, end_date) = get_total_cost_date_range()

    response = ce.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(ce):
    (start_date, end_date) = get_total_cost_date_range()

    response = ce.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_total_cost_date_range():
    start_date = date.today().replace(day=1).isoformat()
    end_date = date.today().isoformat()

    # get_cost_and_usage()Since the same date cannot be specified for start and end of, if today is 1st, it will be in the range from "1st of last month to 1st of this month (today)"
    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_message(total_billing, service_billings):
    start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%Y/%m/%d')

    #Since the End date is not included in the result, it should be the day before on the display.
    end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
    end_yesterday = (end_today - timedelta(days=1)).strftime('%Y/%m/%d')

    total = round(float(total_billing['billing']), 2)
    subject = f'{start}~{end_yesterday}Billing amount:${total:.2f}'

    message = []
    message.append('[Breakdown]')
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)

        if billing == 0.0:
            #If there is no request, the breakdown will not be displayed
            continue
        message.append(f'・{service_name}: ${billing:.2f}')

    return subject, '\n'.join(message)

I think this is complete, but ** the role assigned to Lamda does not have permission to access Cost Explorer **, so the following error occurs.

"errorMessage": "An error occurred (AccessDeniedException) when calling the GetCostAndUsage operation: User: arn:aws:sts::251745928455:assumed-role/SNSServiceRoleForLambda/sendCost is not authorized to perform: ce:GetCostAndUsage on resource: arn:aws:ce:us-east-1:251745928455:/GetCostAndUsage"

Therefore, on the IAM management screen, attach a policy that can access Cost Explorer to the role.

Create and attach policies

First, display Policy list on IAM management screen and press "Create policy".

6-1.ポリシー作成.png

Enter the following items and press "Confirm Policy".

item name Input value / selection value
service Cost Explorer Service
action Search for "Get Cost And Usage" and check it

6-2ポリシー作成.png

On the policy confirmation screen, enter "Name" and press "Create Policy".

6-3.ポリシー作成.png

Go to the Role List screen (https://console.aws.amazon.com/iam/home#/roles) and select the role you assigned to Lambda.

6-4.ポリシー作成.png

Click "Attach policy".

6-5.ポリシー作成.png

In "Policy Filter", enter the name you set when creating the policy and search ("AmazonCostExplorerGetCostAccess" in the example of this article), check the hits, and press "Attach Policy".

6-6.ポリシー作成.png

Execution of Lambda function

Now that the function can be executed normally, click "Test" at the top right of the setting screen of the created function.

7-1.Lambda関数.png

If everything is set up correctly, you should receive an email like the one below.

7-2.メール.png

Trigger settings

Finally, set a trigger to notify you by email at a fixed time every day. Press "Add Trigger" on the left side of the function setting screen. 8-1.トリガーの設定.png

Set as follows and press "Add".

item name Input value / selection value
Select a trigger CloudWatch Events/EventBridge
rule 新規ruleの作成
Rule name sendDailyCost (appropriately)
Rule type Schedule formula
Schedule formula cron(0 14 ? * * *)
Trigger activation To check

This time I set it at 23:00. Note that the time is set in UTC, so set the time **, which is obtained by subtracting 9 hours from JST (Japan Standard Time) **. 8-2.トリガーの設定.png

After that, please make sure that you receive the email at the specified time every day.

Now you can sleep with peace of mind every day!

Reference information / citation

Recommended Posts

Email daily AWS bills on Lambda x Amazon SNS
Amazon SNS → AWS Lambda → Slack → AWS Chatbot to execute AWS commands
Run Python on Schedule on AWS Lambda
[Python] Run Headless Chrome on AWS Lambda
Periodically run a python program on AWS Lambda
Amazon API Gateway and AWS Lambda Python version