Notes · Learn AWS · CHAPTER 4

Compute

The most familiar territory in this whole series. EC2 is a VM, Lambda is a Function App, Fargate is a container without a host - the names change but the shapes you already know are mostly the shapes that ship. Where the gap shows up is in the menu: AWS has at least seven different ways to run code, and picking the right one is half the skill.

Coming from Azure, you've spent years choosing between Virtual Machines, App Service, Functions, Container Instances, Container Apps, AKS, and Batch. AWS gives you a parallel set of seven, with mostly-direct mappings and a few real divergences. This chapter does two things: first, hands you the cheat table you'll actually keep open in a tab - then walks the four services you'll use 90% of the time (EC2, Lambda, ECS/Fargate, and a 30,000-foot view of EKS so chapter 10 isn't a cold start).

In this chapter
  1. The cheat table
  2. EC2 fundamentals (instance families decoded)
  3. AMIs vs Azure images
  4. Lambda (cold starts, concurrency, layers)
  5. ECS / Fargate vs ACI / Container Apps
  6. EKS overview (preview of chapter 10)
  7. Auto Scaling vs VMSS
  8. Try it: explore instance types and Lambda limits
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: deploy the Lambda
  12. Recap & next

The cheat table: Azure compute → AWS compute

CapabilityAzureAWS
General-purpose VMVirtual MachineEC2 instance
VM fleet / autoscaleVirtual Machine Scale Set (VMSS)Auto Scaling Group (ASG) + Launch Template
Managed web app / PaaSApp ServiceElastic Beanstalk (legacy) or App Runner (modern web/container) or Lambda + API GW
Function-as-a-ServiceAzure FunctionsLambda
Single container, no orchestratorAzure Container Instances (ACI)ECS task on Fargate (no service)
Managed containers, serverlessAzure Container AppsApp Runner or ECS service on Fargate
Container orchestrator (managed)AKSEKS (k8s) or ECS (AWS-native)
Batch / HPC jobsAzure BatchAWS Batch (built on ECS + EC2/Fargate)
Image / templateManaged Image, Shared Image GalleryAMI (Amazon Machine Image)
Bootstrap on first bootCustom Script Extension, cloud-inituser-data (also cloud-init under the hood)
Spot / preemptibleSpot VMSpot Instance (2-minute interruption notice)
Reserved discountReserved Instances, Savings PlansReserved Instances (legacy), Savings Plans (preferred)
Dedicated tenancyDedicated HostDedicated Host or Dedicated Instance

