How to create AWS billing alerts using Lambda, Cost Explorer & SES
Introduction
Amazon provides the ability to create Billing Alarms that can be used to alert whenever your AWS bill exceeds a certain threshold. However, this approach has a few shortcomings:
-
You need to set a predefined threshold. When you are first starting to use AWS, it’s hard to know what your AWS bill will look like. A lot of people set this threshold pretty low to be safe.
-
An alert like this tends to be reactive instead of proactive. The alarm gets triggered once the threshold has already crossed. For example, I had forgotten to turn off an EC2 instance I was no longer using but I only found out about a week later once my billing threshold crossed the limit.
Cost Explorer does provide an easy-to-use interface that can help keep you on top of your billing data. However, this requires one to use the tool regularly to ensure we don’t miss anything.
In this article, we will look at how to build a simple pipeline to send us billing reports over email. The generated report will look like this:
The tools we will use are:
- AWS Lambda
- Simple Email Service
- Cost Explorer API
Getting Started
The source code for the project is available at this repository.
Project Setup
Create a new directory for the application
mkdir serverless-cost-alerts
cd serverless-cost-alerts
Setup a virtualenv for the application
virtualenv env
source env/bin/activate
Create requirements.txt with all the dependencies
boto3
Install the dependencies
Run the following command in the shell to install all the dependencies.
pip install -r requirements.txt
Create scaffolding
We will create a new folder called app
which will store the application logic for the Cost Explorer API as well as using SES.
mkdir app
touch app/__init__.py app/cost_explorer.py app/email.py
We will also create a file called handler.py
in the root directory. This file will contain the logic used to generate billing reports.
touch handler.py
Cost Explorer API
We will be using the Cost And Usage endpoint from Cost Explorer to get the billing data we need.
Replace the contents of the cost_explorer.py
file we created earlier with this:
import boto3
from datetime import date, datetime, timedelta
from dateutil.relativedelta import relativedelta
class CostExplorer:
TODAY_DATE = datetime.utcnow().date()
CUR_MONTH_DATE = TODAY_DATE.replace(day=1)
PREV_MONTH_DATE: date = CUR_MONTH_DATE - relativedelta(months=+1)
def __init__(self):
self.client = boto3.client("ce")
self.metrics = ["UNBLENDED_COST"]
self.currency:str = "USD"
self.daily_report_kwargs = {
"TimePeriod": self._get_timeperiod(
start=self.TODAY_DATE - timedelta(days=2), # start_dt is inclusive
end=self.TODAY_DATE, # end_dt is exclusive
),
"Metrics": self.metrics,
"Granularity": "DAILY"
}
self.monthly_report_kwargs = {
"TimePeriod": self._get_timeperiod(
start=self.PREV_MONTH_DATE, # start_dt is inclusive
end=self.TODAY_DATE, # end_dt is exclusive
),
"Metrics": self.metrics,
"Granularity": "MONTHLY"
}
def _get_timeperiod(self, start: date, end: date):
return {
"Start": start.isoformat(),
"End": end.isoformat(),
}
def _get_data(self, results):
"""
Retrieves the individual billing rows from cost explorer data.
"""
rows = []
for v in results:
row = {"date":v["TimePeriod"]["Start"]}
for i in v["Groups"]:
key = i["Keys"][0]
row.update({key:float(i["Metrics"]["UnblendedCost"]["Amount"])})
row.update({"Total":float(v["Total"]["UnblendedCost"]["Amount"])})
rows.append(row)
return [f"{row['date']}: {round(row['Total'], 2)}" for row in rows]
def generate_report(self, report_kwargs):
"""
Get cost data based on the granularity, start date and end date.
"""
response = self.client.get_cost_and_usage(**report_kwargs)
return "\n".join(self._get_data(response["ResultsByTime"]))
Key Points
-
We want to generate two reports, one for the last 2 days (daily granularity) and one to compare the spend last month vs the current month (monthly granularity).
-
We used
Unblended costs
to represent our billing data. More information about this can be found here.
Simple Email Service (SES)
We will be using SES to send the billing reports to ourselves via email.
Prerequisites
SES requires a verified email address before it can be used. Verification can be done via the AWS console.
Using SES
Replace the contents of email.py
with the code snippet below:
import boto3
class EmailClient:
SENDER = "AWS Cost Alert <[email protected]>"
SUBJECT = "Daily AWS Billing Report"
# The email body for recipients with non-HTML email clients.
BODY_TEXT = """AWS Billing Alerts \r\n
Daily billing report\r\n
{daily_billing_report}\r\n
Monthly billing report\r\n
{monthly_billing_report}\r\n
"""
# The HTML body of the email.
BODY_HTML = """<html>
<head></head>
<body>
<h1>AWS Billing Alert</h1>
<hr/>
<h3>Daily Billing</h3>
<p>{daily_billing_report}</p>
<hr/>
<h3>Monthly Billing</h3>
<p>{monthly_billing_report}</p>
</body>
</html>
"""
# The character encoding for the email.
CHARSET = "UTF-8"
# recipient email address
RECIPIENT = "[email protected]"
def __init__(self):
self.client = boto3.client("ses")
def send(self, daily_billing_report, monthly_billing_report):
"""Send an email which contains AWS billing data"""
email_text = self.BODY_TEXT.format(
daily_billing_report=daily_billing_report,
monthly_billing_report=monthly_billing_report
)
email_html = self.BODY_HTML.format(
daily_billing_report=daily_billing_report,
monthly_billing_report=monthly_billing_report
)
response = self.client.send_email(
Destination={
"ToAddresses": [
self.RECIPIENT,
],
},
Message={
"Body": {
"Html": {
"Charset": self.CHARSET,
"Data": email_html
},
"Text": {
"Charset": self.CHARSET,
"Data": email_text,
},
},
"Subject": {
"Charset": self.CHARSET,
"Data": self.SUBJECT,
},
},
Source=self.SENDER,
)
Key points:
- Replace
SENDER
andRECEPIENT
with the email address you verified with SES.
Generating a billing report
We will now generate a report using Cost Explorer and SES. Replace the contents of handler.py
with the code snippet below:
from app.cost_explorer import CostExplorer
from app.email import EmailClient
def main():
ce = CostExplorer()
daily_report = ce.generate_report(ce.daily_report_kwargs)
monthly_report = ce.generate_report(ce.monthly_report_kwargs)
email_client = EmailClient()
email_client.send(
daily_billing_report=daily_report,
monthly_billing_report=monthly_report
)
if __name__ == "__main__":
main()
Next, run the script from your terminal using the following command:
python handler.py
If your AWS profile is setup with the appropriate permissions, you should receive an email with your billing data. This what the email should look like:
Integrating AWS Lambda
Now that we have generated the billing report locally, we will make use of the Serverless Framework to create a serverless pipeline using AWS Lambda.
Prerequisites
-
Setup serverless framework by following the instructions listed here.
-
Run the following commands after installing the serverless framework.
npm init -f npm install --save-dev serverless-python-requirements
The serverless-python-requirements
plugin automatically bundles dependencies from requirements.txt
and makes them available to your Lambda function.
Setting up the serverless project
Next, create the file serverless.yml
in your root directory and copy the following content:
service: serverless-cost-alerts
plugins:
- serverless-python-requirements
package:
excludeDevDependencies: true
exclude:
- node_modules/**
custom:
pythonRequirements:
slim: true
strip: false
slimPatternsAppendDefaults: true
slimPatterns:
- "**/*.egg-info*"
- "**/*.dist-info*"
dockerizePip: true
provider:
name: aws
runtime: python3.7
stage: dev
region: us-west-2
iamRoleStatements:
- Effect: Allow
Action:
- ses:SendEmail
Resource:
- "*"
- Effect: Allow
Action:
- ce:GetCostAndUsage
Resource:
- "*"
functions:
send_daily_cost_report:
handler: handler.generate_report
events:
- http: GET hello
Key points
- The lambda function will have the permission to query the
CostAndUsage
function in Cost Explorer andSendEmail
in SES. - To test our Lambda function, we will create a temporary HTTP endpoint called
hello
.
Lambda Handler
Before we deploy the Lambda function to AWS, we need to update the Lambda function so that it can respond to an HTTP request. Update the contents of your handler.py
with the following code snippet:
from app.cost_explorer import CostExplorer
from app.email import EmailClient
def generate_report(event, context):
ce = CostExplorer()
daily_report = ce.generate_report(ce.daily_report_kwargs)
monthly_report = ce.generate_report(ce.monthly_report_kwargs)
email_client = EmailClient()
email_client.send(
daily_billing_report=daily_report,
monthly_billing_report=monthly_report
)
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
},
"body": "success",
}
Now we will deploy the Lambda function to AWS. Run the following command in your terminal:
sls deploy
If the deploy is successful, you should be able to see the HTTP endpoint for your Lambda like this:
If you click on the HTTP endpoint, you should see output like this:
Scheduled Lambda
Lastly, we will update our Lambda function to run as a scheduled cron instead of an HTTP endpoint. We will setup our Lambda function to run once a day.
Update the functions
section of serverless.yml
:
functions:
send_daily_cost_report:
# handler: handler.generate_report
# events:
# - http: GET hello
handler: handler.generate_report
events:
- schedule: cron(0 9 * * ? *)
We have now scheduled our Lambda function to run every day at 9 am.
Conclusion
I have been using this setup for the last few weeks and it has been useful in keeping on top of my AWS billing without having to worry about checking the console frequently. The cost explorer API has a lot of different reporting options and you can generate a report that works for you.