AWS compute & networking all 4 written
IAM, VPC and EC2, run for real against a free local AWS. Least privilege from the start, not bolted on later.
18 IAM: identities, policies and least privilege
The default answer to "can this identity do this" is no. Nothing is allowed until a policy explicitly allows it, and that default is the single most important fact about IAM -- it means a forgotten permission fails safely, and a forgotten Deny does not.
A real user, with no permissions at all
A brand-new IAM user starts with nothing granted. Confirming that -- not assuming it -- is the first step.
aws iam create-user --user-name course-demo-user > /dev/null
echo "policies attached to a brand-new user:"
inline=$(aws iam list-user-policies --user-name course-demo-user --query 'PolicyNames' --output text)
managed=$(aws iam list-attached-user-policies --user-name course-demo-user --query 'AttachedPolicies' --output text)
echo "inline policies: '$inline'"
echo "managed policies: '$managed'"
echo "(both empty -- deny-by-default means a new identity can do nothing at all)"
check "a brand-new user has no inline policies" "$inline" ""
check "a brand-new user has no managed policies attached" "$managed" ""
What it printed, when the build ran it
policies attached to a brand-new user: inline policies: '' managed policies: '' (both empty -- deny-by-default means a new identity can do nothing at all) ok a brand-new user has no inline policies ok a brand-new user has no managed policies attached
Granting exactly one action, on exactly one resource
Least privilege in practice: a policy naming one action against one specific bucket,
not s3:* against *. The difference is not stylistic -- it is the
entire content of "least privilege" as a real, checkable thing.
cat > /tmp/cd-course-readonly-policy.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::course-data-bucket/*"
}
]
}
JSON
echo "the policy being attached:"
cat /tmp/cd-course-readonly-policy.json
aws iam put-user-policy --user-name course-demo-user --policy-name read-only \
--policy-document file:///tmp/cd-course-readonly-policy.json
echo
echo "reading the SAME policy back from IAM, not from the local file:"
stored=$(aws iam get-user-policy --user-name course-demo-user --policy-name read-only --output json)
echo "$stored"
rm -f /tmp/cd-course-readonly-policy.json
check "the stored policy grants exactly one action" \
"$(echo "$stored" | grep -c 's3:GetObject')" "1"
check "the stored policy names one specific bucket, not a wildcard resource" \
"$(echo "$stored" | grep -c 'course-data-bucket')" "1"
check "the policy does NOT grant a wildcard action" \
"$(echo "$stored" | grep -c '"s3:\*"')" "0"
What it printed, when the build ran it
the policy being attached:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::course-data-bucket/*"
}
]
}
reading the SAME policy back from IAM, not from the local file:
{
"UserName": "course-demo-user",
"PolicyName": "read-only",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::course-data-bucket/*"
}
]
}
}
ok the stored policy grants exactly one action
ok the stored policy names one specific bucket, not a wildcard resource
ok the policy does NOT grant a wildcard action
Evaluating a policy the way AWS actually does: explicit Deny always wins
AWS's own evaluation order, checkable without any special API: collect every applicable statement across every attached policy; if any of them is an explicit Deny, the request is denied, full stop, no matter how many Allow statements also matched. This is the exact mechanism a permissions boundary and an SCP both rely on.
# A minimal, honest re-implementation of AWS's own evaluation order --
# not a metaphor for it. Real policy JSON in, the real verdict out.
evaluate_action() {
local action="$1"
local policies_json="$2" # a JSON array of policy documents, as text
# Deny beats Allow, unconditionally -- checked FIRST, exactly as AWS specifies.
if echo "$policies_json" | grep -q "\"Effect\": \"Deny\".*\"Action\": \"$action\"" ; then
echo "DENY (explicit)"
return
fi
if echo "$policies_json" | grep -q "\"Effect\": \"Allow\".*\"Action\": \"$action\"" ; then
echo "ALLOW"
return
fi
echo "DENY (default -- nothing granted it)"
}
# One policy allows read access; a SECOND, separate policy explicitly denies it --
# exactly the shape of "a permissions boundary overriding a generous role policy."
allow_policy='{"Effect": "Allow", "Action": "s3:GetObject"}'
deny_policy='{"Effect": "Deny", "Action": "s3:GetObject"}'
combined="[$allow_policy, $deny_policy]"
verdict_with_deny=$(evaluate_action "s3:GetObject" "$combined")
verdict_without_deny=$(evaluate_action "s3:GetObject" "[$allow_policy]")
verdict_ungranted=$(evaluate_action "s3:DeleteObject" "[$allow_policy]")
echo "Allow + explicit Deny together : $verdict_with_deny"
echo "Allow alone : $verdict_without_deny"
echo "Never granted at all : $verdict_ungranted"
check "an explicit Deny overrides an Allow for the same action" "$verdict_with_deny" "DENY (explicit)"
check "an Allow with no competing Deny actually allows it" "$verdict_without_deny" "ALLOW"
check "an action nobody granted denies by default, not by explicit rule" "$verdict_ungranted" "DENY (default -- nothing granted it)"
What it printed, when the build ran it
Allow + explicit Deny together : DENY (explicit) Allow alone : ALLOW Never granted at all : DENY (default -- nothing granted it) ok an explicit Deny overrides an Allow for the same action ok an Allow with no competing Deny actually allows it ok an action nobody granted denies by default, not by explicit rule
Why this order matters in practice. An organization's Service Control Policy (SCP) or a role's permissions boundary is exactly a Deny statement sitting alongside whatever generous Allow a role's own policy grants. Someone who only reads their own role's policy and sees broad access can still be denied everything at runtime by a boundary they never looked at -- which is a feature, not a bug, once you know to look for it.
Roles vs users: a policy attached to something that is not a person
A role is the same permissions mechanism as a user, minus a fixed identity -- something else (an EC2 instance, a Lambda function, another AWS account) assumes it temporarily. This is how a service gets permissions without a password ever existing to leak.
aws iam create-role --role-name course-ec2-role --assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}' > /dev/null
role_info=$(aws iam get-role --role-name course-ec2-role --output json)
echo "the role, and specifically WHO is allowed to assume it:"
echo "$role_info"
check "the role exists and names ec2.amazonaws.com as its trusted principal" \
"$(echo "$role_info" | grep -c 'ec2.amazonaws.com')" "1"
What it printed, when the build ran it
the role, and specifically WHO is allowed to assume it:
{
"Role": {
"Path": "/",
"RoleName": "course-ec2-role",
"RoleId": "AROARZPUZDIKO4WTRV2UI",
"Arn": "arn:aws:iam::123456789012:role/course-ec2-role",
"CreateDate": "2026-09-15T20:18:01.159710Z",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
},
"MaxSessionDuration": 3600,
"RoleLastUsed": {}
}
}
ok the role exists and names ec2.amazonaws.com as its trusted principal
19 VPC: your own private network in the cloud
"Public subnet" and "private subnet" are not a special AWS feature -- they are a subnet with a route to an internet gateway, and a subnet without one. Nothing else distinguishes them. This lesson builds that exact distinction from raw parts and then proves it by reading the route table back.
A network, and two subnets inside it
created=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "created VPC: $vpc_id, CIDR 10.0.0.0/16 -- 65,536 addresses, all private until routed otherwise"
pub=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.0.1.0/24 --availability-zone us-east-1a --output json)
pub_id=$(echo "$pub" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "public-intended subnet: $pub_id, in us-east-1a"
priv=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.0.2.0/24 --availability-zone us-east-1b --output json)
priv_id=$(echo "$priv" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "private-intended subnet: $priv_id, in us-east-1b"
echo
echo "right now, before any routing exists, both are equally unreachable from the internet."
check "the VPC was really created" "$(echo "$vpc_id" | grep -Ec '^vpc-')" "1"
check "both subnets were created inside it" \
"$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$vpc_id --query 'length(Subnets)' --output text)" "2"
check "the two subnets landed in two different AZs, on purpose" \
"$([ "$(aws ec2 describe-subnets --subnet-ids $pub_id --query 'Subnets[0].AvailabilityZone' --output text)" != \
"$(aws ec2 describe-subnets --subnet-ids $priv_id --query 'Subnets[0].AvailabilityZone' --output text)" ] && echo yes)" "yes"
What it printed, when the build ran it
created VPC: vpc-45bf9db5a339ca880, CIDR 10.0.0.0/16 -- 65,536 addresses, all private until routed otherwise public-intended subnet: subnet-829a2a1d6db50a8ad, in us-east-1a private-intended subnet: subnet-0e857196be66d3adc, in us-east-1b right now, before any routing exists, both are equally unreachable from the internet. ok the VPC was really created ok both subnets were created inside it ok the two subnets landed in two different AZs, on purpose
What actually makes a subnet "public": one route, to one gateway
An internet gateway attached to the VPC, and a route table sending
0.0.0.0/0 (everything not already inside the VPC) to it -- associated with
only one of the two subnets. That association is the entire, complete mechanism.
created=$(aws ec2 create-vpc --cidr-block 10.1.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
pub=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.1.1.0/24 --availability-zone us-east-1a --output json)
pub_id=$(echo "$pub" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
priv=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.1.2.0/24 --availability-zone us-east-1b --output json)
priv_id=$(echo "$priv" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
igw=$(aws ec2 create-internet-gateway --output json)
igw_id=$(echo "$igw" | grep -o '"InternetGatewayId": "[^"]*"' | head -1 | cut -d'"' -f4)
aws ec2 attach-internet-gateway --internet-gateway-id "$igw_id" --vpc-id "$vpc_id" > /dev/null
rt=$(aws ec2 create-route-table --vpc-id "$vpc_id" --output json)
rt_id=$(echo "$rt" | grep -o '"RouteTableId": "[^"]*"' | head -1 | cut -d'"' -f4)
aws ec2 create-route --route-table-id "$rt_id" --destination-cidr-block 0.0.0.0/0 --gateway-id "$igw_id" > /dev/null
echo "associating the internet-routed table with ONLY the public subnet:"
aws ec2 associate-route-table --route-table-id "$rt_id" --subnet-id "$pub_id" > /dev/null
echo
echo "the route table this subnet actually uses:"
aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$pub_id" \
--query 'RouteTables[0].Routes' --output json
echo
echo "does the PRIVATE subnet have any association with an internet-routed table?"
priv_assoc=$(aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$priv_id" --query 'length(RouteTables)' --output text)
echo "route tables associated with the private subnet: $priv_assoc"
check "the public subnet's route table really does send 0.0.0.0/0 to the gateway" \
"$(aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$pub_id" --query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].GatewayId' --output text)" "$igw_id"
check "the private subnet has no such route, because nobody associated one" "$priv_assoc" "0"
What it printed, when the build ran it
associating the internet-routed table with ONLY the public subnet:
the route table this subnet actually uses:
[
{
"DestinationCidrBlock": "10.1.0.0/16",
"GatewayId": "local",
"Origin": "CreateRouteTable",
"State": "active"
},
{
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": "igw-96e800a602692e9fc",
"Origin": "CreateRoute",
"State": "active"
}
]
does the PRIVATE subnet have any association with an internet-routed table?
route tables associated with the private subnet: 0
ok the public subnet's route table really does send 0.0.0.0/0 to the gateway
ok the private subnet has no such route, because nobody associated one
"Public subnet" is a description of a route, not a property you set.
There is no --make-public true flag anywhere in this panel. A subnet with a
default route to an internet gateway is what everyone calls "public"; one without it is
what everyone calls "private." The vocabulary is a shorthand for exactly the routing
state just inspected above.
The firewall: a security group that opens exactly one port
A security group is stateful, allow-only, and denies everything not explicitly listed -- the same deny-by-default posture as IAM, applied to network traffic instead of API calls.
created=$(aws ec2 create-vpc --cidr-block 10.2.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
sg=$(aws ec2 create-security-group --group-name web-sg --description "web tier" --vpc-id "$vpc_id" --output json)
sg_id=$(echo "$sg" | grep -o '"GroupId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "before any rule is added, what does this security group allow inbound?"
before=$(aws ec2 describe-security-groups --group-ids "$sg_id" --query 'SecurityGroups[0].IpPermissions' --output json)
echo "$before"
echo
echo "opening exactly port 443, from anywhere:"
aws ec2 authorize-security-group-ingress --group-id "$sg_id" --protocol tcp --port 443 --cidr 0.0.0.0/0 > /dev/null
after=$(aws ec2 describe-security-groups --group-ids "$sg_id" --query 'SecurityGroups[0].IpPermissions' --output json)
echo "$after"
port_22_open=$(echo "$after" | grep -c '"FromPort": 22')
check "before any rule, nothing is allowed inbound at all" "$before" "[]"
check "port 443 is now explicitly open" "$(echo "$after" | grep -c '"FromPort": 443')" "1"
check "port 22 was never opened, because nobody asked for it" "$port_22_open" "0"
What it printed, when the build ran it
before any rule is added, what does this security group allow inbound?
[]
opening exactly port 443, from anywhere:
[
{
"IpProtocol": "tcp",
"FromPort": 443,
"ToPort": 443,
"UserIdGroupPairs": [],
"IpRanges": [
{
"CidrIp": "0.0.0.0/0"
}
],
"Ipv6Ranges": [],
"PrefixListIds": []
}
]
ok before any rule, nothing is allowed inbound at all
ok port 443 is now explicitly open
ok port 22 was never opened, because nobody asked for it
The mistake this prevents, when done right. The single most common
real misconfiguration in cloud security scans is a security group allowing
0.0.0.0/0 on port 22 (SSH) or 3389 (RDP) -- opened once for convenience
during setup and never closed. The panel above opened 443 and only 443, on purpose,
which is the entire discipline: name the port, name the source, and open nothing else.
20 EC2: virtual machines, launched for real
An EC2 instance is IaaS made concrete: you pick an image, a size, and hand it a startup script, and everything after boot is between the instance and that script. This lesson launches a real one, gives it real user data, and puts it through the exact lifecycle transitions a running fleet goes through every day.
User data: a script that runs itself, on first boot, with no one watching
User data is not a Docker-style entrypoint override -- it is a shell script (or cloud-init directive) the instance runs automatically once, the first time it boots. The instance stores it as base64, and the AWS CLI handles the encoding for you: give it plain text, get plain text back.
script='#!/bin/bash
echo "server ready" > /var/log/course-app.log
echo "started at boot, not by a human"'
echo "the exact script being handed to the instance:"
echo "$script"
instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro \
--count 1 --user-data "$script" --output json)
instance_id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo
echo "launched: $instance_id"
echo
echo "reading the user data straight back from AWS -- not from our own variable:"
stored_b64=$(aws ec2 describe-instance-attribute --instance-id "$instance_id" \
--attribute userData --query 'UserData.Value' --output text)
decoded=$(echo "$stored_b64" | base64 --decode)
echo "$decoded"
check "the decoded user data matches exactly what was sent" "$decoded" "$script"
check "the instance really exists with a real i-xxxx id" "$(echo "$instance_id" | grep -Ec '^i-')" "1"
What it printed, when the build ran it
the exact script being handed to the instance: #!/bin/bash echo "server ready" > /var/log/course-app.log echo "started at boot, not by a human" launched: i-242a187e22f7dd19b reading the user data straight back from AWS -- not from our own variable: #!/bin/bash echo "server ready" > /var/log/course-app.log echo "started at boot, not by a human" ok the decoded user data matches exactly what was sent ok the instance really exists with a real i-xxxx id
The real lifecycle: running, stopped, running again, gone
Four states, four different cost and availability implications: running
bills for compute, stopped only bills for the attached storage,
terminated is permanent (the instance ID never comes back). Watching the
actual state after each transition -- not assuming the command's own success message is
the ground truth -- is the habit this panel is really teaching.
instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
state_of() {
aws ec2 describe-instances --instance-ids "$1" \
--query 'Reservations[0].Instances[0].State.Name' --output text
}
echo "just launched: $(state_of "$id")"
aws ec2 stop-instances --instance-ids "$id" > /dev/null
echo "after stop: $(state_of "$id")"
aws ec2 start-instances --instance-ids "$id" > /dev/null
echo "after start again: $(state_of "$id")"
aws ec2 terminate-instances --instance-ids "$id" > /dev/null
echo "after terminate: $(state_of "$id")"
check "after the full sequence, the instance ends up terminated" "$(state_of "$id")" "terminated"
check "terminating really is the final, irreversible state" \
"$([ "$(state_of "$id")" = "terminated" ] && echo yes)" "yes"
What it printed, when the build ran it
just launched: running after stop: stopped after start again: running after terminate: terminated ok after the full sequence, the instance ends up terminated ok terminating really is the final, irreversible state
Why the first check above compares to "terminated," not "running."
By the time that check runs, every transition in the panel has already happened, so
asking for the state now correctly returns the LAST one -- confirming the full sequence
completed for real, not just that the launch succeeded. The echo lines above
it are where each individual transition is actually visible.
Tags: how a real account keeps thousands of instances comprehensible
An instance with no tags is a string of hex characters and nothing else. Tags are the only thing that turns "i-0a1b2c3d4e5f" into "the checkout service's web tier, owned by the payments team" -- and cost reports, automation, and access policies all key off them.
instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "before tagging, what does this instance's tag list contain?"
aws ec2 describe-instances --instance-ids "$id" --query 'Reservations[0].Instances[0].Tags' --output json
aws ec2 create-tags --resources "$id" --tags \
Key=Name,Value=course-web-server \
Key=Team,Value=payments \
Key=Environment,Value=staging
echo
echo "after tagging:"
tags=$(aws ec2 describe-instances --instance-ids "$id" --query 'Reservations[0].Instances[0].Tags' --output json)
echo "$tags"
echo
echo "finding this exact instance again, by tag alone, with no ID involved:"
found=$(aws ec2 describe-instances --filters "Name=tag:Team,Values=payments" \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
echo "found by tag search: $found"
check "all three tags were actually stored" "$(echo "$tags" | grep -c '"Key"')" "3"
check "the instance can be found again by tag alone, without knowing its ID" "$found" "$id"
What it printed, when the build ran it
before tagging, what does this instance's tag list contain?
null
after tagging:
[
{
"Key": "Name",
"Value": "course-web-server"
},
{
"Key": "Team",
"Value": "payments"
},
{
"Key": "Environment",
"Value": "staging"
}
]
finding this exact instance again, by tag alone, with no ID involved:
found by tag search: i-61702daeb04011fbe
ok all three tags were actually stored
ok the instance can be found again by tag alone, without knowing its ID
21 Load balancing and auto scaling
Two servers behind one address, and a rule for which one answers any given request -- that is the entire concept. The part worth actually seeing happen is what occurs when one of those servers stops being healthy: the load balancer's job is to notice and stop sending it traffic, automatically, without anyone paging anyone.
An ALB, a target group, and a listener: the three parts, wired together
An Application Load Balancer does not know about instances directly. It has listeners (which port, which protocol) that forward to a target group (a pool of backends with a health check), and the target group is what actually tracks instances.
created=$(aws ec2 create-vpc --cidr-block 10.6.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
sub1=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.6.1.0/24 --availability-zone us-east-1a --output json)
sub1_id=$(echo "$sub1" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
sub2=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.6.2.0/24 --availability-zone us-east-1b --output json)
sub2_id=$(echo "$sub2" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
alb=$(aws elbv2 create-load-balancer --name course-alb --subnets "$sub1_id" "$sub2_id" --output json)
alb_arn=$(echo "$alb" | grep -o '"LoadBalancerArn": "[^"]*"' | head -1 | cut -d'"' -f4)
alb_dns=$(echo "$alb" | grep -o '"DNSName": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "load balancer created, spanning two AZs, DNS name: $alb_dns"
tg=$(aws elbv2 create-target-group --name course-tg --protocol HTTP --port 80 \
--vpc-id "$vpc_id" --health-check-path /health --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "target group created, health check path: /health"
aws elbv2 create-listener --load-balancer-arn "$alb_arn" --protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn="$tg_arn" > /dev/null
echo "listener created: port 80 forwards to the target group above"
check "the load balancer spans more than one Availability Zone" \
"$(aws elbv2 describe-load-balancers --load-balancer-arns "$alb_arn" --query 'length(LoadBalancers[0].AvailabilityZones)' --output text)" "2"
check "the listener really forwards to the target group just created" \
"$(aws elbv2 describe-listeners --load-balancer-arn "$alb_arn" --query 'Listeners[0].DefaultActions[0].TargetGroupArn' --output text)" "$tg_arn"
What it printed, when the build ran it
load balancer created, spanning two AZs, DNS name: course-alb-1.us-east-1.elb.amazonaws.com target group created, health check path: /health listener created: port 80 forwards to the target group above ok the load balancer spans more than one Availability Zone ok the listener really forwards to the target group just created
Two real instances, registered, and both reporting healthy
created=$(aws ec2 create-vpc --cidr-block 10.7.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
tg=$(aws elbv2 create-target-group --name course-tg2 --protocol HTTP --port 80 --vpc-id "$vpc_id" --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)
i1=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id1=$(echo "$i1" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
i2=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id2=$(echo "$i2" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "two instances launched: $id1 and $id2"
aws elbv2 register-targets --target-group-arn "$tg_arn" --targets Id="$id1" Id="$id2"
echo
echo "the target group's own view of health, for both:"
health=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --output json)
echo "$health"
healthy_count=$(echo "$health" | grep -c '"State": "healthy"')
check "both registered instances report healthy" "$healthy_count" "2"
check "the target group really tracks both instance IDs" \
"$(echo "$health" | grep -c "$id1\|$id2" )" "2"
What it printed, when the build ran it
two instances launched: i-854294e89aa62d39c and i-a4bcf243e65b035d5
the target group's own view of health, for both:
{
"TargetHealthDescriptions": [
{
"Target": {
"Id": "i-854294e89aa62d39c",
"Port": 80
},
"HealthCheckPort": "traffic-port",
"TargetHealth": {
"State": "healthy"
}
},
{
"Target": {
"Id": "i-a4bcf243e65b035d5",
"Port": 80
},
"HealthCheckPort": "traffic-port",
"TargetHealth": {
"State": "healthy"
}
}
]
}
ok both registered instances report healthy
ok the target group really tracks both instance IDs
The actual feature: removing one, and watching the pool update
This is the entire point of the exercise. A target that stops being registered (whether by a real failed health check, or by deliberate removal, as here) simply disappears from what the load balancer will send traffic to -- no restart, no manual step, no ticket.
created=$(aws ec2 create-vpc --cidr-block 10.8.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
tg=$(aws elbv2 create-target-group --name course-tg3 --protocol HTTP --port 80 --vpc-id "$vpc_id" --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)
i1=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id1=$(echo "$i1" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
i2=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id2=$(echo "$i2" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
aws elbv2 register-targets --target-group-arn "$tg_arn" --targets Id="$id1" Id="$id2"
before_count=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --query 'length(TargetHealthDescriptions)' --output text)
echo "targets in the pool before removing anything: $before_count"
echo
echo "deregistering $id1, the way a failed health check would trigger automatically:"
aws elbv2 deregister-targets --target-group-arn "$tg_arn" --targets Id="$id1"
after=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --output json)
echo "$after"
after_count=$(echo "$after" | grep -c '"State"')
check "the pool really had two targets before" "$before_count" "2"
check "the pool has exactly one target after removing one" "$after_count" "1"
check "the remaining target is the one that was never removed" \
"$(echo "$after" | grep -c "$id2")" "1"
check "the removed instance no longer appears in the pool at all" \
"$(echo "$after" | grep -c "$id1")" "0"
What it printed, when the build ran it
targets in the pool before removing anything: 2
deregistering i-5fbc3c22af7263ae1, the way a failed health check would trigger automatically:
{
"TargetHealthDescriptions": [
{
"Target": {
"Id": "i-db88648ebb0944f1e",
"Port": 80
},
"HealthCheckPort": "traffic-port",
"TargetHealth": {
"State": "healthy"
}
}
]
}
ok the pool really had two targets before
ok the pool has exactly one target after removing one
ok the remaining target is the one that was never removed
ok the removed instance no longer appears in the pool at all
This is what "auto scaling" actually plugs into. An Auto Scaling Group does not talk to a load balancer through anything special -- it registers new instances into the same target group as they launch, and deregisters them as they terminate, using exactly the two API calls just run by hand above. Scaling out is "launch another instance, register it here"; scaling in is "deregister it, then terminate it." Nothing about that changes at any fleet size.