Instance family decoder (the one you'll keep coming back to)

Azure's VM SKU letters and AWS's instance family letters tell similar stories - they encode the workload shape. Here's the mapping that lets you walk into a sizing conversation without looking lost.

Workload shapeAzure familyAWS familyNotes
Burstable / low baselineB-series (B2s, B4ms)t3, t3a, t4g (Graviton)Cheap, accrue CPU credits when idle, burn them under load. t4g = ARM, ~20% cheaper.
Balanced / generalD-series (Dv5, Ddsv5)m6i (Intel), m6a (AMD), m6g/m7g (Graviton)The default. Pick this if you don't know which.
Compute-optimizedF-series (Fsv2, Fasv6)c6i, c6a, c7gHigher CPU-to-RAM ratio. Good for build agents, web tier under heavy CPU.
Memory-optimizedE-series (Ev5), M-seriesr6i, r7g, x2idn, x2iezn (huge)For caches, in-memory DBs, JVM heaps. x2 family scales to multi-TB RAM.
GPU / MLN-series (NCv3, NDv4, NV)p4, p5 (training: A100/H100), g5/g6 (inference: A10/L4)p = training. g = graphics/inference. Pricier than CPU by an order of magnitude.
Storage-optimizedL-seriesi4i, im4gn, d3enLocal NVMe. Use when EBS latency isn't good enough.
Reading an AWS instance name. m6i.2xlarge = family m (general purpose), generation 6, processor i (Intel; a=AMD, g=Graviton/ARM), size 2xlarge (8 vCPU, 32 GiB). The n suffix means network-enhanced (e.g. m6in); d means NVMe instance store (e.g. m6id). Once the encoding clicks, you can read any name on sight.
What's in a name? - the compute glossary
EC2
Elastic Compute Cloud. Launched August 2006 as one of AWS's three founding services (along with S3 and SQS). "Elastic" was the marketing word of the early-2000s cloud era - it meant "you can grow and shrink it" at a time when buying servers took 6 weeks. The "2" is the Cs, not a version number. Old jokes still refer to it as "ee-cee-two" rather than "ee-cee-cee".
AMI
Amazon Machine Image. The snapshot used to boot an EC2 instance. Pronounced "ah-mee" or "ay-em-eye" depending on team age. An AMI bundles a root filesystem, kernel, and metadata - basically what Azure calls a Managed Image. Every AMI has a region-specific ID like ami-0abcd1234; the same Amazon Linux 2023 AMI has a different ID in each region.
ASG
Auto Scaling Group. The fleet manager that keeps N instances running per a Launch Template. The direct equivalent of Azure VMSS. ASGs predate VMSS by years and pioneered most of the patterns (target tracking, scheduled scaling, health-check-based replacement).
ECS
Elastic Container Service. AWS's homegrown container orchestrator, launched 2015 - one year before EKS. ECS schedules Docker tasks across EC2 or Fargate without needing Kubernetes. Lighter than EKS, AWS-flavored APIs (task definitions, services), no cluster autoscaler nightmare.
EKS
Elastic Kubernetes Service. Launched 2018, four years after Google's GKE. EKS is upstream Kubernetes with the control plane managed for you. AWS doesn't fork k8s - they ship vanilla. Chapter 10 deep-dives.
Fargate
Internally at AWS, a portmanteau of "far" (long-distance, abstracted away) and "stargate" (the sci-fi gateway). The marketing line is "serverless containers" - you give AWS a task definition, they run the container on infrastructure you never see. Same shape as Azure Container Apps and ACI.
Lambda
Named after lambda calculus, Alonzo Church's 1930s formalism where anonymous functions are written λx. x+1. Lambda functions in nearly every modern programming language descend from this notation. The AWS service launched November 2014 as the first true FaaS offering - Azure Functions followed in 2016. The name signals "small anonymous compute units that run on demand".
"warm" / "cold" start
Lambda terminology, now used across the FaaS industry. A cold start means AWS had to spin up a new execution environment (download code, init runtime, run your handler module-level code) before invoking your function. A warm start reuses a still-living environment and skips all that. Cold starts are measured in 100s of milliseconds; warm starts in single-digit ms.
Fun fact Lambda's max memory is 10240 MB (10 GB) - and crucially, vCPU scales proportionally with memory. At 128 MB you get a small slice of one vCPU; at 1769 MB you get exactly one full vCPU; at 10240 MB you get about 6 vCPUs. So the "memory" slider is really a "memory and CPU" slider. Bumping memory often makes CPU-bound functions cheaper because they finish faster, and Lambda bills per ms. The optimal price/perf point is rarely the cheapest memory setting.

EC2 fundamentals

ELI5: what an EC2 instance actually is
Think of EC2 as renting a hotel room. You pick a room class (instance type), a furniture layout that came in (the AMI image), bolt on extra storage (EBS volumes), get a key card to the building network (ENI in your VPC), and leave a sticky note on the door for the cleaning staff (user-data, which runs once at first boot). Same shape as renting an Azure VM - just with more pieces named explicitly.

An EC2 instance isn't one resource - it's a constellation. When you call ec2:RunInstances, AWS wires together half a dozen things, each of which has its own ARN and its own bill.

Anatomy of one EC2 instance EC2 instance · i-0abc123 m6i.large · us-east-1a AMI ami-0123...al2023 boots from user-data shell script, cloud-init runs once EBS root volume gp3, 8 GiB attached ENI eth0, private IP, SG in VPC subnet Instance profile wraps an IAM role → temp creds assumed
Every piece is billed separately. EBS volumes keep charging when the instance is stopped. ENIs are free, but the Elastic IP attached to a stopped instance is not.

Launch an instance: Azure vs AWS, side by side

Azure CLI
# Standard_B2s ~= burstable 2 vCPU 4 GiB
az vm create \
  --resource-group rg-app \
  --name web-01 \
  --image Ubuntu2204 \
  --size Standard_B2s \
  --vnet-name vnet-app \
  --subnet snet-app \
  --admin-username azureuser \
  --generate-ssh-keys \
  --custom-data cloud-init.txt
AWS CLI
# t3.small ~= burstable 2 vCPU 2 GiB (closest peer)
aws ec2 run-instances \
  --image-id ami-0abcd1234 \
  --instance-type t3.small \
  --subnet-id subnet-0aaa \
  --security-group-ids sg-0bbb \
  --key-name my-keypair \
  --iam-instance-profile Name=web-app-profile \
  --user-data file://cloud-init.txt \
  --tag-specifications \
    'ResourceType=instance,Tags=[{Key=Name,Value=web-01}]'

Two things to notice on the AWS side: (1) the AMI ID is mandatory and region-specific - there is no equivalent of --image Ubuntu2204 that magically resolves. You either look up the latest AMI ID via SSM Parameter Store (recommended) or pin it explicitly. (2) the IAM instance profile shows up as an explicit launch parameter, where in Azure it's a flag on the VM resource after creation.

The Terraform shape

main.tf · aws_instance
# Look up the latest Amazon Linux 2023 AMI without hardcoding the ID
data "aws_ssm_parameter" "al2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

resource "aws_instance" "web" {
  ami                  = data.aws_ssm_parameter.al2023.value
  instance_type        = "t3.small"
  subnet_id            = aws_subnet.private_a.id
  vpc_security_group_ids = [aws_security_group.web.id]
  iam_instance_profile = aws_iam_instance_profile.web.name
  user_data            = file("cloud-init.sh")

  metadata_options {
    http_tokens = "required"   # force IMDSv2 - blocks the SSRF class of attacks
  }

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
    encrypted   = true
  }

  tags = { Name = "web-01", Project = "compass" }
}
Always set http_tokens = "required" on new EC2 instances. That forces IMDSv2 (the session-token version of the metadata endpoint), which prevents server-side request forgery (SSRF) from harvesting the instance's IAM credentials. IMDSv1 is the default for backward compatibility and the cause of many breach reports. Azure's IMDS endpoint requires a header by default - AWS only added a similar safeguard in 2019 and didn't make it the default everywhere.

AMIs vs Azure images

ELI5: what an AMI is
An AMI is a cookie cutter. You stamp out EC2 instances from it - each instance is the dough, the AMI is the shape. The cookie cutter lives in one specific region (you can't use a us-east-1 cutter in eu-west-1 without copying it across). Azure's "Managed Image" and "Shared Image Gallery" are the same idea with different ergonomics.

An AMI is a region-scoped artifact containing: the root EBS snapshot, the kernel and boot loader metadata, optional extra EBS snapshots, and ENA/virtualization hints. Every EC2 launch starts from one.

SourceWhat it isUse when
Amazon Linux 2023 (AL2023)Current AWS-curated Linux. Tightly integrated with AWS SDK, cloud-init, SSM agent baked in.Default. Especially if you don't have a strong distro preference. Cheaper to operate than Ubuntu because AWS owns the patch pipeline.
Amazon Linux 2 (AL2)The previous generation. EOL roadmap - extended support through 2026.Only if you have legacy AMIs/AGENT/scripts pinned to it. New work goes to AL2023.
Ubuntu / RHEL / SUSECanonical, Red Hat, SUSE publish AMIs to every region.If your org standardizes on a Linux you operate everywhere. Note RHEL and SUSE add license cost on top of EC2.
Windows ServerAWS-curated Windows AMIs with Server 2019/2022/2025 + EC2Launch agent.Per-second Windows licensing is included in the EC2 hour. No BYOL hassle.
Marketplace AMIsThird-party AMIs sold via AWS Marketplace - Bitnami, Trend Micro, Palo Alto, etc.Use for turnkey appliances. Watch for hourly markup on top of base instance price.
Custom AMIsYou build one with Packer or ec2:CreateImage from a running instance.Golden image pattern. Bake dependencies and config once, launch fast. The right answer for ASG fleets.

Looking up the latest AMI without hardcoding

The Azure equivalent of "use the latest Ubuntu" is --image Ubuntu2204. AWS gives you SSM Parameter Store paths that AWS itself keeps up to date:

terminal
# Latest Amazon Linux 2023, x86_64
aws ssm get-parameter \
  --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query 'Parameter.Value' --output text

# Latest Amazon Linux 2023, arm64 (Graviton)
aws ssm get-parameter \
  --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64 \
  --query 'Parameter.Value' --output text

# Latest Ubuntu 22.04, x86_64
aws ssm get-parameter \
  --name /aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id \
  --query 'Parameter.Value' --output text
Per-region duplication. An AMI ID like ami-0abcd1234 only works in the region where it was created. Copying an AMI across regions (aws ec2 copy-image) is mechanical but takes minutes and creates a new ID. For multi-region deployments, lookups by SSM parameter path (which exist in every region) save your IaC from a sea of regional locals blocks.

Lambda

ELI5: how Lambda runs your code
Imagine a kitchen where chefs are hired the moment an order comes in. If a chef just finished a similar order and is still standing by the stove, the next one is fast (warm start). If no chef is available, AWS has to hire and onboard one from scratch (cold start). Each chef can only handle one order at a time - more orders means more chefs spun up in parallel. After about 5-15 minutes idle, AWS fires the chef. Same model as Azure Functions on a Consumption plan.

Lambda is the FaaS service you reach for when (a) you don't want to manage a host, (b) workload is bursty or event-driven, and (c) individual invocations fit in 15 minutes and 10 GB RAM. Beyond that you're picking a container service.

The lifecycle: init, warm, cold

Lambda execution environment lifecycle COLD START (first invoke) 1. Download deployment package 2. Start the runtime (Node / Py / Java...) 3. Run module-level init code 4. Call your handler 200-2000ms typical, Java/.NET worse WARM (subsequent invokes) Container is alive, runtime loaded. AWS calls the handler directly. Global state (DB clients, caches) is preserved. 1-10ms overhead IDLE / RECYCLED After ~5-15 min of no invocations, AWS reclaims the environment. Also: code update, scale-down, runtime patching. next invoke = cold again if traffic returns → new cold start Each concurrent invocation = its own environment. 100 simultaneous requests = up to 100 cold starts. Use provisioned concurrency to pre-warm environments for latency-sensitive endpoints.
Cold starts are not "Lambda is slow" - they're the cost of materializing a new sandbox. Warm invokes have negligible overhead. Architect for the warm path, optimize for the cold one only where it hurts.

What triggers a cold start?

Anatomy: function definition

Azure Functions (Python)
# function_app.py
import azure.functions as func

app = func.FunctionApp()

@app.function_name(name="hello")
@app.route(route="hello")
def hello(req: func.HttpRequest) -> func.HttpResponse:
    name = req.params.get("name", "world")
    return func.HttpResponse(f"hello {name}")
AWS Lambda (Python)
# handler.py
import json

# module-level code runs once per cold start - reuse clients here
import boto3
s3 = boto3.client("s3")

def handler(event, context):
    name = event.get("name", "world")
    return {
        "statusCode": 200,
        "body": json.dumps({"message": f"hello {name}"}),
    }

Deploy with Terraform

main.tf · aws_lambda_function
resource "aws_lambda_function" "hello" {
  function_name = "hello"
  role          = aws_iam_role.hello_exec.arn
  runtime       = "python3.12"
  handler       = "handler.handler"          # module.function_name
  filename      = "build/hello.zip"
  source_code_hash = filebase64sha256("build/hello.zip")

  memory_size = 512                            # MB. CPU scales with this.
  timeout     = 30                             # seconds, max 900 (15 min)
  architectures = ["arm64"]                   # Graviton: ~20% cheaper

  environment {
    variables = { LOG_LEVEL = "INFO" }
  }

  # Optional: attach to VPC for private-subnet access
  vpc_config {
    subnet_ids         = [aws_subnet.private_a.id, aws_subnet.private_b.id]
    security_group_ids = [aws_security_group.lambda.id]
  }
}

Concurrency, the lever that bites

Reserved concurrency

Sets a maximum for this function. Requests above it get throttled (HTTP 429 or dropped from event sources). Also reserves capacity from the account-wide pool.

Provisioned concurrency

Pre-warms N execution environments. They sit ready, so the first N concurrent requests skip cold start. Costs money (you pay per provisioned-environment-second), but kills tail latency for user-facing endpoints.

Account-level limit

Each account+region has a soft cap (default 1000 concurrent executions). Hit it, and every Lambda in the account throttles. Open a support ticket to raise it for production accounts before launch day.

Bug hunt: my Lambda dies mid-file processing

A teammate writes a Lambda to process 1 GB CSV files dropped into S3. They configure it with timeout=900 (the max) and memory=128 (the cheapest). Files start coming in. Some succeed, but anything over ~200 MB fails with Task timed out or, worse, Runtime exited with error: signal: killed. They paste the config below. What's wrong?

lambda.tf
resource "aws_lambda_function" "csv_processor" {
  function_name = "csv-processor"
  role          = aws_iam_role.csv.arn
  runtime       = "python3.12"
  handler       = "app.handler"
  filename      = "build/csv.zip"

  timeout     = 900          # 15 min - the max!
  memory_size = 128          # save money...
}
Click to reveal the bug
Two bugs in one.

(1) Memory cap kills the process before the timeout fires. The Lambda environment has only 128 MB. Pandas reading a 1 GB CSV needs multiples of that in RAM (10x is common). The Linux OOM killer terminates the process; Lambda surfaces this as signal: killed. You'd see this hours before the 900s timeout.

(2) Even if memory was fine, you'd be CPU-starved. At 128 MB you get a fractional vCPU - maybe 7% of a core. Processing a 1 GB CSV with a sliver of CPU will be slow enough that you'd start hitting the 15-min wall.

Fix: bump memory to 2048-4096 MB (you get 1-2 full vCPUs and 16-32x the RAM), or - better - stream the file: use boto3.get_object().Body as an iterator and process line by line, never materializing the whole file. For files reliably over a few hundred MB, drop Lambda entirely and use ECS Fargate or Athena.

Pro tip: counterintuitively, more memory often costs less because the function finishes faster. Lambda Power Tuning (a free AWS Labs tool) finds the sweet spot.

Real-world incident The S3 + Lambda recursive loop that ran a $10K bill in an afternoon ~$10,000 / afternoon

A team built an image-thumbnail service. The architecture: S3 bucket gets a new image.jpg, triggers a Lambda via S3 Event Notification, Lambda generates a thumbnail and writes it back to the same bucket as image-thumb.jpg. Simple, classic, ships in an hour.

Except the S3 trigger filter was the bucket itself, not a prefix. So when the Lambda wrote image-thumb.jpg back to the bucket, that write fired the trigger again. Lambda tried to thumbnail the thumbnail, wrote image-thumb-thumb.jpg, which triggered another Lambda, which wrote image-thumb-thumb-thumb.jpg, and so on.

Lambda's default per-account concurrency limit is 1000. The loop hit that within seconds and held it pinned. Each invocation cost a few hundredths of a cent for compute, plus a few S3 PUT requests, plus a chunk of data transfer. Multiply by ~10,000 invocations per second for hours before anyone noticed billing alerts. The team paid roughly $10,000 before someone disabled the trigger.

Lessons: (1) Always scope S3 triggers to a prefix (uploads/) and a suffix (.jpg), and write outputs to a different prefix (thumbnails/). (2) Set reserved concurrency on event-driven Lambdas as a circuit breaker. (3) Configure AWS Budgets alerts at a few dollars for early indication; don't wait for the monthly bill. (4) Send dead-letter-queue events for failed invocations so loops surface in CloudWatch alarms, not the next morning's CFO email.

Layers and container-image Lambdas

Lambda Layers

Shared zip artifacts (up to 250 MB unpacked) mounted at /opt in every invocation. Great for shared dependencies (pandas, numpy) or vendor SDKs. One layer can be attached to many functions. Versioned; you reference a specific version ARN.

Container-image Lambdas

Instead of a zip, ship a Docker image (up to 10 GB) pushed to ECR. AWS unpacks it on cold start. Use when your dependencies don't fit in the 250 MB zip limit (ML models, headless Chrome, large native libs). Slightly slower cold start on first deploy of an image, comparable after.

ECS / Fargate vs ACI / Container Apps

ELI5: ECS, Fargate, and how they fit
ECS is AWS's "I don't want Kubernetes" container orchestrator. You describe a container in a task definition (think: a single-file recipe), wrap it in a service (which keeps N copies running), and ECS schedules them somewhere. The "somewhere" is either EC2 instances you manage (cheaper, more work) or Fargate (no hosts, AWS handles it, more per task). Fargate is the direct shape of Azure Container Apps / ACI: serverless containers.

ECS has two halves that often get conflated:

ECS - the control plane

Schedules tasks. Defines services, task definitions, capacity providers. Free to use. The same control plane drives both EC2-backed and Fargate-backed clusters.

Fargate - the launch type

"Run my container without me managing a host." You pay per vCPU-second and per GiB-second of memory. Cold-task start is ~30-60s (vs Lambda's milliseconds, but tasks are long-lived so it rarely matters).

Mapping the Azure container menu

If you reached for this on Azure...Reach for this on AWS
Azure Container Instances (one-off, no orchestrator)ECS RunTask on Fargate with no service - fire-and-forget container.
Azure Container Apps (managed long-running container, scale-to-zero, HTTP ingress)AWS App Runner for HTTPs apps, or ECS service on Fargate for the general case.
AKSEKS for Kubernetes, or ECS if you're happy to leave k8s behind.
Service Fabric (Microsoft's pre-k8s orchestrator)No direct analog. ECS is the closest cultural fit.

Task definitions: the heart of ECS

A task definition is a JSON document. It pins the container image, the resources, the IAM role, environment variables, port mappings, log driver. Think of it as a docker-compose snippet that AWS treats as a versioned artifact - every edit creates a new revision.

task-definition.json (Fargate launch type)
{
  "family": "gfn-reports-api",
  "requiresCompatibilities": ["FARGATE"],
  "networkMode": "awsvpc",
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123:role/gfn-reports-task-role",
  "containerDefinitions": [{
    "name": "api",
    "image": "123.dkr.ecr.us-east-1.amazonaws.com/gfn-reports:1.2.3",
    "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
    "environment": [
      { "name": "LOG_LEVEL", "value": "INFO" }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/gfn-reports-api",
        "awslogs-region": "us-east-1",
        "awslogs-stream-prefix": "api"
      }
    }
  }]
}
Two IAM roles, not one. The executionRoleArn is what ECS itself uses to pull the image from ECR and write logs to CloudWatch. The taskRoleArn is what your container code uses to call AWS services. Mixing them up is the #1 ECS IAM confusion - the equivalent of conflating the kubelet's identity with the pod's.

Service vs task: when does each fit?

PatternUse
One-off batch / cronaws ecs run-task. Container runs once, exits, gone. Pair with EventBridge for scheduled "ECS cron".
Long-running web/APIECS service. Keeps desiredCount tasks healthy, replaces failures, integrates with ALB / NLB for load balancing.
Worker poolECS service with no load balancer. Tasks pull from SQS. Scale via target tracking on queue depth.

Same idea in Terraform

main.tf · aws_ecs_service
resource "aws_ecs_cluster" "main" {
  name = "compass"
}

resource "aws_ecs_task_definition" "api" {
  family                   = "gfn-reports-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.api_task.arn
  container_definitions    = jsonencode([/* container block from above */])
}

resource "aws_ecs_service" "api" {
  name            = "gfn-reports-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = [aws_subnet.private_a.id, aws_subnet.private_b.id]
    security_groups  = [aws_security_group.api.id]
    assign_public_ip = false
  }
}
No SSH on Fargate. Fargate tasks have no host you can ssh into; AWS doesn't expose one. Use aws ecs execute-command ("ECS Exec") to open a shell inside a running task. It requires SSM agent in the image (Amazon's base images have it) and the right IAM permissions. We cover the setup in chapter 9.

