AWS Architecture

AWS Microservices on ECS Fargate: A Production Reference Architecture

Most teams don’t fail at microservices because they can’t write services — they fail because they can’t run twelve of them: twelve pipelines, twelve scaling policies, twelve things that page you at 2 a.m. Amazon ECS (Elastic Container Service) with the Fargate launch type is AWS’s answer for teams who want orchestration without operating a control plane or patching a node fleet: hand ECS an image and a task size, and AWS finds the compute, wires the networking, and replaces anything that dies.

This is a production reference architecture, not a hello-world: an Application Load Balancer splitting /orders and /catalog traffic to independent Fargate services, ECS Service Connect for east-west calls, a database per service (Aurora PostgreSQL and DynamoDB), an SQS-driven worker scaling on queue depth, images flowing through ECR with immutable tags, secrets injected from Secrets Manager, rolling and blue/green deployments, and Container Insights plus X-Ray watching all of it.

Everything is backed by real aws CLI commands, a complete task-definition JSON, and Terraform — including the two decisions that dominate design reviews: Fargate vs EC2, and ECS vs EKS.

What problem this solves

A monolith on EC2 works — until the payments module needs to scale independently of search, until one team’s deploy takes down another team’s feature, until a memory leak anywhere restarts everything. Microservices fix the coupling but multiply the operational surface. ECS on Fargate is the managed middle path: real orchestration (placement, health-driven replacement, discovery, deployment strategies) with zero servers to patch.

Pain in production What breaks without this architecture How the ECS Fargate design answers it
One deploy risks the whole app A bad release of any module restarts the monolith Independent services; deployment circuit breaker rolls back only the broken one
Can’t scale hot paths independently You scale the whole VM fleet for one hot endpoint Per-service target tracking (CPU or ALB requests/target)
Node patching, AMI baking, capacity planning Ops time sunk into ECS-optimized AMI upgrades, ASG tuning Fargate: no nodes; AWS patches the underlying platform
Shared database becomes the coupling point Schema change in one team blocks three others Database per service (Aurora for orders, DynamoDB for catalog)
Synchronous chains amplify failures Checkout fails because email service is slow SQS/SNS/EventBridge decouple; worker retries with DLQ
Hard-coded IPs / config drift between services Redeploys break east-west calls Cloud Map namespace + Service Connect stable names (http://catalog:8080)
Secrets in env files and AMIs Credential leaks, no rotation story Secrets Manager/SSM injected at task start via the execution role
No per-service blast-radius control One compromised process reaches everything One security group per service, one IAM task role per service

If you run more than two or three services with different scaling profiles and different teams shipping them, you are the target audience. One service, one team? Start simpler — your first container deployment on ECS Fargate — and grow into this reference.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already know Docker basics (build/tag/push), VPC fundamentals (subnets, route tables, security groups — refresher: AWS VPC, subnets and security groups explained), and IAM roles versus policies. If ECS itself is new, do the basics lesson first (ECS & Fargate basics); if you’re deciding whether ECS is even the right runtime, read ECS vs EKS vs Fargate: choosing your container path alongside this.

Where this sits: this article owns the middle of the stack — orchestration, Fargate compute, service wiring, deployments, scaling, observability. The edge tier has its own deep-dive in ALB vs NLB vs API Gateway, data-store selection in RDS vs DynamoDB vs Aurora compared, and the async tier connects onward to Lambda event-driven patterns when consumers get bursty enough that an always-on worker stops making sense.

Core concepts

The ECS mental model is four nouns. A cluster is a logical namespace for running tasks — with Fargate, little more than a name, an IAM boundary, and a Container Insights setting. A task definition is the versioned blueprint: image(s), CPU/memory, ports, secrets, log config, IAM roles. A task is a running instantiation of that blueprint — one or more co-scheduled containers sharing an ENI. A service is the supervisor that keeps N copies running, registers them with a load balancer, and orchestrates deployments when you point it at a new revision.

Concept One-line definition Scope/lives in Why it matters in production
Cluster Namespace + capacity settings for tasks Region/account IAM and Container Insights boundary; quota unit
Task definition Versioned JSON blueprint (image, size, roles, ports) Account (family:revision) Every deploy = new revision; rollback = old revision
Task Running copy of a task definition (1+ containers, 1 ENI) Cluster The unit that starts, fails, and gets replaced
Service Keeps desiredCount tasks healthy behind a TG Cluster Owns deployments, autoscaling hooks, circuit breaker
Container definition Per-container config inside a task def Task definition Port names here enable Service Connect
Launch type / capacity provider Where tasks run: FARGATE, FARGATE_SPOT, EC2 ASG Service/task Cost and interruption profile
Task execution role IAM role the ECS agent uses (pull image, fetch secrets, write logs) Task definition Wrong role = ResourceInitializationError
Task role IAM role your code assumes (S3, SQS, DynamoDB access) Task definition Least-privilege per service
Target group ALB’s set of task IPs + health checks ELB Health here decides task replacement
Cloud Map namespace DNS/HTTP registry of service names Region Backbone for Service Connect
Service Connect ECS-managed sidecar proxy + names (catalog:8080) Cluster/namespace East-west traffic with retries + telemetry
Platform version Fargate runtime version (LINUX 1.4.0 = LATEST) Task Controls ephemeral storage, ENI behaviour

Task lifecycle — what you’ll see during an incident

Knowing the fixed task states turns “it’s not starting” into a precise diagnosis.

State What’s happening If tasks are stuck here, suspect…
PROVISIONING Fargate is attaching the ENI in your subnet Subnet out of free IPs; ENI quota
PENDING Pulling image, fetching secrets, starting containers No route to ECR/Secrets Manager (NAT/endpoints); slow 2 GB images
ACTIVATING Registering with target group / Service Connect Wrong container port; TG in a different VPC
RUNNING Serving traffic; health checks apply App-level failures from here on
DEACTIVATINGSTOPPING Deregistering from TG, sending SIGTERM, waiting stopTimeout (default 30 s) 502s during deploys if app ignores SIGTERM
DEPROVISIONINGSTOPPED ENI detached; stoppedReason populated Always read stoppedReason first

The task definition, end to end

A production-shaped task definition for the orders service — awsvpc, ARM64 (cheaper), a named port for Service Connect, secrets from Secrets Manager and SSM, a container health check, and logs:

{
  "family": "orders",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "runtimePlatform": { "cpuArchitecture": "ARM64", "operatingSystemFamily": "LINUX" },
  "executionRoleArn": "arn:aws:iam::111122223333:role/ordersTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::111122223333:role/ordersTaskRole",
  "containerDefinitions": [
    {
      "name": "orders",
      "image": "111122223333.dkr.ecr.ap-south-1.amazonaws.com/orders:2026-07-07.4f9c1e2",
      "essential": true,
      "portMappings": [
        { "name": "orders-http", "containerPort": 8080, "protocol": "tcp", "appProtocol": "http" }
      ],
      "environment": [
        { "name": "SPRING_PROFILES_ACTIVE", "value": "prod" }
      ],
      "secrets": [
        { "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-south-1:111122223333:secret:prod/orders/db-AbCdEf:password::" },
        { "name": "PAYMENT_API_KEY", "valueFrom": "arn:aws:ssm:ap-south-1:111122223333:parameter/prod/orders/payment-api-key" }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"],
        "interval": 15, "timeout": 5, "retries": 3, "startPeriod": 30
      },
      "stopTimeout": 30,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/orders",
          "awslogs-region": "ap-south-1",
          "awslogs-stream-prefix": "orders"
        }
      }
    }
  ]
}

