Skip to main content
Cloud Engineering·30 min read

AWS ECS vs Lambda vs EKS — Picking Wrong Costs You 5x at Scale

Complete guide to AWS ECS (Fargate & EC2), Lambda functions, event-driven architectures, and the decision framework for choosing between containers and serverless.

DT

DevOps Engineer & Technical Writer

# 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.

Serverless Architecture — ECS Fargate + Lambda API Gateway REST / HTTP AWS Lambda Event-Driven Short-lived tasks ECS Fargate Long-Running Containers DynamoDB NoSQL S3 Object Storage RDS Relational DB CloudWatch Monitoring Event-Driven (Lambda) Long-Running (Fargate) Data Flow

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

FeatureEC2 Launch TypeFargate Launch Type
<strong>Infrastructure</strong>You manage EC2 instancesAWS manages infrastructure
<strong>Pricing</strong>Pay for EC2 instances (even idle)Pay per task (vCPU + memory per second)
<strong>Scaling</strong>Must scale instances + tasksOnly scale tasks
<strong>GPU Support</strong>YesLimited (recently added)
<strong>Max Task Size</strong>Up to instance limits16 vCPU, 120 GB memory
<strong>EBS Volumes</strong>Full EBS supportEphemeral storage (20-200 GB)
<strong>Networking</strong>Bridge, host, awsvpcawsvpc only
<strong>Startup Time</strong>Faster (instance pre-provisioned)Slower (infrastructure provisioned per task)
<strong>Spot Support</strong>EC2 Spot InstancesFargate Spot (up to 70% discount)
<strong>Compliance</strong>Full OS-level accessLimited (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

ModeDescriptionUse Case
<strong>awsvpc</strong>Each task gets its own ENI with private IPFargate (required), security groups per task
<strong>bridge</strong>Docker bridge networkingEC2 launch type, dynamic port mapping
<strong>host</strong>Container uses host network directlyMaximum network performance on EC2
<strong>none</strong>No external networkingBatch 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 ScaleOutCooldown shorter than ScaleInCooldown to respond fast but scale in cautiously
  • Use ALBRequestCountPerTarget metric for request-driven services
  • Monitor CapacityProviderReservation for 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

  • Cold Start: AWS provisions a new execution environment (download code, init runtime, run init code)
  • Warm Invocation: Reuses an existing execution environment (skips init, runs handler directly)
  • Freeze/Thaw: Between invocations, the environment is frozen; global variables persist across warm invocations
  • 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

    FactorImpact on Cold StartMitigation
    <strong>Runtime</strong>Java/C# > Python/Node.js > Rust/GoUse lightweight runtimes for latency-sensitive
    <strong>Package Size</strong>Larger = slowerMinimize dependencies, use layers
    <strong>Memory</strong>More memory = more CPU = faster initIncrease 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 initLazy-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

    DimensionChoose ECSChoose 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 connectionsStateless request-response
    <strong>Traffic Pattern</strong>Steady, predictable loadSpiky, 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 utilizationCheaper at low/variable utilization
    <strong>Team Expertise</strong>Container/Docker experienceEvent-driven development experience
    <strong>Deployment Size</strong>Large applications (GB-scale)Small handlers (< 250MB zipped)
    <strong>Resource Needs</strong>GPU, large memory (> 10GB), custom OSStandard CPU/memory workloads
    <strong>Networking</strong>Complex service mesh, persistent connectionsSimple 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

    ComponentCalculationMonthly Cost
    Requests1M × $0.20/1M requests$0.20
    Compute1M × 0.1s × 256MB ÷ 1024 × $0.0000166667/GB-s$0.42
    API Gateway1M × $1.00/1M (REST API)$1.00
    <strong>Total</strong><strong>~$1.62/month</strong>

    ECS Fargate Cost (minimum viable deployment)

    ComponentCalculationMonthly 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

    ComponentCalculationMonthly Cost
    Requests100M × $0.20/1M$20.00
    Compute100M × 0.1s × 0.25GB × $0.0000166667$41.67
    API Gateway100M × $1.00/1M (REST)$100.00
    <strong>Total</strong><strong>~$161.67/month</strong>

    ECS Fargate Cost (scaled for load)

    ComponentCalculationMonthly 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)

    ComponentCalculationMonthly Cost
    Requests10M invocations (free tier: 1M free)$1.80
    Compute10M × 0.5s × 512MB ÷ 1024 × $0.0000166667$41.67
    <strong>Total</strong><strong>~$43.47/month</strong>

    ECS Fargate Cost (Always-On Worker)

    ComponentCalculationMonthly 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 CaseRecommendation
    Steady-state API with predictable trafficECS Fargate
    Event-driven processing (S3, SQS, schedules)Lambda
    WebSocket or long-lived connectionsECS
    Low-traffic APIs or internal toolsLambda (cost-effective)
    ML inference with GPUECS on EC2
    File processing pipelinesLambda + S3 triggers
    Complex orchestration workflowsStep Functions + Lambda
    High-throughput message processingECS (persistent consumers) or Lambda (batch SQS)

    Key Takeaways

  • Start with Lambda for new services — simpler operations, faster iteration, pay-per-use
  • Graduate to ECS when you need persistent connections, exceed Lambda limits, or sustained load makes containers cheaper
  • Use Fargate unless you specifically need EC2 launch type features (GPU, custom AMI, host networking)
  • Always use infrastructure as code (Terraform/CDK) — both ECS and Lambda configs are complex enough to require version control
  • Design for observability from day one — CloudWatch Logs, X-Ray traces, and custom metrics are essential for both
  • Event-driven patterns (EventBridge + SQS + Lambda) provide the best combination of decoupling, resilience, and cost efficiency for asynchronous workloads
  • 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.