# AWS ECS & Lambda Guide: Container Orchestration, Serverless Patterns & When to Use Which
Choosing between containers and serverless is one of the most impactful architectural decisions in cloud engineering. AWS offers two dominant compute options — Elastic Container Service (ECS) for container orchestration and Lambda for serverless functions — each with distinct strengths depending on workload characteristics.
This guide provides a deep technical walkthrough of both services, practical deployment patterns, cost analysis, and a decision framework to help you pick the right tool for each workload.
---
1. ECS Architecture: Core Concepts
Amazon ECS is a fully managed container orchestration service that runs Docker containers at scale. Understanding its building blocks is essential before deploying anything.
Core Components
- Cluster: A logical grouping of tasks and services. Think of it as the boundary for your compute resources.
- Task Definition: A blueprint describing one or more containers — their images, CPU/memory, ports, environment variables, and logging config.
- Task: A running instance of a task definition. A task can contain multiple containers that share networking and storage.
- Service: Maintains a desired count of tasks, handles load balancer integration, and manages deployments.
- Container Instance: An EC2 instance registered to a cluster (only relevant for EC2 launch type).
How They Fit Together
Cluster
├── Service A (desired_count: 3)
│ ├── Task 1 (task-def:web:5)
│ ├── Task 2 (task-def:web:5)
│ └── Task 3 (task-def:web:5)
├── Service B (desired_count: 2)
│ ├── Task 1 (task-def:worker:3)
│ └── Task 2 (task-def:worker:3)
└── Standalone Task (one-off migration)
Services ensure that if a task dies, ECS launches a replacement automatically. Standalone tasks are useful for one-off jobs like database migrations or batch processing.
---
2. ECS Launch Types: EC2 vs Fargate
ECS supports two launch types that determine how your containers are hosted.
Comparison Table
| Feature | EC2 Launch Type | Fargate Launch Type |
|---|---|---|
| <strong>Infrastructure</strong> | You manage EC2 instances | AWS manages infrastructure |
| <strong>Pricing</strong> | Pay for EC2 instances (even idle) | Pay per task (vCPU + memory per second) |
| <strong>Scaling</strong> | Must scale instances + tasks | Only scale tasks |
| <strong>GPU Support</strong> | Yes | Limited (recently added) |
| <strong>Max Task Size</strong> | Up to instance limits | 16 vCPU, 120 GB memory |
| <strong>EBS Volumes</strong> | Full EBS support | Ephemeral storage (20-200 GB) |
| <strong>Networking</strong> | Bridge, host, awsvpc | awsvpc only |
| <strong>Startup Time</strong> | Faster (instance pre-provisioned) | Slower (infrastructure provisioned per task) |
| <strong>Spot Support</strong> | EC2 Spot Instances | Fargate Spot (up to 70% discount) |
| <strong>Compliance</strong> | Full OS-level access | Limited (no SSH, no host access) |
When to Use EC2 Launch Type
- You need GPU instances (ML inference, video processing)
- Workloads require sustained high CPU/memory utilization (better cost efficiency)
- You need host-level access for compliance, custom AMIs, or specialized kernel modules
- Windows containers with specific OS requirements
- Very large tasks exceeding Fargate limits
When to Use Fargate
- Teams want zero infrastructure management
- Variable or spiky workloads where per-second billing saves money
- Security-sensitive workloads benefiting from task-level isolation
- Rapid prototyping and development environments
- Microservices with moderate resource requirements
---
3. Task Definition Deep Dive
The task definition is where all container configuration lives. Here is a production-ready example:
family: web-api
networkMode: awsvpc
requiresCompatibilities:
- FARGATE
cpu: "1024"
memory: "2048"
executionRoleArn: arn:aws:iam::123456789012:role/ecsTaskExecutionRole
taskRoleArn: arn:aws:iam::123456789012:role/ecsTaskRole
containerDefinitions:
- name: web-api
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api:latest
essential: true
portMappings:
- containerPort: 8080
protocol: tcp
environment:
- name: NODE_ENV
value: production
- name: PORT
value: "8080"
secrets:
- name: DATABASE_URL
valueFrom: arn:aws:secretsmanager:us-east-1:123456789012:secret:db-url
- name: API_KEY
valueFrom: arn:aws:ssm:us-east-1:123456789012:parameter/api-key
healthCheck:
command: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 30
timeout: 5
retries: 3
startPeriod: 60
logConfiguration:
logDriver: awslogs
options:
awslogs-group: /ecs/web-api
awslogs-region: us-east-1
awslogs-stream-prefix: ecs
ulimits:
- name: nofile
softLimit: 65536
hardLimit: 65536
- name: datadog-agent
image: public.ecr.aws/datadog/agent:latest
essential: false
environment:
- name: ECS_FARGATE
value: "true"
secrets:
- name: DD_API_KEY
valueFrom: arn:aws:ssm:us-east-1:123456789012:parameter/dd-api-key
Networking Modes Explained
| Mode | Description | Use Case |
|---|---|---|
| <strong>awsvpc</strong> | Each task gets its own ENI with private IP | Fargate (required), security groups per task |
| <strong>bridge</strong> | Docker bridge networking | EC2 launch type, dynamic port mapping |
| <strong>host</strong> | Container uses host network directly | Maximum network performance on EC2 |
| <strong>none</strong> | No external networking | Batch jobs with no network needs |
Key Configuration Notes
- executionRoleArn: Permissions for the ECS agent to pull images from ECR and write logs to CloudWatch
- taskRoleArn: Permissions your application code uses to access AWS services (S3, DynamoDB, etc.)
- secrets: Always use Secrets Manager or SSM Parameter Store — never hardcode credentials
- healthCheck: Define container-level health checks in addition to ALB target group checks for faster failure detection
---
4. ECS Service Deployment Patterns
ECS services support multiple deployment strategies to minimize downtime and risk.
Rolling Update (Default)
The default strategy replaces tasks incrementally:
aws ecs update-service \
--cluster production \
--service web-api \
--deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \
--task-definition web-api:12
- maximumPercent=200: ECS can run up to 2x desired count during deployment
- minimumHealthyPercent=100: Never drops below desired count (zero-downtime)
- Simple and effective for most services
- Rollback requires a new deployment with the previous task definition
Blue-Green with AWS CodeDeploy
For zero-downtime deployments with instant rollback capability:
# Create CodeDeploy deployment group for ECS
aws deploy create-deployment-group \
--application-name web-api-app \
--deployment-group-name web-api-dg \
--service-role-arn arn:aws:iam::123456789012:role/CodeDeployRole \
--ecs-services "clusterName=production,serviceName=web-api" \
--load-balancer-info "targetGroupPairInfoList=[{targetGroups=[{name=tg-blue},{name=tg-green}],prodTrafficRoute={listenerArns=[arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/web-alb/abc/def]}}]" \
--deployment-style "deploymentType=BLUE_GREEN,deploymentOption=WITH_TRAFFIC_CONTROL" \
--blue-green-deployment-configuration "terminateBlueInstancesOnDeploymentSuccess={action=TERMINATE,terminationWaitTimeInMinutes=60},deploymentReadyOption={actionOnTimeout=CONTINUE_DEPLOYMENT,waitTimeInMinutes=0}"
Blue-green gives you:
- Two target groups (blue = current, green = new)
- Traffic shifts: AllAtOnce, Linear10PercentEvery1Minute, Canary10Percent5Minutes
- Automatic rollback on CloudWatch alarm triggers
- Manual approval gates before full traffic shift
Circuit Breaker
ECS deployment circuit breaker automatically rolls back failed deployments:
aws ecs update-service \
--cluster production \
--service web-api \
--deployment-configuration '{
"maximumPercent": 200,
"minimumHealthyPercent": 100,
"deploymentCircuitBreaker": {
"enable": true,
"rollback": true
}
}'
If new tasks repeatedly fail health checks, ECS stops the deployment and reverts to the last stable version without manual intervention.
---
5. Auto Scaling for ECS
ECS integrates with Application Auto Scaling to dynamically adjust your task count based on demand.
Target Tracking Scaling
The simplest and most common approach — maintain a specific metric target:
# Register scalable target
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/production/web-api \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 \
--max-capacity 20
# CPU target tracking (maintain 60% average CPU)
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/production/web-api \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu-target-tracking \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 60.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}'
Step Scaling
For more granular control with different responses at different thresholds:
# Scale aggressively when request count spikes
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/production/web-api \
--scalable-dimension ecs:service:DesiredCount \
--policy-name request-count-step \
--policy-type StepScaling \
--step-scaling-policy-configuration '{
"AdjustmentType": "PercentChangeInCapacity",
"StepAdjustments": [
{"MetricIntervalLowerBound": 0, "MetricIntervalUpperBound": 1000, "ScalingAdjustment": 25},
{"MetricIntervalLowerBound": 1000, "MetricIntervalUpperBound": 3000, "ScalingAdjustment": 50},
{"MetricIntervalLowerBound": 3000, "ScalingAdjustment": 100}
],
"Cooldown": 60
}'
Scheduled Scaling
For predictable traffic patterns (business hours, batch windows):
# Scale up for business hours (Mon-Fri 8am)
aws application-autoscaling put-scheduled-action \
--service-namespace ecs \
--resource-id service/production/web-api \
--scalable-dimension ecs:service:DesiredCount \
--scheduled-action-name scale-up-business-hours \
--schedule "cron(0 8 ? MON-FRI )" \
--scalable-target-action "MinCapacity=6,MaxCapacity=20"
# Scale down for nights (Mon-Fri 8pm)
aws application-autoscaling put-scheduled-action \
--service-namespace ecs \
--resource-id service/production/web-api \
--scalable-dimension ecs:service:DesiredCount \
--scheduled-action-name scale-down-night \
--schedule "cron(0 20 ? MON-FRI )" \
--scalable-target-action "MinCapacity=2,MaxCapacity=6"
Scaling Best Practices
- Combine target tracking (baseline) with scheduled scaling (known patterns)
- Set
ScaleOutCooldownshorter thanScaleInCooldownto respond fast but scale in cautiously - Use ALBRequestCountPerTarget metric for request-driven services
- Monitor
CapacityProviderReservationfor cluster-level capacity planning
---
6. AWS Lambda Fundamentals
AWS Lambda runs code in response to events without provisioning servers. Understanding its execution model is critical for writing efficient functions.
Execution Model
import boto3
import os
# INIT CODE - runs once per cold start
# DB connections, SDK clients, config loading go HERE
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def handler(event, context):
"""HANDLER CODE - runs every invocation.
Keep this lean. Heavy initialization belongs above."""
user_id = event["pathParameters"]["userId"]
response = table.get_item(Key={"pk": f"USER#{user_id}"})
if "Item" not in response:
return {"statusCode": 404, "body": "User not found"}
return {
"statusCode": 200,
"body": json.dumps(response["Item"], default=str),
"headers": {"Content-Type": "application/json"}
}
Cold Start Factors
| Factor | Impact on Cold Start | Mitigation |
|---|---|---|
| <strong>Runtime</strong> | Java/C# > Python/Node.js > Rust/Go | Use lightweight runtimes for latency-sensitive |
| <strong>Package Size</strong> | Larger = slower | Minimize dependencies, use layers |
| <strong>Memory</strong> | More memory = more CPU = faster init | Increase memory for faster cold starts |
| <strong>VPC</strong> | Adds ENI attachment time (~1-2s) | Use VPC endpoints, Hyperplane ENI (now default) |
| <strong>Init Code</strong> | DB connections, SDK init | Lazy-load non-critical resources |
Concurrency Model
- Reserved Concurrency: Guarantees capacity for a function (also acts as throttle)
- Provisioned Concurrency: Pre-warms N execution environments (eliminates cold starts)
- Account Limit: Default 1000 concurrent executions per region (can be increased)
- Burst Limit: 3000 immediate concurrent executions, then 500/minute growth
# Set reserved concurrency (max 100 simultaneous executions)
aws lambda put-function-concurrency \
--function-name process-orders \
--reserved-concurrent-executions 100
# Configure provisioned concurrency on an alias
aws lambda put-provisioned-concurrency-config \
--function-name process-orders \
--qualifier production \
--provisioned-concurrent-executions 20
Lambda Layers
Layers let you share code and dependencies across multiple functions:
# Package and publish a shared utilities layer
cd layer-content
pip install requests boto3-stubs -t python/lib/python3.12/site-packages/
zip -r ../utils-layer.zip python/
aws lambda publish-layer-version \
--layer-name shared-utils \
--zip-file fileb://../utils-layer.zip \
--compatible-runtimes python3.12 python3.11 \
--description "Shared utilities and typed boto3"
---
7. Lambda Integration Patterns
Lambda shines when combined with AWS event sources. Here are the most common production patterns.
API Gateway + Lambda (REST API)
import json
import boto3
from decimal import Decimal
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("orders")
def handler(event, context):
"""Handle API Gateway proxy integration."""
http_method = event["httpMethod"]
path = event["path"]
if http_method == "POST" and path == "/orders":
return create_order(json.loads(event["body"]))
elif http_method == "GET" and path.startswith("/orders/"):
order_id = event["pathParameters"]["orderId"]
return get_order(order_id)
return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}
def create_order(body):
order = {
"orderId": context.aws_request_id,
"customerId": body["customerId"],
"items": body["items"],
"total": Decimal(str(body["total"])),
"status": "PENDING"
}
table.put_item(Item=order)
return {
"statusCode": 201,
"body": json.dumps({"orderId": order["orderId"]}),
"headers": {"Content-Type": "application/json"}
}
def get_order(order_id):
response = table.get_item(Key={"orderId": order_id})
if "Item" not in response:
return {"statusCode": 404, "body": json.dumps({"error": "Order not found"})}
return {
"statusCode": 200,
"body": json.dumps(response["Item"], default=str),
"headers": {"Content-Type": "application/json"}
}
SQS Consumer (Batch Processing)
import json
import boto3
s3 = boto3.client("s3")
def handler(event, context):
"""Process SQS messages in batches.
Configure with:
- BatchSize: 10
- MaximumBatchingWindowInSeconds: 30
- FunctionResponseTypes: ["ReportBatchItemFailures"]
"""
batch_item_failures = []
for record in event["Records"]:
try:
message = json.loads(record["body"])
process_message(message)
except Exception as e:
print(f"Failed to process {record['messageId']}: {e}")
batch_item_failures.append({
"itemIdentifier": record["messageId"]
})
# Return only failed items for retry (partial batch failure)
return {"batchItemFailures": batch_item_failures}
def process_message(message):
"""Process individual message - generate PDF report."""
report_data = generate_report(message["reportId"])
s3.put_object(
Bucket="reports-bucket",
Key=f"reports/{message['reportId']}.pdf",
Body=report_data,
ContentType="application/pdf"
)
S3 Event Trigger (Image Processing)
import boto3
from PIL import Image
import io
s3 = boto3.client("s3")
THUMBNAIL_SIZES = [(128, 128), (256, 256), (512, 512)]
def handler(event, context):
"""Generate thumbnails when images are uploaded to S3."""
for record in event["Records"]:
bucket = record["s3"]["bucket"]["name"]
key = record["s3"]["object"]["key"]
# Skip if already a thumbnail
if key.startswith("thumbnails/"):
return
# Download original image
response = s3.get_object(Bucket=bucket, Key=key)
image = Image.open(io.BytesIO(response["Body"].read()))
# Generate thumbnails
for width, height in THUMBNAIL_SIZES:
thumbnail = image.copy()
thumbnail.thumbnail((width, height))
buffer = io.BytesIO()
thumbnail.save(buffer, format="JPEG", quality=85)
buffer.seek(0)
thumb_key = f"thumbnails/{width}x{height}/{key}"
s3.put_object(
Bucket=bucket,
Key=thumb_key,
Body=buffer,
ContentType="image/jpeg"
)
return {"statusCode": 200, "processed": len(event["Records"])}
Step Functions Orchestration
For complex multi-step workflows, use Step Functions to coordinate Lambda functions:
# step-function-definition.json (Amazon States Language)
# Each state invokes a Lambda function
def validate_order(event, context):
"""Step 1: Validate order data."""
order = event["order"]
if not order.get("items") or not order.get("customerId"):
raise ValueError("Invalid order: missing required fields")
if sum(item["quantity"] for item in order["items"]) > 100:
raise ValueError("Order exceeds maximum quantity")
return {**event, "validated": True}
def check_inventory(event, context):
"""Step 2: Check inventory for all items."""
unavailable = []
for item in event["order"]["items"]:
stock = get_stock_level(item["sku"])
if stock < item["quantity"]:
unavailable.append(item["sku"])
if unavailable:
return {**event, "inventoryAvailable": False, "unavailable": unavailable}
return {**event, "inventoryAvailable": True}
def process_payment(event, context):
"""Step 3: Charge payment (only if inventory available)."""
payment_result = charge_card(
customer_id=event["order"]["customerId"],
amount=event["order"]["total"]
)
return {**event, "paymentId": payment_result["id"]}
---
8. Lambda Best Practices
Connection Pooling
Never create database connections inside the handler. Use the init phase:
import psycopg2
from psycopg2 import pool
import os
# Connection pool created once per execution environment
connection_pool = psycopg2.pool.SimpleConnectionPool(
minconn=1,
maxconn=5,
host=os.environ["DB_HOST"],
port=os.environ.get("DB_PORT", 5432),
database=os.environ["DB_NAME"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
connect_timeout=5
)
def handler(event, context):
"""Reuse connections from pool across invocations."""
conn = connection_pool.getconn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM users WHERE id = %s",
(event["userId"],)
)
user = cur.fetchone()
conn.commit()
return {"statusCode": 200, "body": json.dumps(user)}
except Exception as e:
conn.rollback()
raise
finally:
connection_pool.putconn(conn)
Provisioned Concurrency with Auto Scaling
Eliminate cold starts for latency-sensitive endpoints:
# Set provisioned concurrency on production alias
aws lambda put-provisioned-concurrency-config \
--function-name api-handler \
--qualifier production \
--provisioned-concurrent-executions 50
# Auto-scale provisioned concurrency based on utilization
aws application-autoscaling register-scalable-target \
--service-namespace lambda \
--resource-id "function:api-handler:production" \
--scalable-dimension "lambda:function:ProvisionedConcurrency" \
--min-capacity 10 \
--max-capacity 100
aws application-autoscaling put-scaling-policy \
--service-namespace lambda \
--resource-id "function:api-handler:production" \
--scalable-dimension "lambda:function:ProvisionedConcurrency" \
--policy-name utilization-tracking \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 0.7,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
}
}'
Power Tuning
AWS Lambda Power Tuning (open-source tool) helps find the optimal memory/cost configuration:
# Deploy power tuning state machine
sam deploy --template-file powertuning-template.yaml --stack-name power-tuning
# Run tuning (tests memory from 128MB to 3008MB)
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:123456789012:stateMachine:powerTuningStateMachine \
--input '{
"lambdaARN": "arn:aws:lambda:us-east-1:123456789012:function:process-orders",
"powerValues": [128, 256, 512, 1024, 1536, 2048, 3008],
"num": 50,
"payload": {"orderId": "test-123"},
"parallelInvocation": true,
"strategy": "balanced"
}'
Key findings from power tuning:
- CPU-bound functions benefit linearly from more memory (memory = CPU allocation)
- I/O-bound functions often have a sweet spot where more memory adds cost but not speed
- The "balanced" strategy optimizes for cost × duration
Additional Best Practices
- Minimize package size: Use Lambda layers for shared dependencies, strip unnecessary files
- Set appropriate timeouts: Default is 3s; set based on actual P99 latency + buffer
- Use environment variables: For configuration that changes between stages
- Enable X-Ray tracing: Trace requests across Lambda, API Gateway, DynamoDB, SQS
- Implement idempotency: Use DynamoDB conditional writes or idempotency keys for at-least-once delivery
- Avoid recursive invocations: Never have a Lambda trigger itself (S3 → Lambda → S3 in same bucket)
---
9. Event-Driven Architecture: EventBridge + Lambda + SQS
Event-driven architecture decouples producers from consumers, enabling independent scaling and evolution of services.
Architecture Pattern
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Order Service│────▶│ EventBridge Bus │────▶│ SQS Queue │────▶ Lambda (email)
│ │ │ │ └──────────────┘
└──────────────┘ │ Rules Engine │ ┌──────────────┐
│ │────▶│ SQS Queue │────▶ Lambda (inventory)
│ │ └──────────────┘
│ │ ┌──────────────┐
│ │────▶│ Lambda │ (analytics)
└──────────────────┘ └──────────────┘
Publishing Events to EventBridge
import boto3
import json
from datetime import datetime
eventbridge = boto3.client("events")
def publish_order_event(order, event_type):
"""Publish domain event to EventBridge."""
response = eventbridge.put_events(
Entries=[
{
"Source": "com.myapp.orders",
"DetailType": event_type,
"Detail": json.dumps({
"orderId": order["orderId"],
"customerId": order["customerId"],
"total": str(order["total"]),
"items": order["items"],
"timestamp": datetime.utcnow().isoformat()
}),
"EventBusName": "orders-bus"
}
]
)
if response["FailedEntryCount"] > 0:
raise RuntimeError(f"Failed to publish event: {response['Entries']}")
return response
EventBridge Rules (Infrastructure as Code)
# CloudFormation / SAM template snippet
OrderCreatedRule:
Type: AWS::Events::Rule
Properties:
EventBusName: orders-bus
EventPattern:
source:
- "com.myapp.orders"
detail-type:
- "OrderCreated"
- "OrderUpdated"
detail:
total:
- numeric: [">=", 100]
Targets:
- Id: notification-queue
Arn: !GetAtt NotificationQueue.Arn
SqsParameters:
MessageGroupId: "orders"
- Id: inventory-handler
Arn: !GetAtt InventoryFunction.Arn
SQS as a Buffer Between EventBridge and Lambda
Using SQS between EventBridge and Lambda adds resilience:
- Buffering: Absorbs traffic spikes without throttling Lambda
- Dead Letter Queue: Failed messages automatically move to DLQ after max retries
- Batching: Lambda processes up to 10 messages per invocation
- Backpressure: Queue depth metric drives scaling decisions
# Lambda consuming from SQS with DLQ handling
import json
import boto3
sqs = boto3.client("sqs")
def handler(event, context):
"""Process order notifications from SQS."""
failures = []
for record in event["Records"]:
try:
# EventBridge wraps the event in SQS message body
envelope = json.loads(record["body"])
detail = envelope["detail"]
detail_type = envelope["detail-type"]
if detail_type == "OrderCreated":
send_confirmation_email(detail)
elif detail_type == "OrderUpdated":
send_update_email(detail)
except Exception as e:
print(f"Error processing {record['messageId']}: {e}")
failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": failures}
def send_confirmation_email(order_detail):
"""Send order confirmation via SES."""
ses = boto3.client("ses")
ses.send_email(
Source="orders@myapp.com",
Destination={"ToAddresses": [get_customer_email(order_detail["customerId"])]},
Message={
"Subject": {"Data": f"Order {order_detail['orderId']} Confirmed"},
"Body": {"Text": {"Data": f"Your order for ${order_detail['total']} is confirmed."}}
}
)
Monitoring Event-Driven Systems
Key metrics to track:
- EventBridge:
FailedInvocations,MatchedEvents,ThrottledRules - SQS:
ApproximateNumberOfMessagesVisible,ApproximateAgeOfOldestMessage - Lambda:
Errors,Throttles,Duration,ConcurrentExecutions - DLQ: Any message in DLQ triggers an alarm for investigation
---
10. ECS vs Lambda: Decision Framework
Use this framework to evaluate which compute model fits each workload.
Decision Matrix
| Dimension | Choose ECS | Choose Lambda |
|---|---|---|
| <strong>Execution Duration</strong> | Long-running (hours/days) | Short-lived (< 15 minutes) |
| <strong>Latency Requirements</strong> | Consistent sub-10ms (always warm) | Tolerates occasional cold starts (50-500ms) |
| <strong>State Management</strong> | In-memory state, WebSocket connections | Stateless request-response |
| <strong>Traffic Pattern</strong> | Steady, predictable load | Spiky, unpredictable, or low-volume |
| <strong>Scaling Speed</strong> | Moderate (30-60s for new tasks) | Fast (milliseconds, concurrent model) |
| <strong>Cost at Scale</strong> | Cheaper at sustained high utilization | Cheaper at low/variable utilization |
| <strong>Team Expertise</strong> | Container/Docker experience | Event-driven development experience |
| <strong>Deployment Size</strong> | Large applications (GB-scale) | Small handlers (< 250MB zipped) |
| <strong>Resource Needs</strong> | GPU, large memory (> 10GB), custom OS | Standard CPU/memory workloads |
| <strong>Networking</strong> | Complex service mesh, persistent connections | Simple request-response |
Decision Flowchart
START: What is your workload?
│
├─ Does it run longer than 15 minutes?
│ └─ YES → ECS (Lambda max timeout is 15 min)
│
├─ Does it need persistent connections (WebSocket, gRPC streaming)?
│ └─ YES → ECS
│
├─ Is traffic consistently high (>80% utilization over 24h)?
│ └─ YES → ECS (usually more cost-effective)
│
├─ Does it need GPU or >10GB memory?
│ └─ YES → ECS on EC2
│
├─ Is it event-driven (S3 upload, SQS message, schedule)?
│ └─ YES → Lambda (native event source integration)
│
├─ Is traffic spiky with long idle periods?
│ └─ YES → Lambda (pay nothing when idle)
│
├─ Is cold start latency acceptable (P99 < 1s)?
│ └─ YES → Lambda
│ └─ NO → ECS (or Lambda with provisioned concurrency)
│
└─ Default: Start with Lambda (simpler ops), migrate to ECS if you hit limits
Hybrid Architecture
Many production systems use both. A common pattern:
- ECS: Core API services, WebSocket servers, background workers with persistent connections
- Lambda: Webhooks, scheduled jobs, file processing, event handlers, glue logic between services
---
11. Cost Comparison for Common Workloads
Scenario 1: REST API with 1M Requests/Month
Assumptions: Average duration 100ms, 256MB memory, us-east-1 pricing.
Lambda Cost
| Component | Calculation | Monthly Cost |
|---|---|---|
| Requests | 1M × $0.20/1M requests | $0.20 |
| Compute | 1M × 0.1s × 256MB ÷ 1024 × $0.0000166667/GB-s | $0.42 |
| API Gateway | 1M × $1.00/1M (REST API) | $1.00 |
| <strong>Total</strong> | <strong>~$1.62/month</strong> |
ECS Fargate Cost (minimum viable deployment)
| Component | Calculation | Monthly Cost |
|---|---|---|
| Task (2 tasks, 0.25 vCPU, 0.5GB) | 2 × 730h × ($0.04048/vCPU-h × 0.25 + $0.004445/GB-h × 0.5) | $18.02 |
| ALB | $16.20 fixed + LCU charges | ~$20.00 |
| <strong>Total</strong> | <strong>~$38.02/month</strong> |
Winner at 1M req/month: Lambda (23x cheaper)
Scenario 2: API with 100M Requests/Month
Lambda Cost
| Component | Calculation | Monthly Cost |
|---|---|---|
| Requests | 100M × $0.20/1M | $20.00 |
| Compute | 100M × 0.1s × 0.25GB × $0.0000166667 | $41.67 |
| API Gateway | 100M × $1.00/1M (REST) | $100.00 |
| <strong>Total</strong> | <strong>~$161.67/month</strong> |
ECS Fargate Cost (scaled for load)
| Component | Calculation | Monthly Cost |
|---|---|---|
| Tasks (4 tasks, 1 vCPU, 2GB) | 4 × 730h × ($0.04048 + $0.004445 × 2) | $144.43 |
| ALB | $16.20 + LCU (~$30) | ~$46.20 |
| <strong>Total</strong> | <strong>~$190.63/month</strong> |
At 100M req/month: Comparable costs, but ECS eliminates API Gateway fee with direct ALB
Note: Using ALB directly with ECS saves the API Gateway cost. At this scale, ECS on EC2 with reserved instances can be 40-60% cheaper than both options above.
Scenario 3: Background Worker (Processing 10M Messages/Month)
Lambda Cost (SQS Trigger)
| Component | Calculation | Monthly Cost |
|---|---|---|
| Requests | 10M invocations (free tier: 1M free) | $1.80 |
| Compute | 10M × 0.5s × 512MB ÷ 1024 × $0.0000166667 | $41.67 |
| <strong>Total</strong> | <strong>~$43.47/month</strong> |
ECS Fargate Cost (Always-On Worker)
| Component | Calculation | Monthly Cost |
|---|---|---|
| Tasks (2 tasks, 0.5 vCPU, 1GB) | 2 × 730h × ($0.04048 × 0.5 + $0.004445 × 1) | $36.00 |
| <strong>Total</strong> | <strong>~$36.00/month</strong> |
At high steady throughput: ECS is slightly cheaper and handles sustained load better.
Cost Optimization Tips
- Lambda: Use Graviton2 (arm64) for 20% cost reduction; use HTTP API instead of REST API for 70% gateway savings
- ECS Fargate: Use Fargate Spot for fault-tolerant workloads (up to 70% savings)
- ECS EC2: Use Savings Plans or Reserved Instances for predictable workloads (up to 72% savings)
- Both: Right-size memory/CPU based on actual utilization metrics
---
12. Terraform Examples
ECS Fargate Service (Complete)
# --- Networking ---
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "ecs-vpc" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "private-${count.index + 1}" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "public-${count.index + 1}" }
}
data "aws_availability_zones" "available" {
state = "available"
}
# --- ECS Cluster ---
resource "aws_ecs_cluster" "main" {
name = "production"
setting {
name = "containerInsights"
value = "enabled"
}
configuration {
execute_command_configuration {
logging = "OVERRIDE"
log_configuration {
cloud_watch_log_group_name = aws_cloudwatch_log_group.ecs_exec.name
}
}
}
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
base = 2
}
default_capacity_provider_strategy {
capacity_provider = "FARGATE_SPOT"
weight = 3
}
}
# --- Task Definition ---
resource "aws_ecs_task_definition" "web_api" {
family = "web-api"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = 1024
memory = 2048
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([
{
name = "web-api"
image = "${aws_ecr_repository.web_api.repository_url}:latest"
essential = true
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "PORT", value = "8080" }
]
secrets = [
{
name = "DATABASE_URL"
valueFrom = aws_secretsmanager_secret.db_url.arn
}
]
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.web_api.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
}
])
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "ARM64"
}
}
# --- ECS Service ---
resource "aws_ecs_service" "web_api" {
name = "web-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.web_api.arn
desired_count = 3
capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
base = 2
}
capacity_provider_strategy {
capacity_provider = "FARGATE_SPOT"
weight = 3
}
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.web_api.arn
container_name = "web-api"
container_port = 8080
}
deployment_circuit_breaker {
enable = true
rollback = true
}
deployment_maximum_percent = 200
deployment_minimum_healthy_percent = 100
health_check_grace_period_seconds = 60
lifecycle {
ignore_changes = [task_definition]
}
}
# --- Auto Scaling ---
resource "aws_appautoscaling_target" "web_api" {
max_capacity = 20
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.web_api.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu_scaling" {
name = "cpu-target-tracking"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.web_api.resource_id
scalable_dimension = aws_appautoscaling_target.web_api.scalable_dimension
service_namespace = aws_appautoscaling_target.web_api.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 60.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "request_scaling" {
name = "request-count-tracking"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.web_api.resource_id
scalable_dimension = aws_appautoscaling_target.web_api.scalable_dimension
service_namespace = aws_appautoscaling_target.web_api.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ALBRequestCountPerTarget"
resource_label = "${aws_lb.main.arn_suffix}/${aws_lb_target_group.web_api.arn_suffix}"
}
target_value = 1000.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
# --- Security Group ---
resource "aws_security_group" "ecs_tasks" {
name = "ecs-tasks-sg"
description = "Allow inbound from ALB only"
vpc_id = aws_vpc.main.id
ingress {
protocol = "tcp"
from_port = 8080
to_port = 8080
security_groups = [aws_security_group.alb.id]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
# --- CloudWatch Logs ---
resource "aws_cloudwatch_log_group" "web_api" {
name = "/ecs/web-api"
retention_in_days = 30
}
resource "aws_cloudwatch_log_group" "ecs_exec" {
name = "/ecs/exec"
retention_in_days = 7
}
Lambda Function with API Gateway (Complete)
# --- Lambda Function ---
resource "aws_lambda_function" "api_handler" {
function_name = "api-handler"
role = aws_iam_role.lambda_exec.arn
handler = "handler.handler"
runtime = "python3.12"
architectures = ["arm64"]
timeout = 30
memory_size = 512
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.orders.name
ENVIRONMENT = "production"
LOG_LEVEL = "INFO"
}
}
vpc_config {
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.lambda.id]
}
tracing_config {
mode = "Active"
}
dead_letter_config {
target_arn = aws_sqs_queue.lambda_dlq.arn
}
layers = [
aws_lambda_layer_version.shared_utils.arn
]
tags = {
Environment = "production"
Service = "orders-api"
}
}
# --- Lambda Alias with Provisioned Concurrency ---
resource "aws_lambda_alias" "production" {
name = "production"
function_name = aws_lambda_function.api_handler.function_name
function_version = aws_lambda_function.api_handler.version
}
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api_handler.function_name
provisioned_concurrent_executions = 20
qualifier = aws_lambda_alias.production.name
}
# --- API Gateway (HTTP API) ---
resource "aws_apigatewayv2_api" "main" {
name = "orders-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://myapp.com"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Content-Type", "Authorization"]
max_age = 3600
}
}
resource "aws_apigatewayv2_stage" "production" {
api_id = aws_apigatewayv2_api.main.id
name = "production"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_gateway.arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
routeKey = "$context.routeKey"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
integrationError = "$context.integrationErrorMessage"
})
}
default_route_settings {
throttling_burst_limit = 1000
throttling_rate_limit = 500
}
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_alias.production.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "orders" {
api_id = aws_apigatewayv2_api.main.id
route_key = "ANY /orders/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api_handler.function_name
qualifier = aws_lambda_alias.production.name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}//"
}
# --- SQS Event Source Mapping ---
resource "aws_lambda_event_source_mapping" "sqs_orders" {
event_source_arn = aws_sqs_queue.orders.arn
function_name = aws_lambda_function.api_handler.arn
batch_size = 10
maximum_batching_window_in_seconds = 30
enabled = true
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = 50
}
}
# --- IAM Role for Lambda ---
resource "aws_iam_role" "lambda_exec" {
name = "lambda-api-handler-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "lambda_permissions" {
name = "lambda-permissions"
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query"
]
Resource = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*"
]
},
{
Effect = "Allow"
Action = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
]
Resource = aws_sqs_queue.orders.arn
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:::*"
},
{
Effect = "Allow"
Action = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords"
]
Resource = "*"
}
]
})
}
# --- Lambda Layer ---
resource "aws_lambda_layer_version" "shared_utils" {
filename = "layers/shared-utils.zip"
layer_name = "shared-utils"
compatible_runtimes = ["python3.12", "python3.11"]
description = "Shared utilities, data models, and typed boto3"
}
# --- DynamoDB Table ---
resource "aws_dynamodb_table" "orders" {
name = "orders"
billing_mode = "PAY_PER_REQUEST"
hash_key = "orderId"
attribute {
name = "orderId"
type = "S"
}
attribute {
name = "customerId"
type = "S"
}
global_secondary_index {
name = "customer-index"
hash_key = "customerId"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
tags = {
Environment = "production"
Service = "orders"
}
}
# --- Package Lambda Code ---
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/src"
output_path = "${path.module}/dist/lambda.zip"
}
---
Summary
Choosing between ECS and Lambda is not an either-or decision. The best architectures use both where each excels:
| Use Case | Recommendation |
|---|---|
| Steady-state API with predictable traffic | ECS Fargate |
| Event-driven processing (S3, SQS, schedules) | Lambda |
| WebSocket or long-lived connections | ECS |
| Low-traffic APIs or internal tools | Lambda (cost-effective) |
| ML inference with GPU | ECS on EC2 |
| File processing pipelines | Lambda + S3 triggers |
| Complex orchestration workflows | Step Functions + Lambda |
| High-throughput message processing | ECS (persistent consumers) or Lambda (batch SQS) |
Key Takeaways
The decision framework above should guide 90% of cases. For the remaining edge cases, prototype both approaches and compare real costs and latency before committing.
---
Frequently Asked Questions
What is the difference between AWS ECS and Lambda?
ECS runs Docker containers on managed infrastructure and is ideal for long-running services, while Lambda executes individual functions for up to 15 minutes per invocation. Use ECS for web servers, APIs with consistent traffic, and microservices needing persistent connections. Use Lambda for event-driven workloads, scheduled tasks, and sporadic traffic patterns.
How do I choose between ECS Fargate and EC2 launch type?
Use Fargate when you want zero infrastructure management and predictable per-task pricing, ideal for variable workloads. Choose EC2 launch type when you need GPU instances, specific instance types, or sustained high utilization where reserved instances are cheaper. Fargate costs more per compute-hour but eliminates cluster capacity management overhead.
Why is my Lambda function timing out?
Lambda timeouts usually indicate the function is waiting on an external resource like a database, API call, or S3 operation. Check your VPC configuration if the function needs internet access — it requires a NAT Gateway. Also verify connection pooling, increase timeout limits if the operation legitimately takes longer, and consider cold start impact.
How do I reduce AWS Lambda cold start times?
Use provisioned concurrency for latency-sensitive functions, keep deployment packages small by excluding unnecessary dependencies, and choose lighter runtimes like Python or Node.js over Java. Avoid placing Lambda in a VPC unless it needs access to VPC resources, as VPC-attached functions have longer cold starts.
What is the best way to connect ECS tasks to RDS?
Use AWS PrivateLink or place both ECS tasks and RDS in the same VPC with proper security group rules allowing traffic on the database port. Use Secrets Manager to store and rotate database credentials, and reference them in your ECS task definition. Enable IAM authentication for RDS to avoid managing passwords entirely.