Cut Your AWS Bill: Spot Instances, Savings Plans, and Budget Alerts
Wire up budget and anomaly alerts before you touch anything, then move a stateless ASG to a Spot/On-Demand mix and cover the remaining baseline with a Compute Savings Plan.
What you'll build
You'll set up AWS Budgets threshold alerts and Cost Anomaly Detection wired to SNS, then migrate a stateless Auto Scaling Group from all On-Demand to a Spot/On-Demand mix, and lock in the remaining On-Demand baseline with a Compute Savings Plan. By the end you'll have alerting that fires before a surprise bill shows up, and a workload that costs noticeably less with no change in behavior.
Prerequisites
- AWS CLI v2.x, configured (
aws configure). Confirm withaws sts get-caller-identity. - An existing stateless workload running in an EC2 Auto Scaling Group behind an ALB. This tutorial assumes one already exists (web/API tier that can lose an instance without losing state).
- IAM permissions covering
budgets:*,ce:*,sns:*,ec2:Describe*,ec2:RunInstances,autoscaling:*,savingsplans:*. Scope these down for production; the wildcards here are just to get through the tutorial without permission errors. - AWS Budgets and Cost Explorer only respond on the
us-east-1endpoint, no matter where your resources actually run. Runexport AWS_DEFAULT_REGION=us-east-1before the budget/cecommands, then switch back to your workload's region for the EC2/ASG steps. - When you save any of the JSON snippets below to a file, save them as plain JSON. Don't leave a
// filename.jsoncomment line at the top, JSON has no comment syntax and the CLI'sfile://loader will throw a parse error on it.
Step 1: Create an SNS topic for alerts
Every ARN in this tutorial needs your real AWS account ID, not a placeholder you forgot to swap out. Grab it once and reuse it as a shell variable for the rest of the tutorial:
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo $ACCOUNT_ID
aws sns create-topic --name aws-budget-alerts --region us-east-1
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts \
--protocol email \
--notification-endpoint you@example.com \
--region us-east-1
Confirm the subscription from the email AWS sends you. Skip that and nothing downstream will ever notify you, silently.
Step 2: Let AWS Budgets publish to that topic
AWS Budgets can't push to your SNS topic until you grant it permission explicitly. $ACCOUNT_ID from Step 1 needs to stay set in this shell session, since we're expanding it into the JSON file:
cat > policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AWSBudgetsSNSPublishingPermissions",
"Effect": "Allow",
"Principal": { "Service": "budgets.amazonaws.com" },
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts"
}]
}
EOF
aws sns set-topic-attributes \
--topic-arn arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts \
--attribute-name Policy \
--attribute-value file://policy.json
Step 3: Create a monthly budget with real thresholds
Save this as budget.json. Use FilterExpression, not the older CostFilters field, it's marked deprecated in the current Budgets API reference:
{
"BudgetName": "monthly-ec2-budget",
"BudgetLimit": { "Amount": "500", "Unit": "USD" },
"BudgetType": "COST",
"TimeUnit": "MONTHLY",
"FilterExpression": {
"Dimensions": {
"Key": "SERVICE",
"Values": ["Amazon Elastic Compute Cloud - Compute"],
"MatchOptions": ["EQUALS"]
}
}
}
This one has no account ID in it, so it's safe to save as a plain static file. The notifications file does need your account ID, so build it the same way as Step 2:
cat > notifications.json <<EOF
[
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 80,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS", "Address": "arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts" }]
},
{
"Notification": {
"NotificationType": "FORECASTED",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 100,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS", "Address": "arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts" }]
}
]
EOF
aws budgets create-budget \
--account-id "$ACCOUNT_ID" \
--budget file://budget.json \
--notifications-with-subscribers file://notifications.json
This gives you an "actual spend crossed 80%" alert and a "you're forecasted to blow the whole budget" alert. Adjust the amount and service filter to match what you're actually trying to control.
Step 4: Add anomaly detection for spend spikes
Budgets are threshold-based; they won't catch a weird spike that's still under 80% of your monthly cap. Cost Anomaly Detection watches for unusual deviations instead.
aws ce create-anomaly-monitor --anomaly-monitor '{
"MonitorName": "ec2-spend-monitor",
"MonitorType": "DIMENSIONAL",
"MonitorDimension": "SERVICE"
}'
Grab the MonitorArn from the response and drop it into the subscription file below, alongside your account ID again:
cat > anomaly-subscription.json <<EOF
{
"SubscriptionName": "ec2-anomaly-alerts",
"MonitorArnList": ["<monitor-arn-from-above>"],
"Frequency": "DAILY",
"Subscribers": [{ "Type": "SNS", "Address": "arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alerts" }],
"ThresholdExpression": {
"Dimensions": {
"Key": "ANOMALY_TOTAL_IMPACT_ABSOLUTE",
"MatchOptions": ["GREATER_THAN_OR_EQUAL"],
"Values": ["50"]
}
}
}
EOF
aws ce create-anomaly-subscription --anomaly-subscription file://anomaly-subscription.json
Alerting is done. Now the actual cost cut.
Step 5: Convert the ASG to a Spot/On-Demand mix
Find your existing launch template ID first:
aws ec2 describe-launch-templates \
--query "LaunchTemplates[].[LaunchTemplateName,LaunchTemplateId]" \
--output table
Set it as a variable, then build the mixed instances policy file. Diversify across a few instance families so the Spot allocator has options if one type is capacity-constrained:
export LAUNCH_TEMPLATE_ID=lt-xxxxxxxxxxxxxxxxx # replace with the ID from the command above
cat > mixed-instances-policy.json <<EOF
{
"LaunchTemplate": {
"LaunchTemplateSpecification": {
"LaunchTemplateId": "${LAUNCH_TEMPLATE_ID}",
"Version": "\$Latest"
},
"Overrides": [
{ "InstanceType": "m5.large" },
{ "InstanceType": "m5a.large" },
{ "InstanceType": "m6i.large" },
{ "InstanceType": "m6a.large" }
]
},
"InstancesDistribution": {
"OnDemandBaseCapacity": 2,
"OnDemandPercentageAboveBaseCapacity": 20,
"SpotAllocationStrategy": "price-capacity-optimized"
}
}
EOF
Note the \$Latest above, that backslash keeps the heredoc from trying to expand a shell variable called $Latest (which doesn't exist) while still expanding ${LAUNCH_TEMPLATE_ID} correctly.
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name web-tier-asg \
--mixed-instances-policy file://mixed-instances-policy.json \
--capacity-rebalance
OnDemandBaseCapacity: 2 keeps two guaranteed instances always on-demand for your floor traffic. Everything above that runs 80% Spot, 20% On-Demand. price-capacity-optimized is AWS's current recommended default allocation strategy: like the older capacity-optimized it favors the Spot pools with the deepest available capacity (fewer interruptions), but it also factors in price so you don't get stuck parked on a pool that's stable but needlessly expensive. If you want pure stability with zero price weighting, plain capacity-optimized is still supported. --capacity-rebalance makes the ASG proactively replace a Spot instance when AWS predicts it's at elevated interruption risk, before it actually gets reclaimed.
Step 6: Make sure the workload actually tolerates interruption
Spot instances get a two-minute interruption notice. Most current AMIs require IMDSv2, so checking for it from inside the instance means fetching a token first, a bare curl against the plain metadata path will just hang or get rejected:
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/spot/instance-action
Don't panic if this returns a 404. That's the expected response when the instance is running normally and isn't marked for interruption, the endpoint simply doesn't exist yet. You'll only get a 200 OK with a JSON body (containing an action of terminate, stop, or hibernate and a time) once AWS has actually issued the two-minute warning. If you want to test the drain logic without waiting for a real interruption, use the EC2 Fault Injection Simulator's Spot interruption action instead of hoping one happens naturally.
If your app needs longer than two minutes to drain connections cleanly, fix that before relying on Spot for anything user-facing. Bump the ASG's health check grace period if instances get killed for failing health checks before the app finishes starting:
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name web-tier-asg \
--health-check-grace-period 120
Step 7: Cover the On-Demand baseline with a Compute Savings Plan
Important nuance: Savings Plans discount On-Demand usage, not Spot usage. There's no stacking discount on top of Spot. What you're covering here is the OnDemandBaseCapacity floor from Step 5, plus any other steady On-Demand usage across your account (EC2, Fargate, and Lambda all count toward a Compute Savings Plan).
| Option | Typical discount | Commitment | Covers |
|---|---|---|---|
| Spot Instances | up to 90% | none, can be reclaimed | interruption-tolerant compute |
| Compute Savings Plan | up to ~66% | 1 or 3 yr, $/hr | EC2, Fargate, Lambda On-Demand |
| EC2 Reserved Instances | up to ~72% | 1 or 3 yr, family/region locked | EC2 only |
Check AWS's own recommendation before committing to anything:
aws ce get-savings-plans-purchase-recommendation \
--savings-plans-type COMPUTE_SP \
--term-in-years ONE_YEAR \
--payment-option NO_UPFRONT \
--lookback-period-in-days SIXTY_DAYS
The easiest path is the Cost Explorer console's Savings Plans recommendation page, it has a "Purchase recommendations" view pre-filled with a commitment amount. To do it via CLI instead:
aws savingsplans describe-savings-plans-offerings \
--plan-types Compute \
--payment-options "No Upfront" \
--durations 31536000 \
--currencies USD
aws savingsplans create-savings-plan \
--savings-plan-offering-id <offering-id-from-previous-command> \
--commitment "0.25"
--commitment is your hourly dollar commitment, e.g. "0.25" for $0.25/hour (~$180/mo). Match it to what the recommendation showed for your baseline usage, don't guess.
Verify it works
- Alerts:
aws budgets describe-budget --account-id $ACCOUNT_ID --budget-name monthly-ec2-budgetshould return your budget with the notifications attached. You'll get an SNS email/notification once spend crosses 80%. - Spot mix:
aws ec2 describe-instances --filters "Name=instance-lifecycle,Values=spot" "Name=tag:aws:autoscaling:groupName,Values=web-tier-asg" --query "Reservations[].Instances[].[InstanceId,InstanceType,State.Name]" --output tableshould list running Spot instances. - Savings Plan:
aws savingsplans describe-savings-plans --query "savingsPlans[].[savingsPlanId,state,commitment]" --output tableshould showstate: active. - Give it 24-48 hours, then check Cost Explorer with "Group by: Purchase option" enabled. You should see a visible Spot slice and a Savings Plan coverage percentage on your EC2 spend.
Troubleshooting
- Budget alerts never fire. The SNS subscription is almost certainly unconfirmed. Check
aws sns list-subscriptions-by-topic --topic-arn arn:aws:sns:us-east-1:${ACCOUNT_ID}:aws-budget-alertsforPendingConfirmation. - ASG can't launch Spot instances / stuck at desired capacity. Usually insufficient capacity in the requested Availability Zone or instance type. Add more instance types to
Overridesand make sure your ASG spans at least 3 AZs. - Spot instances get terminated right after launch and the ASG keeps cycling. Health check grace period is too short for your app's startup time; bump
--health-check-grace-period. Also check the ALB target group's deregistration delay isn't dropping in-flight requests during termination. get-savings-plans-purchase-recommendationreturns no useful recommendation. You need meaningful On-Demand usage history; aSIXTY_DAYSlookback on a brand-new account or workload won't produce a solid number. Wait for more usage history or useTHIRTY_DAYSas a rougher estimate.
Next steps
Look at AWS Compute Optimizer for right-sizing recommendations on the On-Demand baseline itself, there's no point committing a Savings Plan to instances that are oversized. If your workload runs on Fargate or Lambda too, the same Compute Savings Plan applies there without any extra setup. And once Spot is stable in one ASG, look at Karpenter (if you're on EKS) or EC2 Fleet for more granular, multi-strategy capacity management.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.