Mission 3.4 · Stage 3 — Compute
Autoscaling and Versioned Deploys
Goal: ShopLite scales between 2 and 6 tasks on CPU and request load, Terraform never fights the autoscaler, and version 1.1.0 rolls out with zero downtime by changing one variable.
By the end of this mission
- Configure target-tracking autoscaling for an ECS service
- Use
ignore_changesso autoscaling and Terraform don't conflict - Deploy a new version by changing
image_tagand read the resulting plan - Decide who owns deploys: Terraform or a separate pipeline step
Part 1
Understand it first
Two controllers, one number
Application Auto Scaling changes the service's desired_count at runtime based on load. Terraform also has an opinion about desired_count (it's in your code). After a scale-out to 5 tasks, the next plan would propose 5 -> 2, and applying it during a traffic peak would scale ShopLite IN at exactly the wrong moment.
lifecycle { ignore_changes = [desired_count] } resolves the conflict: Terraform sets the initial value at creation, then leaves that attribute to the autoscaler. min_capacity/max_capacity on the scaling target remain in Terraform, which is where the bounds belong.
Target tracking
Like a thermostat: you name a metric and a target value and AWS adds or removes tasks to stay near it. ShopLite uses two policies: average CPU at 60%, and ALB requests per target at 500. Whichever demands more capacity wins, and scale-in only happens when both agree. Scale-out cooldowns are short (react quickly to load); scale-in cooldowns are longer (avoid flapping).
Who owns deploys?
Option A: Terraform deploys. CI builds the image, then runs terraform apply -var image_tag=<sha>. One tool, every deploy is a reviewed plan, and state always reflects what's running. This course uses A.
Option B: CI deploys directly (for example aws ecs update-service or a deploy tool) and Terraform ignores the image with ignore_changes = [task_definition]. Deploys are faster and independent of infrastructure changes, but Terraform's view of 'what's running' goes stale. Both are legitimate; the mistake is doing A AND B without ignore_changes, which gives endless drift.
Part 2
Your project after this mission · 5 files change
- app/
- Dockerfile
- package.jsonmodified
- server.jsmodified
- infra/
- alb.tf
- autoscaling.tfnew
- backend.tf
- ecr.tf
- ecs.tfmodified
- iam.tf
- locals.tf
- logs.tf
- network.tf
- outputs.tf
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvarsmodified
- variables.tf
- versions.tf
Part 3
Build it, step by step
- 1
Stop Terraform from fighting the autoscaler
Add a lifecycle block to the service. From now on, Terraform sets
desired_countonly when creating the service.infra/ecs.tfadd to filehcl Add inside resource "aws_ecs_service" "api".
lifecycle { ignore_changes = [desired_count] } - 2
Scaling bounds and policies
resource_idhas a fixed format for ECS:service/<cluster>/<service>. The request-count policy needs aresource_labelbuilt from the ALB and target grouparn_suffixattributes, which exist for exactly this purpose.infra/autoscaling.tfwhole filehcl variable "api_min_count" { type = number default = 2 } variable "api_max_count" { type = number default = 6 } resource "aws_appautoscaling_target" "api" { service_namespace = "ecs" scalable_dimension = "ecs:service:DesiredCount" resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}" min_capacity = var.api_min_count max_capacity = var.api_max_count } resource "aws_appautoscaling_policy" "api_cpu" { name = "${local.name_prefix}-api-cpu" policy_type = "TargetTrackingScaling" service_namespace = aws_appautoscaling_target.api.service_namespace scalable_dimension = aws_appautoscaling_target.api.scalable_dimension resource_id = aws_appautoscaling_target.api.resource_id target_tracking_scaling_policy_configuration { target_value = 60 scale_out_cooldown = 30 scale_in_cooldown = 180 predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } } } resource "aws_appautoscaling_policy" "api_requests" { name = "${local.name_prefix}-api-requests" policy_type = "TargetTrackingScaling" service_namespace = aws_appautoscaling_target.api.service_namespace scalable_dimension = aws_appautoscaling_target.api.scalable_dimension resource_id = aws_appautoscaling_target.api.resource_id target_tracking_scaling_policy_configuration { target_value = 500 scale_out_cooldown = 30 scale_in_cooldown = 180 predefined_metric_specification { predefined_metric_type = "ALBRequestCountPerTarget" resource_label = "${aws_lb.main.arn_suffix}/${aws_lb_target_group.api.arn_suffix}" } } }terminal$ terraform apply── expected output ──Plan: 3 to add, 0 to change, 0 to destroy....Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - 3
Load it and watch it scale
Use any HTTP load tool;
heyis a single binary. Three minutes at 50 concurrent requests pushes requests per target well past 500. Scale-out takes a few minutes, because CloudWatch alarms need several datapoints. Stop the load and it scales back to 2 after the scale-in cooldown.terminal$ hey -z 4m -c 50 "$(terraform output -raw api_url)/products" > /dev/null &watch -n 30 "aws ecs describe-services --cluster shoplite-dev --services shoplite-dev-api --query 'services[0].[desiredCount,runningCount]' --output text"── expected output ──2 22 24 24 46 6 - 4
Confirm Terraform leaves the count alone
While the service is scaled out, plan. Without
ignore_changesthis would showdesired_count = 6 -> 2; with it, nothing.terminal$ terraform plan── expected output ──No changes. Your infrastructure matches the configuration. - 5
Ship version 1.1.0
A real change: the root endpoint reports which task served the request, so you can watch a rolling deploy happen. Bump
package.jsonto1.1.0, build, and push the new tag.app/server.jsadd to filejs Replace the final fallback line in the request handler.
import os from "node:os"; // ... return json(res, 200, { service: "shoplite-api", version, task: os.hostname() });terminal$ docker buildx build --platform linux/arm64 -t "$REPO:1.1.0" --push ../app── expected output ──=> pushing 123456789012.dkr.ecr.ap-south-1.amazonaws.com/shoplite/api:1.1.0 - 6
Deploy it: read the plan, then apply
Exactly two changes: the task definition is replaced (new revision) and the service updated to use it. Nothing else. This is what every normal deploy plan should look like, and it's what Stage 7's pipeline expects. Run a
curlloop in another terminal during the apply: responses switch from1.0.0to1.1.0without a single error.terminal$ terraform apply -var image_tag=1.1.0── expected output ──# aws_ecs_service.api will be updated in-place~ resource "aws_ecs_service" "api" {~ task_definition = "arn:aws:ecs:...:task-definition/shoplite-dev-api:1" -> (known after apply)# (15 unchanged attributes hidden)}# aws_ecs_task_definition.api must be replaced-/+ resource "aws_ecs_task_definition" "api" {~ container_definitions = jsonencode(~ [~ {~ environment = [~ { name = "APP_VERSION", value = "1.0.0" -> "1.1.0" },# (1 unchanged element hidden)]~ image = "...shoplite/api:1.0.0" -> "...shoplite/api:1.1.0"# (6 unchanged attributes hidden)},]) # forces replacement~ revision = 1 -> (known after apply)}Plan: 1 to add, 1 to change, 1 to destroy....Apply complete! Resources: 1 added, 1 changed, 1 destroyed.Make it permanent: set `image_tag = "1.1.0"` in terraform.tfvars, or the next plain `terraform apply` would roll back to the 1.0.0 default.
Checkpoint — you should now have
- ✓Two target-tracking policies (CPU 60%, 500 requests/target) scale the service between 2 and 6 tasks.
- ✓
terraform planshows no changes even while the autoscaler has changed the task count. - ✓
curl $URLreturns version1.1.0and a task hostname, and the rollout caused no errors. - ✓
terraform.tfvarspinsimage_tag = "1.1.0".
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Remove ignore_changes during a scale-out
While the load test has the service at 6 tasks, delete the lifecycle { ignore_changes = [desired_count] } block and run terraform plan.
Break #2
Forget to pin the deployed tag
After deploying 1.1.0 with -var, run a plain terraform apply without setting it in tfvars (for example, while changing an unrelated tag).
Part 5
Interview questions from this mission
How do you combine ECS autoscaling with Terraform without them conflicting?
What does a normal application deploy look like in a Terraform plan for ECS?
Terraform-driven deploys vs deploying outside Terraform: what are the trade-offs?
Before you stop