EKS overview (preview of chapter 10)

ELI5: EKS in one paragraph
EKS is "AKS but on AWS". AWS runs the Kubernetes control plane (apiserver, scheduler, etcd) for you, charges $0.10/hour for that. You bring the worker nodes - either EC2 instances in a node group (you manage), Karpenter (you let an autoscaler decide), or Fargate profiles (no nodes at all, AWS schedules pods on serverless capacity). Same upstream k8s, same kubectl, same YAML.

This is a 30,000-foot preview. Chapter 10 walks the whole setup. But the shape is worth seeing now because EKS will be the project's destination in chapter 10:

EKS at a glance EKS-managed control plane $0.10/hour, multi-AZ HA kube-apiserver scheduler controller-mgr etcd (managed, encrypted) You bring the compute Managed node group (EC2) You pick instance type, AWS rolls AMIs Karpenter Picks the right instance per pod, just-in-time Fargate profile No nodes - pods on serverless capacity Your pods Mixed colors = mixed launch types, one cluster, one kubectl.
EKS clusters can mix node groups, Karpenter, and Fargate in one cluster. Pick per workload; switch over time.

The key differences from AKS are at the edges - IAM integration is through IRSA (chapter 2) or EKS Pod Identity, networking is the VPC CNI (each pod gets a routable VPC IP), and the load balancer controller provisions ALBs/NLBs from Kubernetes Service/Ingress objects. We dig into all of that in chapter 10.

Auto Scaling vs VMSS

Auto Scaling Groups (ASG) are AWS's VMSS equivalent. They watch metrics or schedules, and add/remove EC2 instances to match. Three flavors:

StrategyWhat it doesAzure VMSS analog
Target tracking"Keep average CPU at 50%" or "keep ALB request count per target at 1000". ASG figures out adjustments.Autoscale rule on a metric
Scheduled scalingSet desired-capacity at specific times. Good for predictable diurnal patterns.Scheduled autoscale
Predictive scalingML-based forecast from history. Scales before load arrives, not after. Free; takes 24h of history to start working.No direct analog
Step scalingOlder mechanism: "+2 instances if CPU>80, +4 if CPU>90". Use target tracking instead unless you need very specific stepwise logic.Scale rules with thresholds
main.tf · aws_autoscaling_group with target tracking
resource "aws_launch_template" "web" {
  name_prefix   = "web-"
  image_id      = data.aws_ssm_parameter.al2023.value
  instance_type = "t3.small"
  user_data     = base64encode(file("cloud-init.sh"))

  iam_instance_profile { name = aws_iam_instance_profile.web.name }
  vpc_security_group_ids = [aws_security_group.web.id]
}

resource "aws_autoscaling_group" "web" {
  name             = "web-asg"
  min_size         = 2
  desired_capacity = 2
  max_size         = 10
  vpc_zone_identifier = [aws_subnet.private_a.id, aws_subnet.private_b.id]
  target_group_arns   = [aws_lb_target_group.web.arn]
  health_check_type   = "ELB"

  launch_template { id = aws_launch_template.web.id; version = "$Latest" }
}

resource "aws_autoscaling_policy" "web_cpu" {
  name                   = "web-target-cpu-50"
  autoscaling_group_name = aws_autoscaling_group.web.name
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }
    target_value = 50
  }
}
Cost trap: the orphaned Reserved Instance disaster ~$30,000 / year

A FinOps team buys 3-year, all-upfront Reserved Instances for ten c5.4xlarge instances - the workhorse of a critical service. Big upfront discount, locked in. Six months later, the platform team migrates that service to Graviton (c6g.4xlarge) for 20% better price/perf. The migration is celebrated. The RIs sit unused, still billing. What just happened, and how do you avoid it next time?

Click to reveal the trap
Reserved Instances are tied to an instance family.

A c5 RI does not apply to c6g - different processor architecture (x86 vs ARM), different family generation. The RI keeps charging the team for capacity they're no longer using; meanwhile the new c6g hours bill at full on-demand rate. Two compounded losses for the price of one good engineering decision.

At ~$0.60/hour on-demand for c5.4xlarge, ten unused RIs over the remaining 2.5 years burn roughly 10 × 0.60 × 24 × 365 × 2.5 ≈ $130K. Even at the typical 40% RI discount, the wasted commitment is still ~$78K. Couple thousand dollars per month of pure waste.

Fix: prefer Compute Savings Plans over Reserved Instances. Savings Plans give a similar discount (up to ~66%) but apply across instance families, sizes, regions, even Fargate and Lambda. They follow your workload as architectures evolve. The only reason to still buy RIs is if you've truly frozen the architecture for the full term and need RDS/ElastiCache capacity reservations (Savings Plans don't cover those database services).

Plus: set up the AWS Cost Explorer "RI Utilization" report. Have someone in FinOps check it weekly. The team would have noticed within days, not 18 months.

Try it: explore instance types and Lambda limits

Lab: tour the EC2 catalog and your Lambda quotas $0

Goal: get fluent at querying compute resources and limits in your account, without launching anything billable. All commands below are read-only.

Step 1. List the instance types available in your region. (Spoiler: there are over 700.)

terminal
# Total count of instance types in this region
aws ec2 describe-instance-types \
  --query 'length(InstanceTypes)' \
  --profile compass

# All Graviton (arm64) instance types - 4 vCPU, sorted by memory
aws ec2 describe-instance-types \
  --filters "Name=processor-info.supported-architecture,Values=arm64" \
             "Name=vcpu-info.default-vcpus,Values=4" \
  --query 'InstanceTypes[*].[InstanceType,MemoryInfo.SizeInMiB,VCpuInfo.DefaultVCpus,ProcessorInfo.SupportedArchitectures[0]]' \
  --output table \
  --profile compass

Step 2. Compare the price between an Intel, AMD, and Graviton flavor of the same general family.

terminal
# Look at m6i (Intel), m6a (AMD), m7g (Graviton) at 2xlarge size
for t in m6i.2xlarge m6a.2xlarge m7g.2xlarge; do
  echo "=== $t ==="
  aws ec2 describe-instance-types \
    --instance-types "$t" \
    --query 'InstanceTypes[0].[InstanceType,VCpuInfo.DefaultVCpus,MemoryInfo.SizeInMiB,ProcessorInfo.SupportedArchitectures[0]]' \
    --output text \
    --profile compass
done

# For pricing, the Pricing API lives only in us-east-1, regardless of your workload region
aws pricing get-products \
  --service-code AmazonEC2 \
  --filters 'Type=TERM_MATCH,Field=instanceType,Value=m7g.2xlarge' \
            'Type=TERM_MATCH,Field=regionCode,Value=us-east-1' \
            'Type=TERM_MATCH,Field=tenancy,Value=Shared' \
            'Type=TERM_MATCH,Field=operatingSystem,Value=Linux' \
  --region us-east-1 --max-results 1 --profile compass

Step 3. Check Lambda concurrency limits in your account.

terminal
# Account-level Lambda concurrency cap
aws lambda get-account-settings --profile compass

# Look for: "ConcurrentExecutions" (typically 1000 by default)
# and "UnreservedConcurrentExecutions" (what's left after subtracting
# reserved concurrency from individual functions)

