AWS Cost Optimization Toolkit - Practical Strategies for Production Workloads
The order that makes AWS cost work stick: visibility with Cost Explorer and Budgets, right-sizing with Compute Optimizer, then commitments.
AWS cost work has a default order: build visibility, then right-size, then commit. Cost Explorer and Budgets show where the money goes, Compute Optimizer flags the capacity nobody uses, and Savings Plans or Reserved Instances discount whatever survives those two steps. Teams that reverse the order buy a discounted rate for capacity they should have deleted.
The symptoms that send teams looking are usually the same across production accounts: monthly bills that move without a matching traffic change, development resources running 24/7 when they are needed only during working hours, and instances billed for full capacity while their CPUs sit idle.
Five Recurring Cost Drivers
AWS ships capable native cost tooling, so a missing feature is rarely the blocker. The harder question is which tool answers which symptom, and in what sequence.
Five patterns show up in almost every production account:
- Cost unpredictability: Monthly bills varying significantly without corresponding business growth
- Idle resource waste: Non-production resources burning budget outside business hours
- Over-provisioned instances: Paying for capacity that’s rarely utilized
- Commitment paralysis: Difficulty choosing between Reserved Instances, Savings Plans, or Spot Instances
- Lack of attribution: Unable to track which projects or teams drive AWS spending
Foundation: Cost Explorer and AWS Budgets
Before optimizing costs, you need visibility. Cost Explorer and AWS Budgets provide the foundation for understanding where money goes and catching anomalies early.
Cost Explorer Deep Dive
Cost Explorer offers 12-month historical data and up to 12 months forecasting. Here’s a practical implementation for analyzing cost trends and identifying anomalies:
import boto3
from datetime import datetime, timedelta
ce_client = boto3.client('ce', region_name='us-east-1')
def analyze_cost_trends(months_back=3):
"""
Analyze cost trends across services and identify anomalies
Returns services with >20% cost change month-over-month
"""
end_date = datetime.now().date()
start_date = (datetime.now() - timedelta(days=30 * months_back)).date()
response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date.strftime('%Y-%m-%d'),
'End': end_date.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost'],
GroupBy=[
{'Type': 'DIMENSION', 'Key': 'SERVICE'},
]
)
# Process results
cost_by_service = {}
for result in response['ResultsByTime']:
period = result['TimePeriod']['Start']
for group in result['Groups']:
service = group['Keys'][0]
cost = float(group['Metrics']['UnblendedCost']['Amount'])
if service not in cost_by_service:
cost_by_service[service] = []
cost_by_service[service].append({
'period': period,
'cost': cost
})
# Identify services with >20% cost increase
trending_services = []
for service, costs in cost_by_service.items():
if len(costs) >= 2:
recent_cost = costs[-1]['cost']
previous_cost = costs[-2]['cost']
if previous_cost > 0:
change_pct = ((recent_cost - previous_cost) / previous_cost) * 100
if abs(change_pct) > 20:
trending_services.append({
'service': service,
'change': change_pct,
'current_cost': recent_cost,
'previous_cost': previous_cost
})
return sorted(trending_services, key=lambda x: abs(x['change']), reverse=True)
This script identifies cost anomalies across AWS services. Running it weekly catches issues like misconfigured Auto Scaling groups or forgotten test resources before they accumulate significant costs.
Identifying Cost Allocation Gaps
The cheapest optimization is often finding out what nobody is tracking. Untagged spend hides inside the total, and no tag-filtered dashboard will surface it for you:
def analyze_cost_allocation_gaps():
"""
Identify costs that aren't properly tagged for cost allocation
"""
end_date = datetime.now().date()
start_date = (datetime.now() - timedelta(days=30)).date()
# Check costs by tag
tagged_response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date.strftime('%Y-%m-%d'),
'End': end_date.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost'],
GroupBy=[
{'Type': 'TAG', 'Key': 'Project'},
]
)
total_tagged_cost = 0
for result in tagged_response['ResultsByTime']:
for group in result['Groups']:
if group['Keys'][0]: # Has project tag
total_tagged_cost += float(group['Metrics']['UnblendedCost']['Amount'])
# Get total cost
total_response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date.strftime('%Y-%m-%d'),
'End': end_date.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost']
)
total_cost = float(total_response['ResultsByTime'][0]['Total']['UnblendedCost']['Amount'])
untagged_cost = total_cost - total_tagged_cost
untagged_percentage = (untagged_cost / total_cost) * 100
return {
'total_cost': total_cost,
'tagged_cost': total_tagged_cost,
'untagged_cost': untagged_cost,
'untagged_percentage': untagged_percentage
}
Activate cost allocation tags in the Billing Console before you rely on them. Tags only track costs after activation; there is no retroactive tagging capability.
AWS Budgets with Automated Actions
Budgets provide proactive cost monitoring. Here’s a production-ready implementation with multiple alert thresholds:
import boto3
budgets_client = boto3.client('budgets')
def create_department_budget_with_alerts(
account_id: str,
department: str,
monthly_limit: float
):
"""
Create budget with multiple alert thresholds and SNS notifications
70% = Info, 90% = Warning, 100% = Critical, Forecasted = Predictive
"""
budget_name = f"{department}-monthly-budget"
budgets_client.create_budget(
AccountId=account_id,
Budget={
'BudgetName': budget_name,
'BudgetLimit': {
'Amount': str(monthly_limit),
'Unit': 'USD'
},
'TimeUnit': 'MONTHLY',
'BudgetType': 'COST',
'CostFilters': {
'TagKeyValue': [f'user:Department${department}']
}
},
NotificationsWithSubscribers=[
# 70% threshold - Info alert
{
'Notification': {
'NotificationType': 'ACTUAL',
'ComparisonOperator': 'GREATER_THAN',
'Threshold': 70.0,
'ThresholdType': 'PERCENTAGE',
'NotificationState': 'ALARM'
},
'Subscribers': [
{
'SubscriptionType': 'SNS',
'Address': f'arn:aws:sns:us-east-1:{account_id}:budget-alerts-info'
}
]
},
# 90% threshold - Warning alert
{
'Notification': {
'NotificationType': 'ACTUAL',
'ComparisonOperator': 'GREATER_THAN',
'Threshold': 90.0,
'ThresholdType': 'PERCENTAGE',
'NotificationState': 'ALARM'
},
'Subscribers': [
{
'SubscriptionType': 'SNS',
'Address': f'arn:aws:sns:us-east-1:{account_id}:budget-alerts-warning'
}
]
},
# 100% threshold - Critical alert
{
'Notification': {
'NotificationType': 'ACTUAL',
'ComparisonOperator': 'GREATER_THAN',
'Threshold': 100.0,
'ThresholdType': 'PERCENTAGE',
'NotificationState': 'ALARM'
},
'Subscribers': [
{
'SubscriptionType': 'SNS',
'Address': f'arn:aws:sns:us-east-1:{account_id}:budget-alerts-critical'
},
{
'SubscriptionType': 'EMAIL',
'Address': f'{department}[email protected]'
}
]
},
# Forecasted to exceed - Predictive alert
{
'Notification': {
'NotificationType': 'FORECASTED',
'ComparisonOperator': 'GREATER_THAN',
'Threshold': 100.0,
'ThresholdType': 'PERCENTAGE',
'NotificationState': 'ALARM'
},
'Subscribers': [
{
'SubscriptionType': 'SNS',
'Address': f'arn:aws:sns:us-east-1:{account_id}:budget-forecast-alerts'
}
]
}
]
)
print(f"Created budget {budget_name} with ${monthly_limit} monthly limit")
Budget thresholds are evaluated roughly three times a day, which catches an anomaly well before a daily email digest would. FORECASTED alerts use AWS’s prediction model and need at least 5 weeks of historical data before they produce anything.
Common pitfalls to avoid:
- Creating too many budgets causes alert fatigue; focus on key cost centers
- Using FORECASTED alerts without understanding they need 5+ weeks of historical data
- Not activating cost allocation tags before filtering budgets by tags
- Ignoring untagged resource costs; they stay invisible in every tag-filtered report
Right-Sizing with Compute Optimizer
AWS Compute Optimizer uses machine learning to analyze CloudWatch metrics and recommend optimal instance types, Lambda memory configurations, and EBS volumes. This typically delivers 20-40% cost savings with low implementation risk.
Automated Right-Sizing Implementation
import boto3
from typing import List, Dict
from dataclasses import dataclass
compute_optimizer = boto3.client('compute-optimizer')
ec2_client = boto3.client('ec2')
@dataclass
class RightsizingRecommendation:
instance_id: str
current_type: str
recommended_type: str
current_cost_monthly: float
recommended_cost_monthly: float
savings_monthly: float
cpu_utilization_avg: float
memory_utilization_avg: float
def get_underutilized_instances(
max_cpu_threshold: float = 40.0,
lookback_days: int = 14
) -> List[RightsizingRecommendation]:
"""
Fetch Compute Optimizer recommendations for underutilized instances
Default: instances averaging <40% CPU over 14 days
"""
recommendations = []
# Get EC2 instance recommendations
paginator = compute_optimizer.get_paginator('get_ec2_instance_recommendations')
for page in paginator.paginate():
for rec in page.get('instanceRecommendations', []):
instance_id = rec['instanceArn'].split('/')[-1]
current_instance_type = rec['currentInstanceType']
# Get utilization metrics
cpu_util = next(
(m['value'] for m in rec.get('utilizationMetrics', [])
if m['name'] == 'CPU'),
0.0
)
memory_util = next(
(m['value'] for m in rec.get('utilizationMetrics', [])
if m['name'] == 'MEMORY'),
0.0
)
# Check if instance is underutilized
if cpu_util < max_cpu_threshold:
# Get best recommendation option
if rec.get('recommendationOptions'):
best_option = rec['recommendationOptions'][0]
# Calculate savings
estimated_savings = best_option.get('estimatedMonthlySavings', {}).get('value', 0)
recommendations.append(RightsizingRecommendation(
instance_id=instance_id,
current_type=current_instance_type,
recommended_type=best_option['instanceType'],
current_cost_monthly=estimated_savings,
recommended_cost_monthly=0, # Would calculate from pricing API
savings_monthly=estimated_savings,
cpu_utilization_avg=cpu_util,
memory_utilization_avg=memory_util
))
# Sort by potential savings (highest first)
return sorted(recommendations, key=lambda x: x.savings_monthly, reverse=True)
Applying Rightsizing Recommendations
Here’s a cautious approach to applying recommendations; instances must be stopped first:
def apply_rightsizing_recommendation(
instance_id: str,
new_instance_type: str,
dry_run: bool = True
) -> Dict:
"""
Apply right-sizing recommendation by modifying instance type
Requires instance to be stopped first
"""
try:
# Get current instance state
response = ec2_client.describe_instances(InstanceIds=[instance_id])
instance_state = response['Reservations'][0]['Instances'][0]['State']['Name']
if instance_state != 'stopped':
return {
'success': False,
'message': f'Instance must be stopped. Current state: {instance_state}'
}
# Modify instance type
ec2_client.modify_instance_attribute(
InstanceId=instance_id,
InstanceType={'Value': new_instance_type},
DryRun=dry_run
)
return {
'success': True,
'message': f'Successfully modified {instance_id} to {new_instance_type}',
'instance_id': instance_id,
'new_type': new_instance_type
}
except Exception as e:
return {
'success': False,
'message': f'Failed to modify instance: {str(e)}'
}
Rolling these out safely matters more than rolling them out fast. Test in development first, then apply to 10% of production instances, monitor for a week, and widen from there.
Lambda Memory Optimization
Compute Optimizer also analyzes Lambda functions. Here’s how to get Lambda-specific recommendations:
def get_lambda_optimization_recommendations():
"""
Get Lambda memory configuration recommendations
Returns functions where memory can be reduced without performance impact
"""
paginator = compute_optimizer.get_paginator('get_lambda_function_recommendations')
recommendations = []
for page in paginator.paginate():
for rec in page.get('lambdaFunctionRecommendations', []):
function_arn = rec['functionArn']
current_memory = rec['currentMemorySize']
if rec.get('recommendationOptions'):
best_option = rec['recommendationOptions'][0]
recommended_memory = best_option['memorySize']
# Only include if recommendation differs from current
if recommended_memory != current_memory:
estimated_savings = best_option.get('estimatedMonthlySavings', {})
recommendations.append({
'function_arn': function_arn,
'function_name': function_arn.split(':')[-1],
'current_memory_mb': current_memory,
'recommended_memory_mb': recommended_memory,
'savings_monthly': estimated_savings.get('value', 0),
'savings_currency': estimated_savings.get('currency', 'USD')
})
return sorted(recommendations, key=lambda x: x['savings_monthly'], reverse=True)
Compute Optimizer analyzes CloudWatch metrics from the last 14 days by default. For production workloads with monthly usage patterns, enable enhanced infrastructure metrics ($0.0003360215 per resource per hour, adds ~$0.25/month per resource) to get a 93-day lookback period.
Common pitfalls with Compute Optimizer:
- Applying recommendations during business hours without a maintenance window
- Not testing recommended instance types for compatibility (some types don’t support all features)
- Ignoring “Under-provisioned” warnings; cost savings shouldn’t compromise performance
- Not enabling enhanced metrics for production workloads; 14 days may miss monthly spikes
Commitment Strategy: Savings Plans vs Reserved Instances
Choosing between Reserved Instances, Savings Plans, or staying on-demand requires understanding workload characteristics. Here’s a decision framework:
Recommendations Engine
Here’s how to programmatically get AWS’s commitment recommendations:
import boto3
from typing import List, Dict
from dataclasses import dataclass
ce_client = boto3.client('ce')
@dataclass
class CommitmentRecommendation:
recommendation_type: str # 'RI' or 'SavingsPlan'
service: str
term: str # '1_YEAR' or '3_YEAR'
payment_option: str
monthly_commitment: float
estimated_savings: float
estimated_savings_percentage: float
def get_savings_plan_recommendations(
term_years: int = 1,
payment_option: str = 'NO_UPFRONT'
) -> List[CommitmentRecommendation]:
"""
Get Savings Plans purchase recommendations
Based on last 30 days of usage patterns
"""
response = ce_client.get_savings_plans_purchase_recommendation(
SavingsPlansType='COMPUTE_SP', # or 'EC2_INSTANCE_SP'
TermInYears=f'{term_years}_YEAR',
PaymentOption=payment_option,
LookbackPeriodInDays='THIRTY_DAYS',
AccountScope='PAYER'
)
recommendations = []
for rec in response.get('SavingsPlansPurchaseRecommendation', {}).get('SavingsPlansPurchaseRecommendationDetails', []):
savings_details = rec.get('SavingsPlansDetails', {})
recommendations.append(CommitmentRecommendation(
recommendation_type='SavingsPlan',
service='Compute',
term=f'{term_years}_YEAR',
payment_option=payment_option,
monthly_commitment=float(rec.get('HourlyCommitmentToPurchase', 0)) * 730,
estimated_savings=float(rec.get('EstimatedMonthlySavingsAmount', 0)),
estimated_savings_percentage=float(rec.get('EstimatedSavingsPercentage', 0))
))
return recommendations
Comparison Framework
For workloads with varying stability, here’s a comparison engine:
def compare_commitment_options(
monthly_spend: float,
workload_stability: str # 'stable', 'variable', 'mixed'
) -> Dict:
"""
Compare commitment strategies based on workload characteristics
"""
if workload_stability == 'stable':
# Reserved Instances for predictable workloads
return {
'recommendation': 'Reserved Instances',
'reason': 'Highest discount for stable, predictable workloads',
'expected_savings': monthly_spend * 0.72, # Up to 72% with a 3-year RI
'flexibility': 'Low - locked to instance family and region',
'best_for': 'Production databases, always-on services'
}
elif workload_stability == 'variable':
# Compute Savings Plans for flexibility
return {
'recommendation': 'Compute Savings Plans',
'reason': 'Flexibility across instance families, regions, and compute services',
'expected_savings': monthly_spend * 0.66, # Up to 66% savings
'flexibility': 'High - applies to EC2, Fargate, Lambda',
'best_for': 'Multi-service architectures, evolving workloads'
}
else: # mixed
# Hybrid approach
stable_portion = monthly_spend * 0.6 # 60% stable baseline
variable_portion = monthly_spend * 0.4 # 40% variable
return {
'recommendation': 'Hybrid Strategy',
'reason': 'Combine RIs for baseline, Savings Plans for flexibility',
'breakdown': {
'reserved_instances': {
'monthly_commitment': stable_portion,
'savings': stable_portion * 0.72
},
'savings_plans': {
'monthly_commitment': variable_portion,
'savings': variable_portion * 0.66
}
},
'expected_total_savings': (stable_portion * 0.72) + (variable_portion * 0.66),
'flexibility': 'Balanced - optimized for both scenarios'
}
How the three commitment types differ:
- Reserved Instances: Up to 72% savings, but locked to specific instance family and region. Can sell on RI Marketplace if needs change.
- Compute Savings Plans: Up to 66% savings, applies across EC2, Fargate, and Lambda in any region or instance family. Maximum flexibility.
- EC2 Instance Savings Plans: Up to 72% savings, flexible within instance family and region. Middle ground between RIs and Compute SPs.
- Payment options: All Upfront (highest discount), Partial Upfront (balanced), No Upfront (lowest discount but no capital commitment)
Savings Plans are no longer strictly one-way. AWS offers a 7-day return window with restrictions (hourly commitment $100 or less, returns within the same calendar month, maximum 10 returns per year), which takes some of the fear out of a first purchase.
Common pitfalls with commitments:
- Over-committing based on peak usage instead of baseline; results in unused commitments
- Choosing 3-year terms without considering technology evolution; instance types improve rapidly
- Not monitoring RI/SP utilization after purchase; underutilized commitments waste money
- Mixing RIs and SPs without clear strategy; can lead to coverage gaps or overlaps
A conservative ramp keeps the mistakes cheap: cover 40% of baseline usage in month 1, increase to 60% if utilization exceeds 95%, and target 70-80% coverage long-term. The remaining 20-30% stays on-demand for flexibility and growth.
Spot Instances for Batch Workloads
Spot Instances offer 70-90% cost savings but require interruption-resilient architecture. Here’s when and how to use them effectively:
Spot Fleet with Diversification
The key to Spot Instance resilience is diversification across instance types and availability zones:
import boto3
from typing import List, Dict
ec2_client = boto3.client('ec2')
autoscaling_client = boto3.client('autoscaling')
def create_diversified_spot_fleet(
target_capacity: int,
instance_types: List[str],
subnets: List[str],
user_data_script: str
) -> str:
"""
Create EC2 Auto Scaling group with diversified Spot instances
Uses capacity-optimized allocation strategy to minimize interruptions
"""
# Launch template configuration
launch_template_overrides = []
for instance_type in instance_types:
for subnet in subnets:
launch_template_overrides.append({
'InstanceType': instance_type,
'SubnetId': subnet,
'WeightedCapacity': 1.0
})
# Create Auto Scaling group with mixed instances policy
asg_config = {
'AutoScalingGroupName': 'spot-optimized-asg',
'MinSize': target_capacity,
'MaxSize': target_capacity * 2,
'DesiredCapacity': target_capacity,
'VPCZoneIdentifier': ','.join(subnets),
'MixedInstancesPolicy': {
'InstancesDistribution': {
'OnDemandBaseCapacity': 0, # All Spot instances
'OnDemandPercentageAboveBaseCapacity': 0,
'SpotAllocationStrategy': 'capacity-optimized',
'SpotInstancePools': len(instance_types) * len(subnets)
},
'LaunchTemplate': {
'LaunchTemplateSpecification': {
'LaunchTemplateName': 'spot-fleet-template',
'Version': '$Latest'
},
'Overrides': launch_template_overrides
}
},
'Tags': [
{
'Key': 'CostOptimization',
'Value': 'SpotInstances',
'PropagateAtLaunch': True
}
]
}
response = autoscaling_client.create_auto_scaling_group(**asg_config)
return asg_config['AutoScalingGroupName']
Diversification is the whole game here. Use the capacity-optimized allocation strategy and spread across 10+ instance types and 3+ availability zones. A fleet pinned to a single instance type competes for a single pool, so it gets reclaimed every time that pool tightens.
Interruption Handling
Spot Instances provide a 2-minute warning via EventBridge before termination. Here’s a Lambda function to handle graceful shutdowns:
# Lambda function for Spot interruption handling
import boto3
import json
ec2_client = boto3.client('ec2')
sqs_client = boto3.client('sqs')
def lambda_handler(event, context):
'''
Handle EC2 Spot Instance interruption warnings (2-minute notice)
Strategy: Drain tasks and return work to queue
'''
# Parse EventBridge event
detail = event.get('detail', {})
instance_id = detail.get('instance-id')
instance_action = detail.get('instance-action') # 'terminate', 'stop', or 'hibernate'
if not instance_id:
return {'statusCode': 400, 'body': 'No instance ID in event'}
print(f'Spot interruption warning for {instance_id}: {instance_action}')
# Get instance details
response = ec2_client.describe_instances(InstanceIds=[instance_id])
instance = response['Reservations'][0]['Instances'][0]
# Check for tasks queue
task_queue_url = get_tag_value(instance.get('Tags', []), 'TaskQueueUrl')
if task_queue_url:
# Return in-progress tasks to queue
sqs_client.send_message(
QueueUrl=task_queue_url,
MessageBody=json.dumps({
'action': 'drain_instance',
'instance_id': instance_id,
'interruption_time': detail.get('time')
})
)
# Auto Scaling group automatically replaces terminated Spot instances
# with capacity-optimized strategy
return {
'statusCode': 200,
'body': f'Handled interruption for {instance_id}'
}
def get_tag_value(tags: list, key: str) -> str:
for tag in tags:
if tag['Key'] == key:
return tag['Value']
return None
Checkpointing for Long-Running Jobs
For jobs longer than 2 hours, implement checkpointing to resume from interruptions:
import pickle
import boto3
from dataclasses import dataclass
from typing import Any
s3_client = boto3.client('s3')
@dataclass
class CheckpointState:
job_id: str
progress_percentage: float
current_step: int
total_steps: int
intermediate_results: Any
timestamp: str
def save_checkpoint(
state: CheckpointState,
bucket: str,
prefix: str = 'checkpoints'
):
"""
Save job checkpoint to S3 for recovery after Spot interruption
"""
checkpoint_key = f"{prefix}/{state.job_id}/checkpoint-{state.current_step}.pkl"
# Serialize state
checkpoint_data = pickle.dumps(state)
# Upload to S3
s3_client.put_object(
Bucket=bucket,
Key=checkpoint_key,
Body=checkpoint_data,
ServerSideEncryption='AES256'
)
print(f"Checkpoint saved: {checkpoint_key} ({state.progress_percentage:.1f}% complete)")
def restore_checkpoint(
job_id: str,
bucket: str,
prefix: str = 'checkpoints'
) -> CheckpointState:
"""
Restore latest checkpoint for interrupted job
"""
# List all checkpoints for this job
response = s3_client.list_objects_v2(
Bucket=bucket,
Prefix=f"{prefix}/{job_id}/"
)
if not response.get('Contents'):
raise ValueError(f"No checkpoints found for job {job_id}")
# Get latest checkpoint
latest_checkpoint = sorted(
response['Contents'],
key=lambda x: x['LastModified'],
reverse=True
)[0]
# Download and deserialize
checkpoint_obj = s3_client.get_object(
Bucket=bucket,
Key=latest_checkpoint['Key']
)
state = pickle.loads(checkpoint_obj['Body'].read())
print(f"Restored checkpoint from {state.timestamp} ({state.progress_percentage:.1f}% complete)")
return state
Where Spot fits:
- Good fit: batch processing, CI/CD runners, ML training, data analysis, containerized workloads
- Poor fit: user-facing applications without a fallback, stateful applications without checkpointing
- Diversification: pick instance types with similar CPU/memory ratios (m5, m5a, m5n, m6i, m6a for general compute)
- Checkpoint frequency: every 5-10 minutes for jobs longer than 30 minutes
S3 Storage Optimization
S3 storage costs can be reduced by 40-95% through Intelligent-Tiering and lifecycle policies. Here’s how to implement it effectively:
import boto3
from typing import List, Dict
s3_client = boto3.client('s3')
def create_intelligent_lifecycle_policy(
bucket_name: str,
prefix: str = '',
enable_deep_archive_tier: bool = True
) -> Dict:
"""
Create S3 lifecycle policy to transition objects to Intelligent-Tiering
with optional deep archive tiers for rarely accessed data
"""
lifecycle_rules = []
# Rule 1: Transition to Intelligent-Tiering immediately
intelligent_tiering_rule = {
'ID': 'transition-to-intelligent-tiering',
'Filter': {'Prefix': prefix},
'Status': 'Enabled',
'Transitions': [
{
'Days': 0, # Immediate transition
'StorageClass': 'INTELLIGENT_TIERING'
}
]
}
lifecycle_rules.append(intelligent_tiering_rule)
# Rule 2: Delete incomplete multipart uploads after 7 days
multipart_cleanup_rule = {
'ID': 'cleanup-incomplete-multipart-uploads',
'Filter': {'Prefix': prefix},
'Status': 'Enabled',
'AbortIncompleteMultipartUpload': {
'DaysAfterInitiation': 7
}
}
lifecycle_rules.append(multipart_cleanup_rule)
# Rule 3: Delete old versions after 90 days (if versioning enabled)
noncurrent_version_rule = {
'ID': 'expire-old-versions',
'Filter': {'Prefix': prefix},
'Status': 'Enabled',
'NoncurrentVersionExpiration': {
'NoncurrentDays': 90
}
}
lifecycle_rules.append(noncurrent_version_rule)
# Apply lifecycle configuration
s3_client.put_bucket_lifecycle_configuration(
Bucket=bucket_name,
LifecycleConfiguration={
'Rules': lifecycle_rules
}
)
# Enable Intelligent-Tiering archive configurations
if enable_deep_archive_tier:
s3_client.put_bucket_intelligent_tiering_configuration(
Bucket=bucket_name,
Id='deep-archive-config',
IntelligentTieringConfiguration={
'Id': 'deep-archive-config',
'Status': 'Enabled',
'Tierings': [
{
'Days': 90,
'AccessTier': 'ARCHIVE_ACCESS' # After 90 days no access
},
{
'Days': 180,
'AccessTier': 'DEEP_ARCHIVE_ACCESS' # After 180 days
}
]
}
)
return {
'bucket': bucket_name,
'rules_applied': len(lifecycle_rules),
'intelligent_tiering_enabled': True,
'deep_archive_enabled': enable_deep_archive_tier
}
What Intelligent-Tiering actually does:
- Automatic access tiers: Frequent Access, Infrequent Access (30 days), Archive Instant Access (90 days), optional Archive Access (90 days), optional Deep Archive Access (180 days)
- Cost savings: up to 68% with Archive Instant Access, up to 95% with Deep Archive
- Monitoring fee: $0.0025 per 1,000 objects, which is negligible for large objects and dominant for tiny ones
- Minimum object size: 128KB; smaller objects stay in the Frequent Access tier and still pay the monitoring fee
- No retrieval fees for the Frequent, Infrequent, or Archive Instant Access tiers
Lambda Cost Optimization
Lambda costs comprise three components: requests, duration (GB-seconds), and optional provisioned concurrency. Here’s how to optimize each:
Memory Optimization with Power Tuning
AWS Lambda Power Tuning (open-source) provides data-driven memory optimization:
import boto3
import json
from typing import Dict, List
stepfunctions_client = boto3.client('stepfunctions')
lambda_client = boto3.client('lambda')
def run_lambda_power_tuning(
function_name: str,
power_values: List[int] = [128, 256, 512, 1024, 1536, 2048, 3008],
num_invocations: int = 10,
strategy: str = 'balanced' # 'cost', 'speed', or 'balanced'
) -> Dict:
"""
Run AWS Lambda Power Tuning to find optimal memory configuration
Uses open-source Step Functions state machine
GitHub: alexcasalboni/aws-lambda-power-tuning
"""
state_machine_arn = 'arn:aws:states:us-east-1:123456789012:stateMachine:lambda-power-tuner'
# Input payload for power tuning
input_payload = {
'lambdaARN': f'arn:aws:lambda:us-east-1:123456789012:function:{function_name}',
'powerValues': power_values,
'num': num_invocations,
'payload': {}, # Your test payload
'parallelInvocation': True,
'strategy': strategy
}
# Execute power tuning
response = stepfunctions_client.start_execution(
stateMachineArn=state_machine_arn,
input=json.dumps(input_payload)
)
execution_arn = response['executionArn']
print(f"Power tuning execution started: {execution_arn}")
print(f"Strategy: {strategy} | Memory configs: {power_values}")
# Wait for execution to complete
waiter = stepfunctions_client.get_waiter('execution_succeeded')
waiter.wait(executionArn=execution_arn)
# Get results
result_response = stepfunctions_client.describe_execution(
executionArn=execution_arn
)
output = json.loads(result_response['output'])
return {
'recommended_memory': output.get('power'),
'optimization_summary': output
}
Provisioned Concurrency Cost Analysis
Provisioned concurrency eliminates cold starts, at roughly $11 per month for every GB kept warm. Here’s when that trade is worth making:
from dataclasses import dataclass
from typing import Optional
@dataclass
class LambdaCostBreakdown:
invocations_monthly: int
avg_duration_ms: int
memory_mb: int
provisioned_concurrency: Optional[int] = None
def calculate_lambda_costs(config: LambdaCostBreakdown) -> Dict:
"""
Calculate Lambda costs with and without provisioned concurrency
Helps decide if provisioned concurrency is cost-effective
"""
# Pricing (US East N. Virginia)
price_per_request = 0.20 / 1_000_000 # $0.20 per 1M requests
price_per_gb_second = 0.0000166667
provisioned_price_per_gb_second = 0.0000041667
# Convert memory to GB
memory_gb = config.memory_mb / 1024
# Convert duration to seconds
duration_seconds = config.avg_duration_ms / 1000
# Standard (on-demand) cost
request_cost = config.invocations_monthly * price_per_request
compute_cost = (
config.invocations_monthly *
duration_seconds *
memory_gb *
price_per_gb_second
)
total_on_demand = request_cost + compute_cost
# Provisioned concurrency cost (if applicable)
provisioned_cost = 0
if config.provisioned_concurrency:
seconds_per_month = 730 * 3600
provisioned_cost = (
config.provisioned_concurrency *
memory_gb *
seconds_per_month *
provisioned_price_per_gb_second
)
total_with_provisioned = request_cost + compute_cost + provisioned_cost
return {
'on_demand_cost_monthly': round(total_on_demand, 2),
'provisioned_cost_monthly': round(total_with_provisioned, 2),
'cost_difference': round(total_with_provisioned - total_on_demand, 2),
'breakdown': {
'request_charges': round(request_cost, 2),
'compute_charges': round(compute_cost, 2),
'provisioned_charges': round(provisioned_cost, 2)
},
# 1.5x is a starting threshold for "the latency is worth the premium";
# set it from your own latency budget
'recommendation': 'Use provisioned concurrency' if (
config.provisioned_concurrency and
total_with_provisioned < total_on_demand * 1.5
) else 'Stick with on-demand'
}
# Example: Interactive API
interactive_api = LambdaCostBreakdown(
invocations_monthly=5_000_000,
avg_duration_ms=200,
memory_mb=1024,
provisioned_concurrency=10
)
costs = calculate_lambda_costs(interactive_api)
print(f"On-demand: ${costs['on_demand_cost_monthly']}")
print(f"With provisioned: ${costs['provisioned_cost_monthly']}")
print(f"Recommendation: {costs['recommendation']}")
What actually moves the Lambda bill:
- Memory allocation also determines CPU and network share, so more memory often means shorter duration and a lower total
- 1024-1536MB tends to be the practical range for a cost/performance balance, but measure before settling
- Compute Savings Plans apply to Lambda duration charges (up to 17% discount)
- Reducing the code’s own runtime usually beats memory tuning, and it costs nothing per month
- Provisioned concurrency belongs on user-facing APIs with a strict latency budget, not on sporadic workloads
Cost Allocation and Tagging
Implementing comprehensive tagging enables cost attribution across teams, projects, and environments. Here’s a production-ready approach:
import boto3
from typing import Dict, List
organizations_client = boto3.client('organizations')
config_client = boto3.client('config')
def create_tagging_compliance_rule():
"""
Create AWS Config rule to detect untagged resources
Enforces Environment, Project, CostCenter tags
"""
config_rule = {
'ConfigRuleName': 'required-tags-compliance',
'Description': 'Check that resources have required cost allocation tags',
'Source': {
'Owner': 'AWS',
'SourceIdentifier': 'REQUIRED_TAGS'
},
'InputParameters': '{"tag1Key":"Environment","tag2Key":"Project","tag3Key":"CostCenter"}',
'Scope': {
'ComplianceResourceTypes': [
'AWS::EC2::Instance',
'AWS::RDS::DBInstance',
'AWS::S3::Bucket',
'AWS::Lambda::Function',
'AWS::DynamoDB::Table'
]
}
}
config_client.put_config_rule(ConfigRule=config_rule)
return {
'rule_name': 'required-tags-compliance',
'enforcement': 'Detect non-compliant resources',
'remediation': 'Manual tagging or automated via SSM Automation'
}
Cost Allocation Reporting
Generate monthly cost reports grouped by tags for chargeback/showback:
def generate_cost_allocation_report(
month: str, # Format: 'YYYY-MM'
group_by_tags: List[str] = ['Project', 'Environment', 'CostCenter']
) -> Dict:
"""
Generate cost allocation report grouped by tags
Identifies untagged costs requiring attention
"""
ce_client = boto3.client('ce')
from datetime import datetime, timedelta
start_date = datetime.strptime(f'{month}-01', '%Y-%m-%d')
# Calculate end date (last day of month)
if start_date.month == 12:
end_date = start_date.replace(year=start_date.year + 1, month=1, day=1)
else:
end_date = start_date.replace(month=start_date.month + 1, day=1)
end_date = end_date - timedelta(days=1)
# Get cost and usage by tags
cost_by_tags = {}
for tag_key in group_by_tags:
response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date.strftime('%Y-%m-%d'),
'End': end_date.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost'],
GroupBy=[
{'Type': 'TAG', 'Key': tag_key}
]
)
# Process results
tag_costs = {}
for result in response['ResultsByTime']:
for group in result['Groups']:
tag_value = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else 'Untagged'
cost = float(group['Metrics']['UnblendedCost']['Amount'])
tag_costs[tag_value] = cost
cost_by_tags[tag_key] = tag_costs
# Calculate total and untagged percentages
total_response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': start_date.strftime('%Y-%m-%d'),
'End': end_date.strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost']
)
total_cost = float(total_response['ResultsByTime'][0]['Total']['UnblendedCost']['Amount'])
tagged_cost = sum(cost_by_tags.get('Project', {}).values())
untagged_cost = total_cost - tagged_cost
untagged_percentage = (untagged_cost / total_cost) * 100 if total_cost > 0 else 0
return {
'month': month,
'total_cost': round(total_cost, 2),
'tagged_cost': round(tagged_cost, 2),
'untagged_cost': round(untagged_cost, 2),
'untagged_percentage': round(untagged_percentage, 1),
'breakdown_by_tags': cost_by_tags,
'alert': 'High untagged cost - improve compliance' if untagged_percentage > 20 else None
}
Tagging rules that hold up:
- Required tags: Environment (production/staging/development), Project, CostCenter, Owner
- Activate cost allocation tags in the Billing Console before using them, since nothing is tracked retroactively
- Pin the allowed tag values;
productionvsprodvsProductionsilently splits one cost center into three - Use AWS Config to detect untagged resources, and enforce at creation time rather than remediating by hand
- Target: <10% untagged resource costs
Optimization Techniques Comparison
| Technique | Savings | Implementation Effort | Risk | Best For |
|---|---|---|---|---|
| Right-sizing (Compute Optimizer) | 20-40% | Low | Low | Over-provisioned instances |
| Savings Plans | Up to 66% | Low | Medium | Predictable baseline |
| Reserved Instances | Up to 72% | Low | Medium | Stable workloads |
| Spot Instances | 70-90% | Medium | Medium | Fault-tolerant workloads |
| Lambda memory optimization | 20-50% | Low | Low | Lambda-heavy architectures |
| S3 Intelligent-Tiering | 40-95% | Low | Low | Large storage, mixed access |
| Instance scheduling | 70% | Medium | Low | Non-production environments |
Where the Default Breaks
Visibility first, then right-sizing, then commitments holds for most production accounts, and the reason is sequencing risk. A Savings Plan bought against unmeasured, over-provisioned usage locks in the wrong baseline for one to three years. A right-sizing pass done first shrinks the commitment you need to buy in the first place.
Three situations justify breaking that order. A workload whose shape is fixed by something outside your control, such as a licensed database pinned to one instance family, can take a Reserved Instance immediately, because right-sizing will not move it. An account under an immediate spend ceiling can buy a No Upfront commitment first and right-size afterwards, since the discount applies from the hour it activates and No Upfront asks for no capital. A batch tier that already tolerates interruption can move to Spot before anything else is measured, because a 70-90% discount dwarfs whatever right-sizing would have found there.
Whichever entry point you take, activate cost allocation tags before the first purchase. Tags are not retroactive, so every month without them is a month of spend nobody will be able to attribute later.
References
- AWS Cost Explorer - Core tool for analyzing and visualizing cost trends
- AWS Budgets - Budget thresholds and proactive alerts
- AWS Compute Optimizer - Automatic recommendations for EC2, Lambda, and EBS
- Savings Plans - Flexible commitment options and savings models
- Spot Instances - Cost optimization for interruption-tolerant workloads
- S3 Intelligent-Tiering - Automatic storage transitions based on access patterns
- FinOps Foundation - Cloud cost management practices and community resources
Related posts
A technical guide to choosing and implementing AWS edge computing for global apps, with practical examples and cost optimization strategies.
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.