The fields that decide whether your 2 a.m. is quiet:

Field Values Default Production guidance
cpu / memory (task level) Fixed Fargate combos (matrix below) none — required Size from Container Insights p95, not guesses
runtimePlatform.cpuArchitecture X86_64, ARM64 X86_64 ARM64 ≈ 20% cheaper; needs multi-arch or ARM images
portMappings[].name free text unset Required for Service Connect — name every port
secrets[].valueFrom Secrets Manager/SSM ARN Resolved once at task start; rotation needs redeploy
healthCheck container-level command none Set it AND the ALB check; startPeriod ≥ boot time
stopTimeout 2–120 s (Fargate) 30 s Must exceed your graceful-shutdown drain time
essential true/false true (first) Non-essential sidecars (X-Ray) shouldn’t kill the task
ephemeralStorage.sizeInGiB 21–200 20 GiB free Only pay beyond 20 GiB; watch image + tmp usage
executionRoleArn vs taskRoleArn IAM roles Never merge them; execution = platform, task = your code

Register it, and the cluster around it:

# Cluster with Container Insights and Fargate + Spot capacity providers
aws ecs create-cluster \
  --cluster-name shopfast-prod \
  --capacity-providers FARGATE FARGATE_SPOT \
  --default-capacity-provider-strategy \
      capacityProvider=FARGATE,weight=1,base=2 \
      capacityProvider=FARGATE_SPOT,weight=3 \
  --settings name=containerInsights,value=enhanced

# Register the task definition from the JSON above
aws ecs register-task-definition --cli-input-json file://orders-taskdef.json

# See what revision you got
aws ecs describe-task-definition --task-definition orders \
  --query 'taskDefinition.{family:family,rev:revision,cpu:cpu,mem:memory}'

Fargate or EC2 launch type — and where EKS fits

The launch-type decision is the first fork in every ECS design review. Both run the same task definitions; what changes is who owns the compute.

Dimension Fargate EC2 launch type
Servers to manage None — AWS owns host patching You: AMI updates, ASG, capacity providers
Billing unit Per task: vCPU-seconds + GB-seconds (1-min minimum) Per instance-hour, regardless of utilization
Bin-packing efficiency Perfect (you pay task size only) Yours to engineer; idle headroom is waste
Task density / cost at scale Premium per vCPU (~40% over comparable EC2 on-demand) Cheaper if you keep fleets >70% utilized
Daemons (log agents, security agents) No DAEMON scheduling — must be sidecars DAEMON strategy supported
GPU / Windows / special instance types No GPUs; Windows supported (min 1 vCPU) Full instance-type catalogue incl. GPU
Ephemeral storage 20 GiB free, up to 200 GiB Instance/EBS — anything
Privileged containers, custom kernels Not allowed Allowed
Per-task isolation VM-level (Firecracker-style micro-VM isolation) Shared kernel per instance
Spot FARGATE_SPOT (~70% off, 2-min warning) EC2 Spot via capacity providers
Scaling granularity Per task — no instance warm-up Task + instance scaling (two layers to tune)
Quotas vCPU-based account quota (commonly 4,000 on-demand vCPUs; adjustable) EC2 instance quotas

Rule of thumb: below roughly 50–100 sustained vCPUs, Fargate’s premium is smaller than the salary-time of running node fleets. Above that, measure — a well-packed EC2/Graviton fleet with Savings Plans wins on raw compute, but only if someone owns AMI hygiene, drain logic, and utilization. Mixed estates are normal: Fargate for spiky/low-baseline services, EC2 capacity providers for the big steady ones.

Fargate task size matrix (valid CPU/memory combinations)

Fargate only accepts fixed shapes — this matrix is the sizing vocabulary for the whole platform:

Task vCPU Valid memory range Increment Typical use
0.25 vCPU 0.5, 1, 2 GB fixed steps Sidecars-light APIs, cron tasks
0.5 vCPU 1–4 GB 1 GB Small REST services (our orders svc)
1 vCPU 2–8 GB 1 GB JVM/Node services with headroom
2 vCPU 4–16 GB 1 GB Bigger APIs, queue workers
4 vCPU 8–30 GB 1 GB Heavy batch, in-memory caches
8 vCPU 16–60 GB 4 GB Rare — consider EC2 economics here
16 vCPU 32–120 GB 8 GB Largest shape; almost always EC2 territory

ECS vs EKS — the honest decision table

If… Pick Because
Team < ~10 engineers, no k8s skills in-house ECS Fargate Zero control-plane ops; IAM/ALB/CloudWatch native
You need Helm charts, operators, CRDs, Istio/Argo ecosystem EKS ECS has no CRD equivalent; ecosystem lives on k8s
Multi-cloud/on-prem portability is a hard requirement EKS Kubernetes API is the portability layer
You want AWS-native primitives, fewest moving parts ECS Service Connect, CodeDeploy, Cloud Map are built in
Platform team exists and wants to own upgrades EKS Someone must own the 3–4 k8s upgrades/yr + $0.10/hr/cluster
Compliance needs VM-isolated multi-tenant tasks fast ECS Fargate Per-task micro-VM isolation by default
Batch/GPU/ML scheduling with custom logic EKS (or Batch) Scheduler extensibility

The trap: choosing EKS “for the hiring signal”, then running it like ECS — no operators, no CRDs, one namespace — while paying the upgrade tax anyway. If the k8s API isn’t a requirement, ECS removes an entire failure domain. Deeper treatment: choose your container path.

Networking: one ENI, one security group per service

Fargate mandates awsvpc network mode: every task gets its own elastic network interface (ENI) with a private IP in your subnet — no port juggling, no bridge-mode NAT. This is the platform’s biggest security win: you write security-group rules between services exactly as you would between EC2 instances.

The reference layout: tasks in private subnets across two AZs with assignPublicIp=DISABLED, ALB in public subnets, and either a NAT gateway or VPC interface endpoints for the AWS APIs Fargate needs at task start (required for zero-egress designs).

Security group Inbound rule From Purpose
sg-alb TCP 443 0.0.0.0/0 (or CloudFront prefix list) Public HTTPS entry
sg-orders TCP 8080 sg-alb ALB → orders tasks only
sg-catalog TCP 8080 sg-alb, sg-orders ALB north-south + orders→catalog east-west
sg-worker (none inbound) Pull-based (SQS); egress only
sg-aurora TCP 5432 sg-orders Only orders reaches the orders DB
sg-vpce TCP 443 VPC CIDR (e.g. 10.20.0.0/16) Interface endpoints for ECR/logs/secrets

Chaining SGs by reference (source = another SG, never a CIDR) means scale-out needs zero rule changes — every new task ENI inherits its service’s SG. What Fargate must reach at task start — miss one in a private subnet and tasks die in PENDING with pull/init errors:

Endpoint Type Needed for
com.amazonaws.<region>.ecr.api Interface ECR auth (GetAuthorizationToken)
com.amazonaws.<region>.ecr.dkr Interface Image manifest/layer API
com.amazonaws.<region>.s3 Gateway (free) ECR image layers live in S3
com.amazonaws.<region>.logs Interface awslogs driver delivery
com.amazonaws.<region>.secretsmanager Interface secrets[] from Secrets Manager
com.amazonaws.<region>.ssm Interface secrets[] from Parameter Store

Cost math: interface endpoints run ~$0.01/hr per AZ (two AZs × 5 endpoints ≈ $73/month) vs one NAT gateway at ~$0.045/hr + $0.045/GB processed (≈ $33/month + data). Endpoints win on security (no internet path) and data-heavy pulls; NAT wins on simplicity when you need general egress anyway.

Traffic in: ALB path-based routing to services

One ALB fronts the platform. The HTTPS :443 listener evaluates rules by ascending priority; each rule matches conditions (path, host, header, query, source IP) and forwards to a target group (TG) holding task IPs (target-type: ip — mandatory for awsvpc).

Priority Condition Action → target group Notes
10 path-pattern: /orders, /orders/* orders-tg Both patterns — /orders alone doesn’t match /orders/
20 path-pattern: /catalog/* catalog-tg Case-sensitive matching
30 host-header: admin.shopfast.in admin-tg Host + path conditions can combine (AND)
40 path-pattern: /internal/* + source-ip: 10.20.0.0/16 internal-tg Defense in depth for private paths
default (no match) fixed-response 404 Never default to a real service — fail closed

Key ALB numbers to design against: 100 rules per listener (default, adjustable), 5 condition values per rule, 1,000 targets per ALB, idle timeout 60 s default (raise for long polls/SSE) — and WAF associates at the ALB, so one Web ACL protects every service behind it.

Target-group health checks drive ECS task replacement, so tune them deliberately:

TG setting Default Reference value Why
Health path / /healthz (cheap, no DB call) A DB-touching health check turns a DB blip into mass task kills
Interval / timeout 30 s / 5 s 15 s / 5 s Faster detection without flapping
Healthy / unhealthy threshold 5 / 2 2–3 / 2 Default 5×30 s = 2.5 min to come into service
Matcher 200 200 (never 200-499) Wide matchers hide broken apps
Deregistration delay 300 s 30–60 s 300 s makes every deploy drag; match your longest request
healthCheckGracePeriodSeconds (on the ECS service) 0 ≥ app boot time (e.g. 60 s) Stops ECS killing slow-booting tasks before first health pass
# Target group for the orders service (IP targets for awsvpc)
aws elbv2 create-target-group \
  --name orders-tg --protocol HTTP --port 8080 --vpc-id vpc-0ab12cd34ef56gh78 \
  --target-type ip --health-check-path /healthz \
  --health-check-interval-seconds 15 --healthy-threshold-count 2

# Path rule on the HTTPS listener
aws elbv2 create-rule \
  --listener-arn arn:aws:elasticloadbalancing:ap-south-1:111122223333:listener/app/shopfast/50dc6c495c0c9188/f2f7dc8efc522ab2 \
  --priority 10 \
  --conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/orders","/orders/*"]}}]' \
  --actions '[{"Type":"forward","TargetGroupArn":"arn:aws:elasticloadbalancing:ap-south-1:111122223333:targetgroup/orders-tg/943f017f100becff"}]'

Outgrowing path rules (per-team throttling, API keys, edge JWT authorizers) is an API Gateway conversation — see ALB vs NLB vs API Gateway.

East-west traffic: Cloud Map and ECS Service Connect

North-south is the ALB’s job; the harder question is how orders calls catalog. Four options, ascending in capability:

Option How it works Gives you Fails when / limits
Internal ALB per tier Second ALB, private subnets Familiar; L7 features ~$16+/mo per ALB; another hop; no per-service identity
Cloud Map service discovery (DNS) ECS registers task IPs as A/SRV records in a private hosted zone Simple catalog.shopfast.local names DNS TTL caching → stale IPs during deploys; no retries/metrics
ECS Service Connect ECS injects a managed sidecar proxy; names resolve via namespace; proxy load-balances, retries, emits metrics Stable names (http://catalog:8080), outlier ejection, per-route CloudWatch metrics, connection draining Only ECS↔ECS in namespaces; small CPU/mem overhead for the proxy; tasks must be (re)launched after enablement
Full mesh (App Mesh / Istio on EKS) Envoy everywhere, mTLS, traffic shifting Maximum control App Mesh is discontinued (EOL Sep 30, 2026) — don’t start new builds on it; Istio means EKS

Service Connect is the default answer for ECS microservices in 2026. It piggybacks on a Cloud Map HTTP namespace (no hosted zone needed) and is transparent to the client: your code calls http://catalog:8080, the sidecar resolves and load-balances across healthy catalog tasks, retries transient failures, and publishes RequestCount and per-target latency to CloudWatch with zero code change.

The wiring is three requirements — miss any and names won’t resolve: the port mapping must be named in the task definition ("name": "orders-http" above); both services join the same namespace, the server side declaring an alias (catalog:8080); and the config applies only to tasks launched after it’s set — force a new deployment when enabling it on an existing service.

aws ecs create-service \
  --cluster shopfast-prod \
  --service-name orders \
  --task-definition orders:12 \
  --desired-count 3 \
  --network-configuration 'awsvpcConfiguration={subnets=[subnet-0a1b2c3d,subnet-0e4f5a6b],securityGroups=[sg-0aa11bb22cc33dd44],assignPublicIp=DISABLED}' \
  --load-balancers 'targetGroupArn=arn:aws:elasticloadbalancing:ap-south-1:111122223333:targetgroup/orders-tg/943f017f100becff,containerName=orders,containerPort=8080' \
  --health-check-grace-period-seconds 60 \
  --deployment-configuration 'deploymentCircuitBreaker={enable=true,rollback=true},maximumPercent=200,minimumHealthyPercent=100' \
  --service-connect-configuration 'enabled=true,namespace=shopfast,services=[{portName=orders-http,discoveryName=orders,clientAliases=[{port=8080,dnsName=orders}]}]'

Per-service data: RDS and DynamoDB

The architectural rule that keeps microservices micro: each service owns its data store; no other service connects to it directly. Cross-service data access goes through the owning service’s API or through events — never through a shared schema. In the reference: orders owns Aurora PostgreSQL (Multi-AZ, 5432, reachable only from sg-orders via RDS Proxy) because order state wants transactions and joins; catalog owns a DynamoDB on-demand table because key-based reads at any scale with zero connection pools is exactly DynamoDB’s shape; the billing worker consumes the queue and writes invoices to its own Aurora schema.

The selection heuristic in one pass (full comparison: RDS vs DynamoDB vs Aurora): multi-row transactions and ad-hoc/reporting queries → Aurora, and plan for connection limits under scale-out; known key-based access with spiky, unbounded scale → DynamoDB, and respect the 400 KB item cap and hot-partition-key modelling up front. A service that genuinely needs both patterns is usually two services wearing one name.

The connection-pool trap is the #1 scale-out failure with containers + RDS. Thirty tasks × 20-connection pools = 600 connections marching toward max_connections. Fix it structurally: cap pools per task (5–10 is plenty at 0.5 vCPU) and put RDS Proxy between services and Aurora — it multiplexes thousands of client connections onto a small pinned pool, cuts app-visible failover time sharply, and supports IAM auth so tasks never hold a DB password. Cost ≈ $0.015/hr per vCPU of the target DB — cheap insurance against a 2 a.m. FATAL: too many connections.

DynamoDB pairs beautifully with Fargate — no connections at all; the task role IAM policy is the access control:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem"],
    "Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/catalog"
  }]
}

The async backbone: SQS, SNS, EventBridge

Synchronous chains are how microservices reinvent the distributed monolith: checkout → inventory → email, and suddenly checkout’s p99 includes the mail provider’s bad day. The reference keeps the synchronous graph shallow (client → service, at most one east-west hop) and pushes everything else through messages.

SQS SNS EventBridge
Model Queue (pull, 1 consumer group) Pub/sub fan-out (push) Event bus with content-based routing
Primary use here Work buffering for the billing worker One event → many subscribers (SQS, Lambda, email) Cross-service/cross-account event routing, SaaS + AWS events
Ordering Standard: best-effort; FIFO: strict per group FIFO topics → FIFO queues Not guaranteed
Delivery At-least-once (FIFO: exactly-once processing) At-least-once per subscriber At-least-once
Retry story Visibility timeout + redrive to DLQ Per-subscriber retry policy + DLQ 24-h retry with backoff + DLQ per target
Filtering Consumer-side Subscription filter policies Rule patterns on any event field
Throughput Standard: ~unlimited; FIFO: 300 msg/s (3,000 batched) per queue Very high Default bus: soft-limited (thousands/s, raisable)
Payload cap 256 KB (1 MB support arrived late 2025 — verify in your region) 256 KB 256 KB per entry
Targets/consumers 1 logical consumer Up to 12.5M subs/topic 5 targets per rule
Latency ms (long-poll up to 20 s wait) ms typically sub-second, not guaranteed

The pattern that covers 90% of platforms: orders publishes OrderPlaced to EventBridge; rules route it to the billing queue, the notifications queue, and an archive; each consumer owns its SQS queue (buffer + retry isolation). Queue settings that must not stay on defaults:

Setting Default Set to Or else
Visibility timeout 30 s ≥ 6× worker processing time Duplicate processing mid-work
Redrive policy / maxReceiveCount none DLQ after 3 receives Poison message loops forever, blocks nothing but burns cost
WaitTimeSeconds (long polling) 0 20 s Empty-receive API spam (real money at scale)
Retention 4 days 14 days on the DLQ Losing evidence before Monday’s investigation
Alarm none ApproximateAgeOfOldestMessage > SLA; DLQ depth > 0 Silent backlog until customers notice

The worker service runs no load balancer — just desiredCount pollers scaling on backlog per task (ApproximateNumberOfMessagesVisible ÷ RunningTaskCount as a custom metric): near zero at night, dozens during a sale. For when a Lambda consumer beats a Fargate worker (bursty, short, sub-15-min work), see Lambda event-driven patterns.

Shipping images: ECR and the pipeline

Every running task traces back to an image in Amazon ECR; the difference between a calm platform and incident archaeology is image discipline.

ECR setting Value in this reference Why
Tag mutability IMMUTABLE :latest overwritten twice a day = you can’t say what’s running
Tag scheme <date>.<git-sha> (e.g. 2026-07-07.4f9c1e2) Human-sortable + traceable to a commit
Scan on push Enhanced (Amazon Inspector) CVE gate before deploy, continuous rescan after
Lifecycle policy Keep last 30 per repo; expire untagged in 7 days Storage is $0.10/GB-month — old layers add up
Repo per service orders, catalog, billing-worker Per-repo IAM + lifecycle + scan policy
Pull-through cache For public.ecr.aws + Docker Hub bases Kills Docker Hub 429 rate-limit failures at 6 a.m. scale-out
aws ecr create-repository --repository-name orders \
  --image-tag-mutability IMMUTABLE \
  --image-scanning-configuration scanOnPush=true

# Build (multi-arch for our ARM64 tasks), push, and roll the service
aws ecr get-login-password --region ap-south-1 | \
  docker login --username AWS --password-stdin 111122223333.dkr.ecr.ap-south-1.amazonaws.com

docker buildx build --platform linux/arm64 \
  -t 111122223333.dkr.ecr.ap-south-1.amazonaws.com/orders:2026-07-07.4f9c1e2 --push .

The pipeline is boring on purpose: build → scan gate → register-task-definition with the new tag → update-service. Because a revision is immutable, deploy = new revision, rollback = point back at the old one. Never deploy by re-pushing a mutable tag — you lose the audit trail and the rollback lever.

Deployments: rolling by default, blue/green when it hurts

ECS gives you two first-class strategies (a built-in BLUE_GREEN option also landed in deploymentConfiguration mid-2025 — verify regional availability; CodeDeploy remains the battle-tested path).

Rolling (ECS-native) Blue/green (CodeDeploy)
Mechanics Replace tasks in place under one TG, honouring min/max % Full green task set on a second TG; listener flips traffic
Extra infra None 2 target groups, CodeDeploy app + deployment group, optional test listener
Traffic shifting None (task-by-task) AllAtOnce, linear, canary configs
Instant rollback Circuit breaker redeploys old revision (minutes) Listener flips back to blue (seconds)
Test-before-traffic No Yes — green reachable on a test listener (e.g. :9001) first
Cost during deploy Up to maximumPercent extra tasks ~2× task count during the window
Choose when Most services, most days Payments-grade services, schema-coupled releases, canary needs

Rolling behaviour is governed by two percentages: minimumHealthyPercent (default 100) and maximumPercent (default 200) — with 3 tasks: “start up to 3 new ones, never dip below 3 healthy.” Always pair it with the deployment circuit breaker (enable=true,rollback=true): after repeated task-start failures (threshold = half the desired count, min 3, max 200) it marks the deployment failed and auto-rolls back to the last good revision — a bad image costs 4 minutes instead of an evening.

CodeDeploy blue/green shifts traffic with named configs — the real ones you’ll reference: CodeDeployDefault.ECSAllAtOnce (flip 100% at once, still with instant rollback), ECSCanary10Percent5Minutes / ECSCanary10Percent15Minutes (10% first, the rest after the bake), and ECSLinear10PercentEvery1Minutes / ECSLinear10PercentEvery3Minutes (+10% per interval). Attach CloudWatch alarms (5xx rate, p99 latency) to the deployment group and CodeDeploy rolls back automatically when the canary trips them. The deploy artifact is an appspec.yaml:

version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "arn:aws:ecs:ap-south-1:111122223333:task-definition/orders:13"
        LoadBalancerInfo:
          ContainerName: "orders"
          ContainerPort: 8080

One more deploy-time 502 source: graceful shutdown. On replacement, ECS deregisters the task (ALB stops new sends; in-flight continue up to the deregistration delay), sends SIGTERM, waits stopTimeout (default 30 s), then SIGKILL. Your app must catch SIGTERM and drain — PID-1 shell wrappers that swallow signals are the classic culprit; use exec in entrypoints.

Configuration and secrets

Plain config rides in environment; anything sensitive rides in secrets[] — fetched by the execution role at task start, injected as env vars, never visible in the task definition, console, or describe-tasks.

Secrets Manager SSM Parameter Store
Cost $0.40/secret/month + $0.05/10k calls Standard: free (4 KB); Advanced: $0.05/param/month (8 KB)
Rotation Built-in Lambda rotation (RDS/Aurora templates) None built-in
Cross-region replication Yes No (copy yourself)
Random generation, JSON keys (:password::) Yes No key extraction — one value per param
Use for DB creds, API keys that rotate Feature flags, non-secret config, low-churn tokens

Two production gotchas: (1) secrets resolve once, at task start — rotation does nothing to running tasks until an update-service --force-new-deployment (bake it into the rotation Lambda); (2) the execution role needs secretsmanager:GetSecretValue plus kms:Decrypt — putting those on the task role is the most common ResourceInitializationError on first deploy.

Autoscaling: track a target, don’t chase alarms

ECS service scaling is Application Auto Scaling on DesiredCount. Target tracking is the default tool: pick a metric and a target value; AWS manages the alarms both directions.

Service Metric Target Scale-out / scale-in cooldown Why this metric
orders (CPU-bound API) ECSServiceAverageCPUUtilization 60% 60 s / 300 s Headroom for spikes while scaling
catalog (I/O-bound API) ALBRequestCountPerTarget ~800 req/target/min 60 s / 300 s CPU barely moves on I/O waits; requests do
billing worker Custom: backlog per task 10 msgs/task 60 s / 300 s Queue depth is the real demand signal
all + scheduled scaling for known peaks (9 a.m., sale start) Pre-warm before the wave, don’t react to it
resource "aws_appautoscaling_target" "orders" {
  service_namespace  = "ecs"
  resource_id        = "service/shopfast-prod/orders"
  scalable_dimension = "ecs:service:DesiredCount"
  min_capacity       = 3
  max_capacity       = 30
}

resource "aws_appautoscaling_policy" "orders_cpu" {
  name               = "orders-cpu-60"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.orders.service_namespace
  resource_id        = aws_appautoscaling_target.orders.resource_id
  scalable_dimension = aws_appautoscaling_target.orders.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    target_value       = 60
    scale_out_cooldown = 60
    scale_in_cooldown  = 300
  }
}

Rules that keep scaling boring: min_capacity ≥ 2 (one per AZ) for anything user-facing; scale in slower than out (the asymmetric cooldowns above); protect long-running jobs with ECS task scale-in protection ($ECS_AGENT_URI/task-protection/v1/state from inside the task); and remember target tracking never acts on missing data — a worker that crashes and stops emitting backlog metrics won’t scale itself back up.

Observability: Container Insights, logs, X-Ray

Three signals, all native:

Metrics — CloudWatch Container Insights (containerInsights=enhanced on the cluster) publishes task- and service-level CPU, memory, network, and storage plus deployment state. Logs — the awslogs driver ships stdout/stderr to a log group per service (/ecs/orders); log JSON and CloudWatch Logs Insights becomes your grep. Traces — X-Ray stitches the request path (ALB → orders → catalog → Aurora) via an ADOT or amazon/aws-xray-daemon sidecar (UDP 2000); default sampling (1 req/s + 5%) shows structure without paying for every request.

The alarm set that catches 95% of pages before customers do, per service: ALB HTTPCode_Target_5XX_Count above ~1% of requests for two 1-minute periods; TargetResponseTime p99 above 2× its normal level; HealthyHostCount < 2; Container Insights RunningTaskCount short of DesiredTaskCount for 5 minutes (task churn); memory utilization > 85% sustained (OOM kills arrive at 100%); SQS ApproximateAgeOfOldestMessage above the processing SLA plus any message visible on a DLQ; and an EventBridge rule on SERVICE_DEPLOYMENT_FAILED deployment state changes that pages the owning team directly.

Architecture at a glance

Read the diagram left to right, the direction a request travels. A client resolves api.shopfast.in through Route 53 (alias to the ALB) and lands on the ALB behind AWS WAF (managed rule groups plus a 2k/5-min rate limit). Listener rules path-route /orders/* and /catalog/* to separate target groups, each backed by an independent Fargate service — every task with its own ENI and security group, so sg-aurora literally cannot accept a connection that didn’t come from an orders task. East-west, orders calls catalog by its Service Connect name (catalog:8080) through the managed proxy rather than back out through the ALB.

Each service owns its data: orders writes to Aurora PostgreSQL (Multi-AZ, 5432, via RDS Proxy); catalog reads a DynamoDB on-demand table. Order events flow to SQS (maxReceiveCount 3 DLQ) where the billing worker — no load balancer, scaled on backlog-per-task — consumes them. On the ops rail, ECR feeds immutable scan-on-push images to all three services and CloudWatch (Container Insights + X-Ray) watches the whole path. The numbered badges mark the five places this architecture most often breaks; the legend beneath the diagram narrates symptom, confirmation, and fix for each.

ECS Fargate microservices reference: client over HTTPS 443 to Route 53, aliased to a WAF-fronted ALB whose listener rules path-route /orders/* and /catalog/* to separate Fargate services (orders ×3 tasks on 8080, catalog ×2 via Service Connect at catalog:8080, and an SQS-polling billing worker scaling on queue depth); data tier with Aurora PostgreSQL Multi-AZ owned by orders and a DynamoDB on-demand catalog table; async/ops tier with the order-events SQS queue plus DLQ, ECR with immutable scan-on-push images, and CloudWatch Container Insights with X-Ray; numbered badges mark ALB target-health 502s, task start failures from image pull or secrets, Service Connect resolution issues, DB connection exhaustion on scale-out, and queue backlog with poison messages.

Real-world scenario

ShopFast, a Bengaluru D2C electronics retailer, ran a Django monolith on four EC2 m5.large instances behind a classic ELB. Two problems forced the move: flash-sale traffic (baseline ~40 req/s, peaks ~1,100 req/s) meant permanently paying for peak, and the checkout and catalog teams blocked each other’s releases — eleven deploys a month, four rollbacks, every rollback all-or-nothing.

Over eight weeks they carved three services onto a shopfast-prod cluster: orders (0.5 vCPU/1 GB, min 3 tasks, Aurora PG), catalog (0.5 vCPU/1 GB, min 2, DynamoDB on-demand), and a billing worker (0.25 vCPU/512 MB on FARGATE_SPOT) consuming an order-events queue fed by EventBridge. The ALB path-routed /orders and /catalog; everything else still hit the monolith — a strangler pattern, not a big bang.

The first flash sale after cutover exposed two design debts nine minutes apart. At 12:00, catalog autoscaling (CPU target 60%) hadn’t reacted — catalog is I/O-bound, CPU sat at 31% while requests-per-target tripled; p95 went from 210 ms to 2.4 s. At 12:09, orders tasks began failing health checks: Aurora threw FATAL: remaining connection slots are reserved — 24 scaled-out tasks × 25-connection Django pools had blown past max_connections. The on-call switched catalog’s scaling metric to ALBRequestCountPerTarget=700 (five-minute change, immediate relief) and capped the orders pool at 8; RDS Proxy went in the next week and the error class disappeared.

After one quarter: deploys up from 11 to 41/month (three circuit-breaker rollbacks, all automatic, none customer-visible); sale-day capacity scales 5→28 orders tasks and back in under 20 minutes; compute spend down from ₹31,000/month (4× m5.large 24×7 + headroom) to ~₹19,500/month — and zero AMI-patching weekends. Their retro one-liner is this article’s thesis: “The migration was 20% containers and 80% health checks, pools, and metrics.”

Advantages and disadvantages

Advantages Disadvantages
No node fleet: no AMIs, no patching, no capacity planning per host Per-vCPU premium (~40%+ over well-utilized EC2/Graviton on-demand)
VM-grade task isolation by default (safer multi-team clusters) No GPUs; no privileged containers; no DAEMON scheduling
Per-service everything: SG, IAM role, scaling policy, deploy cadence More moving parts than a monolith — you now run a platform
Deployment safety nets built in (circuit breaker, CodeDeploy canaries) Cold task starts (image pull + ENI attach) are 30–90 s, not instant
Service Connect gives retries/telemetry without a mesh to operate ECS-only: no CRDs/operators/Helm ecosystem; AWS lock-in is real
Scales to zero-ish for workers; Spot cuts async compute ~70% Fargate ephemeral storage caps at 200 GiB; awkward for stateful loads
Native wiring to ALB, IAM, CloudWatch, ECR — few third-party parts Per-task ENIs consume subnet IPs — /24s fill up fast at scale

The disadvantages cluster into two honest costs: money at high sustained scale (revisit EC2 capacity providers past ~100 steady vCPUs) and organizational surface — twelve services means twelve owners; if you don’t have the teams, don’t manufacture the services.

Hands-on lab

Deploys a real path-routed Fargate service behind an ALB in ~30 minutes. Cost: a few rupees if torn down within the hour (ALB + two 0.25 vCPU tasks ≈ $0.05/hr; new accounts get 750 free ALB-hours/month for 12 months). Uses the default VPC’s public subnets with public IPs to avoid NAT/endpoint costs — a lab-only shortcut, never production.

# 0. Variables (adjust region/account)
export AWS_REGION=ap-south-1
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
VPC=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)
SUBNETS=$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC --query 'Subnets[0:2].SubnetId' --output text | tr '\t' ',')

# 1. ECR repo + a public demo image pushed into it
aws ecr create-repository --repository-name lab-web --image-tag-mutability IMMUTABLE
aws ecr get-login-password | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com
docker pull public.ecr.aws/nginx/nginx:stable
docker tag public.ecr.aws/nginx/nginx:stable $ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/lab-web:v1
docker push $ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/lab-web:v1

# 2. Cluster + log group + execution role (AmazonECSTaskExecutionRolePolicy)
aws ecs create-cluster --cluster-name lab --settings name=containerInsights,value=enabled
aws logs create-log-group --log-group-name /ecs/lab-web
aws iam create-role --role-name labEcsExecRole --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name labEcsExecRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
# 3. Register the task definition (80 = nginx's port)
cat > taskdef.json <<EOF
{
  "family": "lab-web", "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"], "cpu": "256", "memory": "512",
  "executionRoleArn": "arn:aws:iam::$ACCOUNT:role/labEcsExecRole",
  "containerDefinitions": [{
    "name": "web", "image": "$ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/lab-web:v1",
    "essential": true,
    "portMappings": [{ "name": "web-http", "containerPort": 80, "protocol": "tcp" }],
    "logConfiguration": { "logDriver": "awslogs", "options": {
      "awslogs-group": "/ecs/lab-web", "awslogs-region": "$AWS_REGION", "awslogs-stream-prefix": "web" } }
  }]
}
EOF
aws ecs register-task-definition --cli-input-json file://taskdef.json

# 4. Security groups: ALB open on 80; tasks only from the ALB SG
ALB_SG=$(aws ec2 create-security-group --group-name lab-alb-sg --description "lab alb" --vpc-id $VPC --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $ALB_SG --protocol tcp --port 80 --cidr 0.0.0.0/0
TASK_SG=$(aws ec2 create-security-group --group-name lab-task-sg --description "lab tasks" --vpc-id $VPC --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $TASK_SG --protocol tcp --port 80 --source-group $ALB_SG

# 5. ALB + target group (ip targets) + listener with a path rule
ALB=$(aws elbv2 create-load-balancer --name lab-alb --subnets ${SUBNETS//,/ } --security-groups $ALB_SG --query 'LoadBalancers[0].LoadBalancerArn' --output text)
TG=$(aws elbv2 create-target-group --name lab-web-tg --protocol HTTP --port 80 --vpc-id $VPC --target-type ip --health-check-path / --query 'TargetGroups[0].TargetGroupArn' --output text)
LISTENER=$(aws elbv2 create-listener --load-balancer-arn $ALB --protocol HTTP --port 80 \
  --default-actions '[{"Type":"fixed-response","FixedResponseConfig":{"StatusCode":"404","ContentType":"text/plain","MessageBody":"no route"}}]' \
  --query 'Listeners[0].ListenerArn' --output text)
aws elbv2 create-rule --listener-arn $LISTENER --priority 10 \
  --conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/","/index.html","/web/*"]}}]' \
  --actions "[{\"Type\":\"forward\",\"TargetGroupArn\":\"$TG\"}]"

# 6. The service: 2 tasks, circuit breaker on
aws ecs create-service --cluster lab --service-name web --task-definition lab-web \
  --desired-count 2 --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[$TASK_SG],assignPublicIp=ENABLED}" \
  --load-balancers "targetGroupArn=$TG,containerName=web,containerPort=80" \
  --health-check-grace-period-seconds 30 \
  --deployment-configuration 'deploymentCircuitBreaker={enable=true,rollback=true},maximumPercent=200,minimumHealthyPercent=100'

# 7. Watch it come up, then hit it
aws ecs describe-services --cluster lab --services web --query 'services[0].{running:runningCount,desired:desiredCount,events:events[0].message}'
DNS=$(aws elbv2 describe-load-balancers --load-balancer-arns $ALB --query 'LoadBalancers[0].DNSName' --output text)
curl -s http://$DNS/ | head -4        # expect the nginx welcome HTML
curl -s -o /dev/null -w "%{http_code}\n" http://$DNS/nope   # expect 404 (fail-closed default)

Try the platform behaviours: scale with update-service --desired-count 4 (watch two new ENIs register), then a rolling deploy — push :v2, register a new revision, update-service --task-definition lab-web:2, and watch deployments go PRIMARY/ACTIVE and back to one.

# 8. Teardown — order matters
aws ecs update-service --cluster lab --service web --desired-count 0
aws ecs delete-service --cluster lab --service web --force
aws elbv2 delete-load-balancer --load-balancer-arn $ALB && sleep 30
aws elbv2 delete-target-group --target-group-arn $TG
aws ec2 delete-security-group --group-id $TASK_SG && aws ec2 delete-security-group --group-id $ALB_SG
aws ecs delete-cluster --cluster lab
aws ecr delete-repository --repository-name lab-web --force
aws logs delete-log-group --log-group-name /ecs/lab-web
aws iam detach-role-policy --role-name labEcsExecRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam delete-role --role-name labEcsExecRole

Common mistakes & troubleshooting

First move in any ECS incident: read the stopped task, not the service page.

aws ecs list-tasks --cluster shopfast-prod --service-name orders --desired-status STOPPED
aws ecs describe-tasks --cluster shopfast-prod --tasks <task-arn> \
  --query 'tasks[0].{reason:stoppedReason,code:stopCode,containers:containers[].{name:name,exit:exitCode,reason:reason}}'

The playbook — symptom to fix:

# Symptom Root cause Confirm Fix
1 Tasks flip PENDING→STOPPED, CannotPullContainerError: … i/o timeout Private subnet with no NAT/VPC endpoints to ECR describe-tasks stoppedReason; route table has no 0.0.0.0/0 or endpoints Add ecr.api, ecr.dkr, S3 gateway, logs endpoints (or NAT)
2 ResourceInitializationError: unable to pull secrets or registry auth Execution role missing secretsmanager:GetSecretValue/kms:Decrypt, or no path to the endpoint Same query; IAM policy simulator on the execution role Grant on execution role (not task role); add secretsmanager endpoint
3 Service creates tasks, ALB kills them ~2 min in; loop forever Task SG doesn’t allow ALB SG on container port, or health path wrong TG → Targets: Health status: unhealthy, reason Request timed out (SG) vs 404 (path) Ingress sg-alb → sg-svc:8080; point check at /healthz, matcher 200
4 Slow-booting app killed before first health pass healthCheckGracePeriodSeconds = 0 Task stops with Task failed ELB health checks; app logs show mid-boot Grace period ≥ boot time; container healthCheck.startPeriod too
5 502/504 spikes exactly during deploys App ignores SIGTERM (shell PID 1), or deregistration delay ≫ drain kubectl-style check: docker inspect entrypoint uses sh -c; ALB 502 timing matches task stop exec your process as PID 1; handle SIGTERM; deregistration delay 30–60 s; stopTimeout ≥ drain
6 Task killed, exit code 137, OutOfMemoryError: container killed due to memory usage Task memory hard limit hit (JVM/Node heap not container-aware) describe-tasks container reason; Container Insights MemoryUtilized at 100% Right-size task memory; set -XX:MaxRAMPercentage=75 / --max-old-space-size
7 orders can’t reach http://catalog:8080 though both run Port mapping unnamed, different namespaces, or tasks pre-date Service Connect enablement aws ecs describe-services … --query 'services[0].serviceConnectConfiguration'; task launch time vs SC enable time Name ports, same namespace, --force-new-deployment both services
8 Random task kills at off-peak; stopCode: SpotInterruption Whole service on FARGATE_SPOT describe-tasks stopCode; capacity provider strategy shows no base base=N on FARGATE for the floor; Spot only for interrupt-tolerant workers
9 Scale-out bursts then RunTask throttles: “reached the limit on … vCPUs” Fargate account vCPU quota Service Quotas console → “Fargate On-Demand vCPU” usage graph Request quota increase; stagger max_capacity across services
10 FATAL: too many connections on Aurora as tasks scale Per-task pools × task count > max_connections Aurora DatabaseConnections metric vs task count Cap pools (5–10/task); insert RDS Proxy; scale on requests not CPU
11 Deploy “succeeds” but old code still serving Pushed over a mutable tag; task def still pins old digest/tag describe-task-definition image tag vs ECR image pushed-at Immutable tags; new revision per deploy; never redeploy-by-repush
12 Worker scales to max but queue age still climbs Visibility timeout < processing time → redelivery storm ApproximateAgeOfOldestMessage up while NumberOfMessagesReceived ≫ deletes Visibility ≥ 6× processing; DLQ maxReceiveCount 3; idempotent handlers

Error strings you’ll meet in stoppedReason, decoded:

stoppedReason / stopCode Meaning First place to look
CannotPullContainerError … not found Image/tag doesn’t exist (typo, race with pipeline) ECR repo tags
CannotPullContainerError … 429 Too Many Requests Docker Hub rate limit on base image at scale-out ECR pull-through cache
ResourceInitializationError … failed to validate logger args Log group missing and no create permission aws logs create-log-group / exec role perms
Essential container in task exited Your app crashed (this is your bug, not ECS) Container exit code + CloudWatch logs
Task failed ELB health checks in target-group … ALB declared it unhealthy TG health reason column
ServiceSchedulerInitiated (stopCode) Normal: deploy/scale-in replacement Nothing — expected
SpotInterruption (stopCode) Fargate Spot reclaim (2-min warning was sent) Capacity provider mix

Best practices

  1. One service = one repo, one task family, one SG, one task role, one dashboard. Uniformity is what makes twelve services operable by three people.
  2. Pin images by immutable tag and treat task-definition revisions as the deploy ledger — rollback is update-service --task-definition orders:12, never a rebuild.
  3. Always enable the deployment circuit breaker with rollback — one flag that converts bad deploys from incidents into log lines.
  4. Health checks must be cheap and honest: /healthz without downstream calls for the ALB; deep dependency checks belong in alarms, not in the thing that kills tasks.
  5. Scale asymmetrically (fast out, slow in), keep min_capacity ≥ 2 across AZs, pre-schedule known peaks.
  6. Run ARM64 (Graviton) by default — ~20% cheaper for a one-line runtimePlatform change and a multi-arch build.
  7. FARGATE_SPOT for workers, with a FARGATE base for the floor — never 100% Spot for anything user-facing.
  8. Secrets via secrets[] only, execution role scoped per service, rotation wired to force a new deployment.
  9. Every service gets its own log group with retention set (30–90 days) — unbounded log groups are the silent ₹ leak of container platforms.
  10. Keep the synchronous call graph ≤ 2 hops; everything else through EventBridge/SQS with DLQs and queue-age alarms.
  11. Standardize a golden dashboard per service (RunningVsDesired, CPU/mem p95, TG 5xx, p99, deployment events) and stamp it out via IaC.
  12. Load-test the scaling policy, not just the app — the first flash sale is the wrong time to learn your cooldowns.

Security notes

The security model hangs on separating the two IAM roles and treating every service as its own trust boundary:

Control Implementation Common failure
Execution role (platform) ECR pull + logs + specific secret ARNs + KMS decrypt Wildcard secretsmanager:* shared by all services
Task role (app) Per-service, least privilege (dynamodb:GetItem on one table, sqs:SendMessage on one queue) One fat “app role” reused everywhere
Network Private subnets, assignPublicIp=DISABLED, SG-to-SG references, VPC endpoints Tasks in public subnets “temporarily”, forever
Edge WAF managed rules + rate limiting on the ALB; TLS 1.2+ policy on the listener WAF in count mode since launch day
Images ECR scan-on-push (Inspector), immutable tags, minimal distroless/alpine bases, non-root USER Root containers with a full OS + shells
Data KMS CMKs on Aurora/DynamoDB/SQS/secrets; RDS Proxy IAM auth removes DB passwords Default keys everywhere, passwords in env vars
Exec access aws ecs execute-command (SSM-based) with CloudTrail + logging on the cluster SSH-ish sidecars, or ECS Exec enabled with no audit
Runtime threat detection GuardDuty Runtime Monitoring for ECS/Fargate No visibility into what runs inside tasks

Fargate itself removes a whole class of concerns — no shared kernel between tenants (per-task micro-VM), no host agents to patch, task roles served from a task-scoped endpoint. The residual risk is almost entirely your image, your IAM scoping, and your egress rules.

Cost & sizing

Fargate bills per second (1-minute minimum) on task-level vCPU and memory. On-demand Linux in us-east-1 (Mumbai runs a few percent higher):

Pricing lever Rate (us-east-1, indicative) Note
vCPU-hour (x86) $0.04048 The dominant term
GB-hour (x86) $0.004445 Memory is ~9× cheaper than vCPU
vCPU-hour (ARM64/Graviton) $0.03238 ~20% saving for a config line
GB-hour (ARM64) $0.00356
Fargate Spot ~70% below on-demand 2-min interruption warning; workers only
Ephemeral storage First 20 GiB free; ~$0.000111/GiB-hr beyond Rarely material
Compute Savings Plans Apply to Fargate (rates vary by term) Commit only to your measured baseline
ALB ~$0.0225/hr + LCUs Shared across all services — a platform win
NAT vs endpoints NAT $0.045/hr + $0.045/GB; endpoint $0.01/hr/AZ Image pulls through NAT add up

The reference stack priced (on-demand x86, 730 hrs/month):

Component Shape Monthly (USD) Monthly (≈INR @ ₹88)
orders ×3 0.5 vCPU / 1 GB each 1.5×$29.55 + 3×$3.24 = $54.05 ₹4,760
catalog ×2 0.5 vCPU / 1 GB $36.03 ₹3,170
billing worker ×1 (Spot) 0.25 vCPU / 0.5 GB ≈ $2.40 ₹210
ALB base + modest LCU ≈ $22 ₹1,940
Compute + LB total (before autoscaling headroom) ≈ $115 ≈ ₹10,100

Switch the APIs to ARM64 and the compute line drops ~20% (≈ $92/₹8,100). Always-on EC2 sized for peak (say 6 × m5.large) would run ~$420/month — Fargate wins not on unit price but because baseline is small and peak is rented by the minute.

Right-sizing rules: read Container Insights p95 CPU/memory after two weeks — CPU p95 < 25% means halve the vCPU tier (tune JVM MaxRAMPercentage before trusting memory graphs). The costs that sneak up: CloudWatch log ingestion ($0.50–0.67/GB dwarfs task cost for chatty debug logging), NAT data processing on image-heavy deploy days, and enhanced Container Insights on high-cardinality clusters.

Interview & exam questions

Mapped to AWS SAA-C03, DVA-C02, and DOP-C02:

  1. Q: Task definition vs task vs service — what does each own? A: The task definition is the immutable versioned blueprint (image, CPU/mem, ports, roles, secrets). A task is one running copy with its own ENI. A service is the controller keeping desiredCount tasks healthy, registering them with target groups, and orchestrating deployments between task-definition revisions.

  2. Q: Why does Fargate require awsvpc mode, and what’s the practical consequence? A: There’s no host to bridge through — each task gets its own ENI and private IP. Consequences: security groups apply per task (per service), target groups use ip targets, and each task consumes a subnet IP, so subnets must be sized for peak task count.

  3. Q: Execution role vs task role — a task fails with ResourceInitializationError: unable to pull secrets. Which role and why? A: The execution role — it’s what the ECS agent uses before your code runs: ECR pull, CloudWatch log delivery, and resolving secrets[]. The task role is assumed by your application code for its AWS calls (S3, SQS, DynamoDB). Secrets-at-start failures are always execution-role (or endpoint/network) issues.

  4. Q: When would you pick the EC2 launch type over Fargate? A: GPUs or special instance types, daemon-set-style agents, privileged containers, very large steady-state fleets where high utilization plus Savings Plans beats Fargate’s premium, or >200 GiB local storage. Otherwise Fargate’s zero node-ops usually wins.

  5. Q: How does ALB path-based routing decide which microservice gets a request? A: The listener evaluates rules in ascending priority; the first rule whose conditions (path/host/header/query/source-IP, up to 5 values each) all match forwards to its target group. No match hits the default action — which should be a fixed 404, not a real service.

  6. Q: What is ECS Service Connect and how does it differ from plain Cloud Map service discovery? A: Cloud Map DNS just publishes task IPs as A/SRV records — clients cache TTLs and get no retries or metrics. Service Connect injects an ECS-managed sidecar proxy that resolves namespace aliases (catalog:8080), load-balances across healthy tasks, retries, drains connections during deploys, and emits per-route CloudWatch metrics.

  7. Q: Explain the ECS deployment circuit breaker. A: During a rolling deploy, if newly launched tasks repeatedly fail to reach steady state (threshold = 50% of desired count, min 3 max 200 failures), ECS marks the deployment FAILED and, with rollback=true, automatically redeploys the last steady-state task definition — no human in the loop.

  8. Q: How do blue/green ECS deployments with CodeDeploy achieve near-instant rollback? A: CodeDeploy provisions the full green task set on a second target group, optionally exposes it on a test listener, shifts production listener traffic per the deployment config (canary/linear/all-at-once), and keeps blue running through the bake window — rollback is re-pointing the listener at blue, seconds not minutes.

  9. Q: Your API is I/O-bound and CPU-based autoscaling never triggers. What do you scale on? A: ALBRequestCountPerTarget target tracking — it measures demand directly. For queue workers, scale on backlog per task (visible messages ÷ running tasks) as a custom metric; for known peaks add scheduled scaling.

  10. Q: A secret was rotated in Secrets Manager but tasks still use the old password. Why? A: ECS resolves secrets[] once at task start and injects env vars; running tasks never re-read. You must force a new deployment (or have the app fetch from the API at runtime). Production rotation flows trigger update-service --force-new-deployment from the rotation Lambda.

  11. Q: What breaks first when you scale a Fargate service from 3 to 50 tasks? A: In practice: subnet IP capacity (50 ENIs), the Fargate account vCPU quota, downstream DB connections (pools × tasks), and any Docker Hub-based image pulls hitting rate limits — which is why endpoints, RDS Proxy, quota monitoring, and ECR pull-through caches are part of the reference design.

Quick check

  1. Which two AWS-managed touchpoints must the execution role reach for a task using Secrets Manager secrets and awslogs?
  2. Default minimumHealthyPercent/maximumPercent for a rolling deployment — and what do they mean for a 4-task service?
  3. Name the three requirements for http://catalog:8080 to resolve via Service Connect.
  4. A task stops with exit code 137. What happened and what are the two standard fixes?
  5. Which metric would you use to autoscale (a) an I/O-bound API and (b) an SQS worker?

Answers

  1. Secrets Manager (GetSecretValue, plus KMS decrypt) and CloudWatch Logs (CreateLogStream/PutLogEvents) — reachable via NAT or the secretsmanager and logs VPC endpoints.
  2. 100% / 200%: ECS may launch up to 4 extra tasks (8 total) during the deploy but never lets healthy count drop below 4.
  3. A named portMapping in the task definition, both services attached to the same Cloud Map namespace with the server declaring the catalog:8080 client alias, and tasks (re)launched after Service Connect was enabled.
  4. OOM kill — the container hit the task memory hard limit. Fix by right-sizing task memory and making the runtime container-aware (-XX:MaxRAMPercentage, --max-old-space-size).
  5. (a) ALBRequestCountPerTarget target tracking; (b) backlog per task — ApproximateNumberOfMessagesVisible divided by running task count, published as a custom metric.

Glossary

Next steps

ECSFargateMicroservicesALBService ConnectECRAuto ScalingBlue/Green Deployment
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading