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).
| Capability | Azure | AWS |
|---|---|---|
| General-purpose VM | Virtual Machine | EC2 instance |
| VM fleet / autoscale | Virtual Machine Scale Set (VMSS) | Auto Scaling Group (ASG) + Launch Template |
| Managed web app / PaaS | App Service | Elastic Beanstalk (legacy) or App Runner (modern web/container) or Lambda + API GW |
| Function-as-a-Service | Azure Functions | Lambda |
| Single container, no orchestrator | Azure Container Instances (ACI) | ECS task on Fargate (no service) |
| Managed containers, serverless | Azure Container Apps | App Runner or ECS service on Fargate |
| Container orchestrator (managed) | AKS | EKS (k8s) or ECS (AWS-native) |
| Batch / HPC jobs | Azure Batch | AWS Batch (built on ECS + EC2/Fargate) |
| Image / template | Managed Image, Shared Image Gallery | AMI (Amazon Machine Image) |
| Bootstrap on first boot | Custom Script Extension, cloud-init | user-data (also cloud-init under the hood) |
| Spot / preemptible | Spot VM | Spot Instance (2-minute interruption notice) |
| Reserved discount | Reserved Instances, Savings Plans | Reserved Instances (legacy), Savings Plans (preferred) |
| Dedicated tenancy | Dedicated Host | Dedicated Host or Dedicated Instance |
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 shape | Azure family | AWS family | Notes |
|---|---|---|---|
| Burstable / low baseline | B-series (B2s, B4ms) | t3, t3a, t4g (Graviton) | Cheap, accrue CPU credits when idle, burn them under load. t4g = ARM, ~20% cheaper. |
| Balanced / general | D-series (Dv5, Ddsv5) | m6i (Intel), m6a (AMD), m6g/m7g (Graviton) | The default. Pick this if you don't know which. |
| Compute-optimized | F-series (Fsv2, Fasv6) | c6i, c6a, c7g | Higher CPU-to-RAM ratio. Good for build agents, web tier under heavy CPU. |
| Memory-optimized | E-series (Ev5), M-series | r6i, r7g, x2idn, x2iezn (huge) | For caches, in-memory DBs, JVM heaps. x2 family scales to multi-TB RAM. |
| GPU / ML | N-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-optimized | L-series | i4i, im4gn, d3en | Local NVMe. Use when EBS latency isn't good enough. |
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.
ami-0abcd1234; the same Amazon Linux 2023 AMI has a different ID in each region.λ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".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.
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.
# 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
# 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.
# 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" }
}
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.
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.
| Source | What it is | Use 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 / SUSE | Canonical, 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 Server | AWS-curated Windows AMIs with Server 2019/2022/2025 + EC2Launch agent. | Per-second Windows licensing is included in the EC2 hour. No BYOL hassle. |
| Marketplace AMIs | Third-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 AMIs | You 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. |
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:
# 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
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 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.
# 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}")
# 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}"}),
}
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]
}
}
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.
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.
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.
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?
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...
}
(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.
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.
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.
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 has two halves that often get conflated:
Schedules tasks. Defines services, task definitions, capacity providers. Free to use. The same control plane drives both EC2-backed and Fargate-backed clusters.
"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).
| 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. |
| AKS | EKS 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. |
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.
{
"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"
}
}
}]
}
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.
| Pattern | Use |
|---|---|
| One-off batch / cron | aws ecs run-task. Container runs once, exits, gone. Pair with EventBridge for scheduled "ECS cron". |
| Long-running web/API | ECS service. Keeps desiredCount tasks healthy, replaces failures, integrates with ALB / NLB for load balancing. |
| Worker pool | ECS service with no load balancer. Tasks pull from SQS. Scale via target tracking on queue depth. |
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
}
}
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.
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:
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 Groups (ASG) are AWS's VMSS equivalent. They watch metrics or schedules, and add/remove EC2 instances to match. Three flavors:
| Strategy | What it does | Azure 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 scaling | Set desired-capacity at specific times. Good for predictable diurnal patterns. | Scheduled autoscale |
| Predictive scaling | ML-based forecast from history. Scales before load arrives, not after. Free; takes 24h of history to start working. | No direct analog |
| Step scaling | Older 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 |
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
}
}
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?
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.
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.)
# 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.
# 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.
# 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.
# 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.
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.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?
3. You're running an ECS service on Fargate. Which is true?
aws ecs execute-command.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?
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?
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.
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.
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.
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.
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.
# 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,
}),
}
mkdir -p build
cd gfn-reports
zip -r ../build/gfn-reports.zip handler.py
cd ..
# Confirm contents
unzip -l build/gfn-reports.zip
# 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
# 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.
m6i.2xlarge) like a sentence: family, generation, processor, size.| Trap | Fix |
|---|---|
| "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. |