Command Palette

Search for a command to run...

Hectal

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.

40 min Same as 3.3, briefly more during the load test 6 steps 2 break-it drills

By the end of this mission

  • Configure target-tracking autoscaling for an ECS service
  • Use ignore_changes so autoscaling and Terraform don't conflict
  • Deploy a new version by changing image_tag and 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.

Scaling loop and deploy pathdiagram
Rendering diagram…

Part 2

Your project after this mission · 5 files change

shoplite/
  • 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. 1

    Stop Terraform from fighting the autoscaler

    Add a lifecycle block to the service. From now on, Terraform sets desired_count only when creating the service.

    infra/ecs.tfadd to filehcl

    Add inside resource "aws_ecs_service" "api".

      lifecycle {
        ignore_changes = [desired_count]
      }
  2. 2

    Scaling bounds and policies

    resource_id has a fixed format for ECS: service/<cluster>/<service>. The request-count policy needs a resource_label built from the ALB and target group arn_suffix attributes, 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. 3

    Load it and watch it scale

    Use any HTTP load tool; hey is 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 2
    2 2
    4 2
    4 4
    6 6
  4. 4

    Confirm Terraform leaves the count alone

    While the service is scaled out, plan. Without ignore_changes this would show desired_count = 6 -> 2; with it, nothing.

    terminal
    $ terraform plan
    ── expected output ──
    No changes. Your infrastructure matches the configuration.
  5. 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.json to 1.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. 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 curl loop in another terminal during the apply: responses switch from 1.0.0 to 1.1.0 without 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 plan shows no changes even while the autoscaler has changed the task count.
  • ✓curl $URL returns version 1.1.0 and a task hostname, and the rollout caused no errors.
  • ✓terraform.tfvars pins image_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.

terminal
$ terraform plan
── what you'll see ──
# aws_ecs_service.api will be updated in-place
~ resource "aws_ecs_service" "api" {
~ desired_count = 6 -> 2
# (15 unchanged attributes hidden)
}
 
Plan: 0 to add, 1 to change, 0 to destroy.

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

terminal
$ terraform plan
── what you'll see ──
# aws_ecs_task_definition.api must be replaced
-/+ resource "aws_ecs_task_definition" "api" {
~ container_definitions = jsonencode(
~ [ { ~ image = "...shoplite/api:1.1.0" -> "...shoplite/api:1.0.0" ... } ]
) # forces replacement
...
Plan: 1 to add, 1 to change, 1 to destroy.

Part 5

Interview questions from this mission

01

How do you combine ECS autoscaling with Terraform without them conflicting?

02

What does a normal application deploy look like in a Terraform plan for ECS?

03

Terraform-driven deploys vs deploying outside Terraform: what are the trade-offs?

Before you stop

Clean up

terminal
$ # Pausing for a while? Everything is reproducible from code:
terraform destroy
# Next session: terraform apply (ECR force_delete is on in dev, so images go too — re-push 1.1.0)
── expected output ──
Destroy complete! Resources: 47 destroyed.
Your count may differ (HTTPS adds more). Or keep it running and just stop NAT: the ALB (~$0.6/day) and two tasks (~$0.5/day) are the main remaining costs. The state bucket lives in bootstrap/ and is never touched by this destroy.
0/4 · 0%