Step 4. Look at your account's Service Quotas page programmatically.

terminal
# EC2 vCPU quotas - the post-2019 quota model is "total vCPUs per family"
# Standard family quota (covers M, C, R, T, D, etc.)
aws service-quotas get-service-quota \
  --service-code ec2 \
  --quota-code L-1216C47A \
  --profile compass

# GPU instances (P family) - usually starts at 0, request increase before use
aws service-quotas get-service-quota \
  --service-code ec2 \
  --quota-code L-417A185B \
  --profile compass

What you learned: the AWS compute catalog is huge and queryable. Before any new workload, run describe-instance-types with filters - it surfaces options you didn't know existed (cheaper variants on AMD/Graviton, newer-generation siblings of what you were going to use). And always confirm quotas before launch day, especially for GPU families.

Quick check

Test yourself - 5 questions

Pause and answer before clicking. The "obvious" choice is sometimes the trap.

1. You're training a large language model on AWS. Which instance family is the right starting point?

  • m6i - general purpose, balanced.
  • c7g - compute-optimized Graviton, fastest CPU.
  • p4 or p5 - GPU-optimized for training.
  • r7i - memory-optimized so the model fits in RAM.
Show answer
Answer: c. The p family (p4 = A100, p5 = H100) is what AWS designed for training. The g family (g5, g6) is for inference and graphics - cheaper GPUs (A10, L4) that don't have the memory or interconnect for serious training. CPUs and RAM-rich instances are nowhere close on throughput; modern LLM training is fundamentally GPU-bound.

2. Which of the following does not cause a Lambda cold start?

  • Deploying a new version of the function.
  • A second concurrent request arriving while one is already in flight.
  • Two sequential requests arriving 30 seconds apart with no other traffic.
  • Changing the environment variables on the function.
Show answer
Answer: c. Sequential requests 30 seconds apart hit a still-warm environment - Lambda keeps an environment alive for 5-15 minutes of idle time. Deployments (a), concurrent requests requiring a new sandbox (b), and config changes (d) all force a fresh environment. Note: config changes invalidate the running environment because environment variables are baked in at init time.

3. You're running an ECS service on Fargate. Which is true?

  • You can SSH into the underlying instance for debugging.
  • You can open a shell inside a running task via aws ecs execute-command.
  • You need to manage EC2 instances; Fargate is just a billing model.
  • Fargate tasks share an instance with other customers, so memory limits aren't enforced.
Show answer
Answer: b. Fargate has no host to SSH to - AWS hides it. The supported way to get a shell inside a task is aws ecs execute-command ("ECS Exec"), which uses the SSM agent in your container image. Memory and CPU limits are enforced per task; Fargate is not just a billing label, it's a different launch type with its own scheduler.

4. You launch a t3.medium instance. After running at 100% CPU for a few hours, your monitoring shows performance has fallen off a cliff. What's most likely?

  • The instance is being noisy-neighbored by another tenant.
  • You've burned through the accrued CPU credits; performance fell back to baseline.
  • EBS bandwidth is throttling the workload.
  • The CPU is thermally throttling.
Show answer
Answer: b. The t family is burstable - it has a low baseline (about 20% of a vCPU for t3.medium) and accrues "CPU credits" while idle that can be spent to burst above baseline. Sustained 100% CPU burns the credit balance, then the instance falls back to baseline (which feels slow). You can enable T-unlimited mode, which lets you keep bursting and bills the extra CPU as overage - but most teams accidentally hit this without realizing it.

5. What's the maximum execution time for a single Lambda invocation?

  • 5 minutes
  • 15 minutes
  • 1 hour
  • Unlimited - it's serverless.
Show answer
Answer: b. 15 minutes (900 seconds) is the hard ceiling. A common mistake from Azure Functions developers: the Premium and Dedicated plans on Azure raise the timeout up to 60 minutes (or unlimited on Dedicated). Lambda has no equivalent - past 15 minutes you're picking a different service (Step Functions for orchestration, ECS for long-running tasks, Batch for compute jobs).

Gotchas for Azure devs

1. A stopped EC2 instance still bills for attached EBS volumes. "I stopped my dev box for the weekend, why am I getting charged?" Because the root EBS volume keeps existing - it's a separate resource. Stopping pauses compute billing; terminating removes the instance and (by default) deletes the root volume. Treat stopped instances as paused-not-free. Azure handles this the same way for VMs with managed disks, but the cost surfaces sooner there because the SKU is bundled differently.
2. Lambda's 15-minute hard timeout is non-negotiable. No support ticket will raise it. If your workload approaches 15 minutes, that's a strong signal to move to ECS, Step Functions, or Batch. Re-architecting around an artificial wall after-the-fact is far more painful than picking the right service up front.
3. ECS Fargate has no SSH. The host is hidden by design. Use aws ecs execute-command to exec into a task. You'll need to (a) bake the SSM agent into your image - amazoncorretto and amazonlinux base images already have it; (b) enable enableExecuteCommand: true on the service or task; (c) grant the task role ssmmessages:*. Skipping any of those gives you "TargetNotConnected" with no further help.
4. t3.medium credits silently exhaust under load. The instance type "feels" like it has 2 full vCPUs - and it does, briefly. Sustained CPU work depletes the credit balance and you fall back to ~20% baseline. CloudWatch shows the credit-balance metric, but most teams don't watch it until the production app suddenly gets slow. Enable T-unlimited if you want predictable performance, or move to m6i/m7g for steady workloads.
5. Spot interruptions give you 2 minutes' notice, no more. AWS posts an interruption notice to instance metadata at http://169.254.169.254/latest/meta-data/spot/instance-action; you have ~120 seconds to drain. Build for it: graceful shutdown handlers, ECS capacity providers that diversify across instance types, ASG mixed-instances policy. Spot is amazing for stateless workers; brutal for stateful single-instance services.

