Skip to main content
AWS · lesson 17 of 19
AWS intermediate Lesson 17 of 19

ELB and Auto Scaling: Wiring an ASG Behind a Load Balancer

Wire a launch template, an Auto Scaling Group, and an Application Load Balancer's target group together, watch a terminated instance get replaced automatically, then hit several real moto emulator gaps this lesson states plainly.

By SkillByExample · Updated

aws elb auto-scaling ec2

An Auto Scaling Group (ASG) launches and replaces EC2 instances to match a desired count; an Application Load Balancer’s target group tracks which instances are healthy enough to receive traffic. Wiring them together is mostly about which ARNs point at which other ARNs — this lesson does that wiring for real against moto, then states plainly where the emulator’s fidelity runs out.

Launch Template, Target Group, ALB, and an ASG That Wires Itself In

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

VPC_ID=$(aws ec2 describe-vpcs --query 'Vpcs[0].VpcId' --output text)
SUBNETS=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" --query 'Subnets[0:2].SubnetId' --output text)
SUBNET_A=$(echo "$SUBNETS" | cut -f1)
SUBNET_B=$(echo "$SUBNETS" | cut -f2)

LT_ID=$(aws ec2 create-launch-template --launch-template-name web-lt \
  --launch-template-data '{"ImageId":"ami-12345678","InstanceType":"t3.micro"}' \
  --query 'LaunchTemplate.LaunchTemplateId' --output text)
echo "launch template: $LT_ID"

TG_ARN=$(aws elbv2 create-target-group --name web-tg --protocol HTTP --port 80 \
  --vpc-id "$VPC_ID" --query 'TargetGroups[0].TargetGroupArn' --output text)
echo "target group: $TG_ARN"

ALB_ARN=$(aws elbv2 create-load-balancer --name web-alb --subnets "$SUBNET_A" "$SUBNET_B" \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)
echo "load balancer: $ALB_ARN"

aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" --protocol HTTP --port 80 \
  --default-actions "Type=forward,TargetGroupArn=$TG_ARN" --query 'Listeners[0].ListenerArn' --output text >/dev/null
echo "listener created"

aws autoscaling create-auto-scaling-group --auto-scaling-group-name web-asg \
  --launch-template "LaunchTemplateId=$LT_ID" \
  --min-size 1 --max-size 4 --desired-capacity 2 \
  --vpc-zone-identifier "$SUBNET_A,$SUBNET_B" \
  --target-group-arns "$TG_ARN"
echo "asg created, desired 2"
echo "$TG_ARN" > tg_arn.txt
$ docker network create aws17-lab
ce49fadba02f47ce575774bc2bc10c02344460e9c28c3220fb3c0931c52e02e7
$ docker run -d --name aws17-moto --network aws17-lab motoserver/moto:5.2.5
8b8e8104e0c3e342f4bd7fb5bb3f7962737b1cbcda032e62e08c8e0f25c3397a
$ until docker run --rm --network aws17-lab curlimages/curl:8.11.1 -s -o /dev/null http://aws17-moto:5000; do sleep 0.3; done
$ bash setup.sh
launch template: lt-dff54023308e714e6
target group: arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web-tg/27541d7f65130784
load balancer: arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/web-alb/75086152ea00bf96
listener created
asg created, desired 2

Nothing here calls RegisterTargets — the ASG’s own --target-group-arns flag is what keeps the target group in sync as instances come and go.

Both Instances Auto-Registered and Healthy

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

TG_ARN=$(cat tg_arn.txt)

echo "asg instances:"
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
  --query 'AutoScalingGroups[0].Instances[].{InstanceId:InstanceId,LifecycleState:LifecycleState}'

echo "target group health:"
aws elbv2 describe-target-health --target-group-arn "$TG_ARN" \
  --query 'TargetHealthDescriptions[].{Target:Target.Id,State:TargetHealth.State}'
$ bash check_asg.sh
asg instances:
[
    {
        "InstanceId": "i-063056c84879f5413",
        "LifecycleState": "InService"
    },
    {
        "InstanceId": "i-3de9736418a722bb6",
        "LifecycleState": "InService"
    }
]
target group health:
[
    {
        "Target": "i-063056c84879f5413",
        "State": "healthy"
    },
    {
        "Target": "i-3de9736418a722bb6",
        "State": "healthy"
    }
]

Both instances are InService in the ASG and healthy in the target group, with no explicit registration step — exactly the wiring --target-group-arns was for. What moto can’t show here: the “healthy” status is unconditional. There’s no real instance behind either ID, so there’s nothing moto could check a health check against; a real target only reports healthy after actually passing one.

Terminate One Instance, a Replacement Appears

#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

BEFORE=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
  --query 'AutoScalingGroups[0].Instances[].InstanceId' --output text)
echo "before: $BEFORE"

KILL=$(echo "$BEFORE" | cut -f1)
aws autoscaling terminate-instance-in-auto-scaling-group --instance-id "$KILL" \
  --no-should-decrement-desired-capacity --query 'Activity.StatusCode' --output text

AFTER=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
  --query 'AutoScalingGroups[0].Instances[].InstanceId' --output text)
echo "after: $AFTER"
$ bash terminate.sh
before: i-063056c84879f5413	i-3de9736418a722bb6
InProgress
after: i-3de9736418a722bb6	i-337470dc5df6339fd

Still 2 instances afterward, but one ID changed — --no-should-decrement-desired-capacity tells the ASG the terminated instance should be replaced to stay at DesiredCapacity, rather than lowering the desired count by one. The replacement’s ID is proof the ASG actually launched a new instance, not that it un-terminated the old one.

Practice

1. Set desired capacity to 6 — one above MaxSize (4) — and see that moto accepts it instead of rejecting it like real AWS would.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

aws autoscaling set-desired-capacity --auto-scaling-group-name web-asg --desired-capacity 6
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
  --query 'AutoScalingGroups[0].{Desired:DesiredCapacity,Max:MaxSize}'
$ bash practice1.sh
{
    "Desired": 6,
    "Max": 4
}

Desired: 6 sits right alongside Max: 4 — moto applied the request without the bounds check real AWS’s API performs, which would reject this with a validation error instead. Never rely on this emulator to catch an invalid desired-capacity value during local testing.

2. Check describe-scaling-activities after all the launches and terminations so far.
#!/usr/bin/env bash
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

aws autoscaling describe-scaling-activities --auto-scaling-group-name web-asg --query 'length(Activities)'
$ bash practice2.sh
0

0 — despite the create, the termination-triggered replacement, and the scale-out to 6 instances, moto never records any scaling activity history. A real ASG’s console and API both show this as a timeline; here there is none to inspect at all.

3. Check which Availability Zone every instance actually landed in, given the ASG spans two subnets in two different AZs.
#!/usr/bin/env bash
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
  --query 'AutoScalingGroups[0].Instances[].AvailabilityZone'
$ bash practice3.sh
[
    "us-east-1a",
    "us-east-1a",
    "us-east-1a",
    "us-east-1a",
    "us-east-1a",
    "us-east-1a"
]

Every single instance is in the same AZ, even though --vpc-zone-identifier named subnets in two different AZs. Real AWS spreads instances across the ASG’s subnets’ AZs for fault tolerance; moto doesn’t model that distribution logic at all.

4. Scale desired capacity down to 1 (without the no-decrement flag) and confirm an instance is actually terminated this time.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws17-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws17-moto:5000; }

BEFORE=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg --query 'length(AutoScalingGroups[0].Instances)')
aws autoscaling update-auto-scaling-group --auto-scaling-group-name web-asg --desired-capacity 1 --min-size 1
sleep 1
AFTER=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg --query 'length(AutoScalingGroups[0].Instances)')
echo "before: $BEFORE, after: $AFTER"
$ bash practice4.sh
before: 6, after: 1

The instance count drops to match the new DesiredCapacity — unlike Practice 1’s upward move (accepted without validation), scaling down through UpdateAutoScalingGroup does actually terminate the extra instances here; it’s specifically the over-MaxSize validation that moto skips, not scaling mechanics in general.

Cleanup

$ docker rm -f aws17-moto
aws17-moto
$ docker network rm aws17-lab
aws17-lab

Next: ECS task definitions and services — a versioned control-plane artifact, with the actual container run separately to show what a task really is.

Frequently Asked Questions

How does an instance launched by an Auto Scaling Group end up registered in a target group?
The ASG itself does the registration — create-auto-scaling-group's --target-group-arns flag tells the ASG which target groups to keep in sync with its own membership. Every time the ASG launches or terminates an instance, it automatically calls the equivalent of RegisterTargets or DeregisterTargets on each attached target group, so a reader never calls those APIs directly for ASG-managed instances.
Why does terminating an instance with --no-should-decrement-desired-capacity still leave the group at the same instance count?
ShouldDecrementDesiredCapacity is a required parameter on this call — the CLI rejects the request outright with a ParamValidation error if you give neither --should-decrement-desired-capacity nor --no-should-decrement-desired-capacity, so there's no implicit default to fall back on. Passing --no-should-decrement-desired-capacity tells the ASG the desired capacity itself hasn't changed — only this one instance is gone — so its reconciliation loop (comparing DesiredCapacity against how many instances currently exist) immediately launches a replacement. --should-decrement-desired-capacity instead lowers DesiredCapacity by one and leaves the group one instance smaller, with no replacement.
Does this lesson's ASG actually serve real traffic through the load balancer, or get scaled by real alarms?
Neither. moto's ALB and target group accept the real API calls and report InService/healthy status, but there's no real network path and no real instance behind an instance ID, so there's nothing to send traffic to or measure response times from. This lesson also never touches scaling policies or CloudWatch-alarm-driven scaling at all — every capacity change here is a direct, explicit API call (set-desired-capacity, update-auto-scaling-group), not something triggered automatically by load or a metric. It's entirely about the control-plane wiring (what gets registered with what, what the ASG does on termination), not traffic, performance, or automatic scaling.
What should I not trust about moto's Auto Scaling behavior specifically?
Four things this lesson hits directly: set-desired-capacity accepted a value above MaxSize without rejecting it (real AWS validates that); every instance landed in the same Availability Zone despite the ASG spanning two subnets in different AZs (real AWS distributes them); describe-scaling-activities came back empty throughout, with no activity history to inspect; and no scaling policy or CloudWatch alarm integration is exercised at all, so this lesson says nothing about whether moto models those correctly.