Project Compass: deploy the Lambda

Three chapters of scaffolding (profile, IAM role, VPC) finally pay off here. We deploy gfn-reports as a Lambda that uses the role from chapter 2, lives in the private subnets from chapter 3, and logs to CloudWatch. The first running compute in the project. Tiny, but real.

Project Compass · Step 4 of 12 Deploy gfn-reports as a hello-world Lambda

Why now: chapter 2 built the IAM role, chapter 3 built the VPC. This chapter introduces Lambda. The natural first deploy is the smallest function that consumes both. Subsequent chapters add storage, queues, secrets, and observability.

Step 1: the function code
handler.py
# gfn-reports/handler.py
import json
import logging
import os

log = logging.getLogger()
log.setLevel(logging.INFO)

def handler(event, context):
    log.info("received event: %s", json.dumps(event))
    log.info("environment: stage=%s region=%s",
             os.environ.get("STAGE", "dev"),
             os.environ.get("AWS_REGION"))
    return {
        "statusCode": 200,
        "body": json.dumps({
            "service": "gfn-reports",
            "message": "hello from compass",
            "input": event,
        }),
    }
Step 2: package the zip
terminal
mkdir -p build
cd gfn-reports
zip -r ../build/gfn-reports.zip handler.py
cd ..

# Confirm contents
unzip -l build/gfn-reports.zip
Step 3: create the Lambda function
terminal · uses ch2 role + ch3 subnets
# Pull the artifacts we already created in earlier chapters
ROLE_ARN=$(aws iam get-role --role-name gfn-reports-role \
  --query 'Role.Arn' --output text --profile compass)

# Replace with the private subnet IDs from ch3 (export from terraform if you used IaC)
PRIV_SUBNETS="subnet-0aaa,subnet-0bbb"
LAMBDA_SG="sg-0lambda"      # a security group with egress only

aws lambda create-function \
  --function-name gfn-reports \
  --runtime python3.12 \
  --role "$ROLE_ARN" \
  --handler handler.handler \
  --zip-file fileb://build/gfn-reports.zip \
  --timeout 30 \
  --memory-size 256 \
  --architectures arm64 \
  --environment "Variables={STAGE=dev}" \
  --vpc-config "SubnetIds=$PRIV_SUBNETS,SecurityGroupIds=$LAMBDA_SG" \
  --tags "Project=Compass,ManagedBy=learn-aws" \
  --profile compass
Step 4: invoke it and read the response
terminal
# Send a test event and capture the response
aws lambda invoke \
  --function-name gfn-reports \
  --cli-binary-format raw-in-base64-out \
  --payload '{"hello": "world", "report_id": "r-001"}' \
  --log-type Tail \
  --query 'LogResult' --output text \
  /tmp/response.json --profile compass | base64 -d

echo "--- response ---"
cat /tmp/response.json

# Tail the CloudWatch log group for live logs
aws logs tail /aws/lambda/gfn-reports --follow --profile compass

If the first invoke takes a second or two, that's the cold start - first ENI provisioning into your VPC, first Python runtime warmup. Run it twice in a row and the second one returns in under 50ms.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Heads up for chapter 5. S3 + Lambda is the most common AWS pattern. Next chapter we add the bucket, configure an event notification, and watch the Lambda fire for every upload. (And, having read the horror story above, we'll scope the prefix correctly.)

Recap & next

What stuck?

The mental model in one sentence

Azure has a clean compute menu of 8 services; AWS has the same shapes plus a few extras (App Runner, Beanstalk, Batch), and the right pick is usually obvious once you know the cheat table. The discipline that pays off is naming the workload shape first (burstable? steady? bursty? long-running container? sub-15-min event handler?) and only then picking the service.

Common pitfalls so far

TrapFix
"My stopped instance keeps costing money"EBS root volume is still attached. Terminate (don't just stop) for true zero cost, or delete the volume.
"My Lambda processes small files but OOMs on big ones"Bump memory (also bumps CPU). Stream rather than fully load. Move to ECS Fargate past a few hundred MB.
"My ECS task crashes and I can't get a shell"Enable ECS Exec (enableExecuteCommand) and grant ssmmessages:* to the task role. SSH does not exist on Fargate.
"My t3 instance got fast, then slow, then fast"Burst credits depleted and refilled. Either set T-unlimited or move to a non-burstable family.
"My Reserved Instances aren't being used"You probably changed instance family. Switch future commitments to Compute Savings Plans, which are family-agnostic.
NEXT CHAPTER
5. Storage
S3 ~ Blob Storage, EBS ~ Managed Disks, EFS ~ Files - and why "eventual consistency" stopped being a thing in S3 (almost). Plus, wiring the gfn-reports Lambda to an S3 event safely.