In lesson 02-01 we sized the Auto Scaling group asg-mercadofresco-tienda for the Friday peak: minimum 2, desired 2, maximum 4, with the cpu-objetivo-60 policy and the pico-viernes-tarde scheduled action. The numbers worked out: the Friday peak from 17:00 to 21:00 is 900 orders per hour, each instance handles 600 orders per hour, and with two instances there is capacity to spare. In 03-01 and 03-02 we gave them a network of their own and a firewall that protects them.

And yet MercadoFresco still goes down on Fridays. Because the ASG can launch four instances, but nobody sends traffic to the last three. The domain points at one specific IP, that of the first machine, and the rest sit switched on and idle while that one drowns. The central piece is missing: something that receives all the traffic and shares it out.

That is a load balancer. In this lesson Marta builds alb-mercadofresco-tienda, connects it to the Auto Scaling group and finishes solving the course's problem 1: the Friday outages. Along the way we meet health checks, which are the part that has brought down more applications than anything else by being badly configured.

Contents

  1. Why a load balancer is needed
  2. The types of load balancer: ALB, NLB, GWLB and the legacy CLB
  3. Anatomy of an ALB: listeners, rules and target groups
  4. Health checks: the dangerous part
  5. Integration with the Auto Scaling group
  6. Connection draining and deployments with no interruption
  7. Advanced routing by path and by host
  8. Redirects and fixed responses
  9. HTTPS on the load balancer: ACM, TLS policies and SNI
  10. Sticky sessions and why it is better not to need them
  11. Access logs and metrics
  12. Full creation from the CLI
  13. ALB cost and clean-up
  14. Friday's traffic, end to end

Why a load balancer is needed

A load balancer is a managed service that sits in front of a set of servers, receives every request and distributes them among the servers. What it brings goes well beyond the sharing out itself:

  • A single front door. The domain mercadofresco.example points at the load balancer, not at any instance. Instances can be created, die or change IP without anyone noticing.
  • Health checks. The load balancer periodically asks each instance whether it is well, and stops sending it traffic if it does not answer. A failure stops being an outage.
  • High availability across zones. Being in both public subnets, if the whole of eu-west-1a goes down, traffic keeps coming in through eu-west-1b.
  • TLS termination. The certificate lives on the load balancer, not on every instance. A single place to renew.
  • Real elastic scaling. The ASG registers new instances automatically, so the capacity added at 17:00 on Friday starts receiving traffic on its own.
  • Isolation. The instances move to private subnets with no public IP: only the load balancer talks to them.

Without a load balancer each of those six points has to be solved by hand, and none is solved well.

The types of load balancer: ALB, NLB, GWLB and the legacy CLB

AWS offers three modern load balancers and one legacy one. Choosing badly breaks nothing, but it costs money or functionality.

ALB
Application LB
NLB
Network LB
GWLB
Gateway LB
CLB
(legacy)
OSI layer 7 (application) 4 (transport) 3 (network) 4 and 7, halfway
Protocols HTTP, HTTPS, gRPC, WebSocket TCP, UDP, TLS IP (GENEVE) HTTP, HTTPS, TCP, SSL
Routes by Path, host, header, method, query string, source IP Port and protocol All traffic Port
Added latency ~milliseconds ~microseconds Low Milliseconds
Static IP No (DNS name) Yes, one elastic IP per AZ Not applicable No
Targets Instances, IPs, Lambda, nested ALB Instances, IPs, ALB Virtual appliances Instances
Terminates TLS Yes Yes No Yes
Preserves the source IP In the X-Forwarded-For header Yes, natively Yes In the header
Performance Millions of requests/s Millions of connections/s High Limited
Use case Web applications and APIs Games, IoT, MQTT, extreme latency, fixed IP Third-party firewalls and IDS Nothing new

MercadoFresco's choice is the ALB, and the reasons are concrete:

  1. The traffic is HTTP/HTTPS. That is exactly what the ALB exists for.
  2. Path-based routing will be needed: /api/* to the orders service and everything else to the shop. An NLB cannot do it, because it does not read the HTTP request.
  3. Automatic HTTP to HTTPS redirection is needed, which the ALB does natively.
  4. The extra milliseconds of latency are irrelevant for a shop; for a real-time video game they would not be, and then the answer would be the NLB.

About the CLB: it is the first generation, it is still around for compatibility and it must not be used for anything new. If you come across one, migrating it to an ALB is almost always direct.

One pattern worth knowing even though we will not use it: an NLB in front of an ALB. It serves to obtain a fixed IP (because a payment provider demands an IP allowlist, for instance) while keeping the ALB's path-based routing.

Anatomy of an ALB: listeners, rules and target groups

An ALB has four pieces and you need them clear, because the CLI creates them one by one:

flowchart TB
    NET["Internet"]
    subgraph ALB["alb-mercadofresco-tienda (public subnets in 1a and 1b)"]
        L80["HTTP listener :80<br/>default action: redirect to HTTPS"]
        L443["HTTPS listener :443<br/>ACM certificate"]
        R1["Rule 10: path = /api/*"]
        R2["Rule 20: host = admin.mercadofresco.example"]
        RD["Default action"]
    end
    subgraph TGS["Target groups"]
        TG1["tg-mercadofresco-tienda<br/>protocol HTTPS :443"]
        TG2["tg-mercadofresco-api<br/>protocol HTTPS :443"]
        TG3["tg-mercadofresco-admin"]
    end
    E1["ASG instances<br/>snet-app-a"]
    E2["ASG instances<br/>snet-app-b"]

    NET --> L80
    NET --> L443
    L443 --> R1 --> TG2
    L443 --> R2 --> TG3
    L443 --> RD --> TG1
    TG1 --> E1
    TG1 --> E2
Piece What it is Important detail
Load balancer The resource itself, with its DNS name Must be in at least two subnets in different AZs
Listener A port and protocol being listened on Each one has a mandatory default action
Rule Condition + action, inside a listener Evaluated in ascending priority order
Target group The set of targets and their health check The health check lives here, not on the load balancer

Two concepts people confuse:

  • The target group has its own protocol and port, independent of the listener's. The listener can receive HTTPS on 443 and forward to the instances over HTTP on port 8080. That is TLS termination.
  • The health check is defined on the target group, not on the load balancer. One load balancer can have groups with very different checks.

The available target types:

Type What it registers When it is used
instance EC2 instance ID The usual choice with an ASG
ip Specific private IPs Containers, on-premises targets over VPN
lambda A Lambda function Serverless APIs behind an ALB
alb Another ALB Only from an NLB, for the fixed-IP pattern

And a target group's distribution algorithms:

Algorithm How it distributes When
round_robin One to each target in turn Default; homogeneous requests
least_outstanding_requests To the target with fewest requests in flight Recommended when requests take very different times
weighted_random Random with weights With anomaly mitigation enabled

For MercadoFresco, least_outstanding_requests: loading the home page does not take the same time as confirming an order through a payment gateway.

Health checks: the dangerous part

The load balancer periodically sends a request to each registered target. If it answers correctly a given number of times in a row, it marks it healthy and sends it traffic. If it fails, it marks it unhealthy and stops sending.

Parameters and recommended values for MercadoFresco:

Parameter What it is Default MercadoFresco Reason
HealthCheckPath The path requested / /salud A dedicated endpoint, not the home page
HealthCheckProtocol HTTP or HTTPS HTTP HTTPS Consistent with the target group
HealthCheckIntervalSeconds How often it asks 30 s 15 s Detect failures sooner
HealthCheckTimeoutSeconds How long it waits for the reply 5 s 5 s Lower than the interval, always
HealthyThresholdCount Successes to declare it healthy 5 2 Bring it back quickly
UnhealthyThresholdCount Failures to declare it unhealthy 2 3 Tolerate a one-off spike
Matcher Accepted HTTP codes 200 200 Explicit and strict

With these values, a downed instance takes at most 3 × 15 = 45 seconds to stop receiving traffic.

What really happens when an instance is marked unhealthy

This is the exact sequence, and it is worth detailing because there is a surprising knock-on effect:

  1. The ALB stops sending it new requests. The ones already in flight are completed.
  2. The instance stays switched on and keeps costing money. The ALB does not shut it down.
  3. The target group's HealthyHostCount metric drops by one.
  4. All the traffic is spread across the remaining instances. If there were two and one is left, that one receives double.
  5. If the ASG has the load balancer health check enabled (--health-check-type ELB), the ASG terminates the instance and launches a new one.

Step 4 is the danger. Imagine a /salud that queries the database. If the database gets slow for a moment, every instance fails the check at once, the ALB marks them all unhealthy and answers 503 Service Unavailable to every customer. The application was working; the health check took it down.

Worse still with the ASG in ELB mode: the ASG terminates every instance and launches new ones, which also fail because the database is still slow, and it enters a perpetual replacement loop.

How to write a health check that does not take the application down

The rule: the health check must verify that THIS instance can serve traffic, not that the whole system is working.

# In the shop application: two different endpoints with two different purposes.

@app.route("/salud")
def health():
    """Check for the load balancer. It must be fast, local and NOT touch dependencies.
    It answers one thing only: is this process alive and ready to serve requests?"""
    if not app.config.get("STARTUP_COMPLETE"):
        return {"status": "starting"}, 503
    return {"status": "ok", "version": app.config["VERSION"]}, 200


@app.route("/salud/profunda")
def deep_health():
    """Check for monitoring (CloudWatch, 05-01). This one does query dependencies,
    but the load balancer NEVER uses it: it is for alerting, not for pulling instances."""
    result = {"database": "ok", "s3": "ok", "efs": "ok"}
    code = 200
    try:
        with get_connection() as conn:
            conn.execute("SELECT 1")
    except Exception as e:
        result["database"] = f"error: {type(e).__name__}"
        code = 503
    return result, code

The difference between the two endpoints is the whole lesson:

  • /salud answers in microseconds, depends on nothing external and can only fail if this instance is broken. If it fails, pulling it out is always the right decision.
  • /salud/profunda does check dependencies, but its failure should raise an alarm so that somebody looks, not the automatic removal of healthy servers.

The 503 during start-up matters just as much: it stops the ALB sending traffic to an instance that is still downloading the application in user-data-tienda.sh.

Integration with the Auto Scaling group

Connecting asg-mercadofresco-tienda to the target group takes a single command, and from then on everything is automatic:

aws autoscaling attach-load-balancer-target-groups \
  --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --target-group-arns "$TG_ARN"

What happens from that moment on:

Event What the ASG does What the ALB does
The cpu-objetivo-60 policy launches an instance Creates it from lt-mercadofresco-tienda Registers it in the group, state initial
The instance starts and /salud answers 200 twice It turns healthy and starts receiving traffic
The peak passes and the ASG scales in Marks the instance for termination Puts it into draining
The in-flight connections finish Waits for the deregistration_delay Removes it from the group
The ALB marks an instance unhealthy With --health-check-type ELB, it replaces it Stops sending it traffic

The second important change is the ASG's health check type:

aws autoscaling update-auto-scaling-group \
  --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --health-check-type ELB \
  --health-check-grace-period 300
Type What the ASG checks Problem
EC2 (default) Only the hypervisor and system status An instance with a dead application shows as healthy
ELB The hypervisor status and the target group's Detects downed applications, not just downed machines

The --health-check-grace-period 300 matters as much as the type: it is the number of seconds the ASG waits from the moment the instance starts before paying attention to the load balancer. MercadoFresco's user-data-tienda.sh takes about two minutes to install and start the application; with no grace period the ASG would kill the instance just before it finished starting, and would do so in a loop. Five minutes give enough slack.

Connection draining and deployments with no interruption

When an instance is deregistered, the ALB does not cut open connections dead: it puts it into the draining state. During the deregistration delay (deregistration_delay.timeout_seconds, 300 seconds by default) it receives no new requests, but it finishes the ones it had in flight.

aws elbv2 modify-target-group-attributes \
  --profile mercadofresco-dev --region eu-west-1 \
  --target-group-arn "$TG_ARN" \
  --attributes \
    Key=deregistration_delay.timeout_seconds,Value=60 \
    Key=load_balancing.algorithm.type,Value=least_outstanding_requests \
    Key=stickiness.enabled,Value=false

How to choose the value: the longest request your application serves, plus a margin.

Application type Recommended value
API with millisecond responses 15-30 s
Shop with order confirmation and payment gateway 60 s
Large file uploads 180-300 s
Long-lived WebSocket Up to 3600 s

MercadoFresco's 60 seconds cover the worst case: a customer who has clicked "confirm order" and is waiting for the gateway's answer. With the default of 300 s, scaling in after the Friday peak would take five minutes longer while paying for idle instances; with 5 s, a payment would be cut off halfway.

Advanced routing by path and by host

This is where the ALB earns its name: it reads the HTTP request and decides based on its content. MercadoFresco needs three different destinations under the same load balancer.

# Rule 10: anything starting with /api/ goes to the API group
aws elbv2 create-rule --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" \
  --priority 10 \
  --conditions '[{"Field":"path-pattern","Values":["/api/*"]}]' \
  --actions "[{\"Type\":\"forward\",\"TargetGroupArn\":\"$TG_API\"}]"

# Rule 20: the administration panel, by host
aws elbv2 create-rule --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" \
  --priority 20 \
  --conditions '[{"Field":"host-header","Values":["admin.mercadofresco.example"]}]' \
  --actions "[{\"Type\":\"forward\",\"TargetGroupArn\":\"$TG_ADMIN\"}]"

The available conditions and an example of each:

Condition Example Use in MercadoFresco
path-pattern /api/* Separating the API from the website
host-header admin.mercadofresco.example Internal panel on its own subdomain
http-header X-Canario: si Targeted testing before a deployment
http-request-method POST Routing writes to a different group
query-string version=beta Switching on a new version with a parameter
source-ip 81.45.20.7/32 Restricting /admin to the office

Evaluation rules you need to know:

  • Rules are evaluated in ascending priority order and the first match applies. Leave gaps between priorities (10, 20, 30) so you can insert new ones.
  • If none matches, the listener's default action applies.
  • Path patterns are case sensitive and accept * and ?.
  • A rule can combine up to 5 conditions, and all of them must be met (AND).
  • An ALB supports up to 100 rules per listener.

Redirects and fixed responses

Besides forwarding, a listener can answer by itself, without touching any instance.

HTTP to HTTPS redirection, the standard configuration of the port 80 listener:

aws elbv2 create-listener --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN" \
  --protocol HTTP --port 80 \
  --default-actions '[{
    "Type": "redirect",
    "RedirectConfig": {
      "Protocol": "HTTPS",
      "Port": "443",
      "Host": "#{host}",
      "Path": "/#{path}",
      "Query": "#{query}",
      "StatusCode": "HTTP_301"
    }
  }]'

The placeholders #{host}, #{path} and #{query} preserve what the client asked for: anyone landing on http://mercadofresco.example/producto/tomate?origen=email ends up on the same page over HTTPS. A 301 is permanent and the browser caches it, so the second visit no longer goes through port 80. If you were testing and did not want it cached, you would use HTTP_302.

Fixed response, useful for blocking paths or serving maintenance pages:

aws elbv2 create-rule --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" --priority 5 \
  --conditions '[{"Field":"path-pattern","Values":["/.env","/.git/*","/wp-admin/*"]}]' \
  --actions '[{
    "Type": "fixed-response",
    "FixedResponseConfig": {
      "StatusCode": "403",
      "ContentType": "text/plain",
      "MessageBody": "Forbidden"
    }
  }]'

That rule, with priority 5 (the highest), cuts off at the load balancer the automated attempts to read configuration files. They never even touch an instance. It is coarse filtering: serious filtering of malicious requests is AWS WAF, in 04-05.

HTTPS on the load balancer: ACM, TLS policies and SNI

MercadoFresco still serves over HTTP. Adding HTTPS with AWS is free and takes two steps.

AWS Certificate Manager (ACM) issues public TLS certificates at no cost and renews them automatically as long as they are attached to an AWS resource. That removes at a stroke the classic problem of the certificate that expires on a Sunday.

aws acm request-certificate --profile mercadofresco-dev --region eu-west-1 \
  --domain-name mercadofresco.example \
  --subject-alternative-names "*.mercadofresco.example" \
  --validation-method DNS \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=tienda Key=Propietario,Value=marta \
         Key=CentroCoste,Value=operaciones

Two details:

  • The wildcard *.mercadofresco.example covers www, admin, api and any future subdomain with a single certificate.
  • The region matters: for an ALB, the certificate must be in the same region as the ALB (eu-west-1). For CloudFront it has to be in us-east-1, a trap we will see in 03-04.

The certificate stays in PENDING_VALIDATION until you prove the domain is yours by creating a CNAME record that ACM specifies. That DNS validation is completed in lesson 03-05, once we have the Route 53 hosted zone. Until then, to practise you can create the HTTPS listener with an imported self-signed certificate, or work with port 80 only.

The HTTPS listener, once the certificate has been issued:

LISTENER_443=$(aws elbv2 create-listener --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN" \
  --protocol HTTPS --port 443 \
  --certificates CertificateArn="$CERT_ARN" \
  --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --default-actions "Type=forward,TargetGroupArn=$TG_TIENDA" \
  --query 'Listeners[0].ListenerArn' --output text)

The TLS security policies define which versions and ciphers are accepted:

Policy TLS supported Comment
ELBSecurityPolicy-TLS13-1-2-2021-06 1.2 and 1.3 Recommended: secure and compatible with every current browser
ELBSecurityPolicy-TLS13-1-3-2021-06 1.3 only Maximum security; breaks old clients
ELBSecurityPolicy-FS-1-2-Res-2020-10 1.2 with mandatory forward secrecy Environments with compliance requirements
ELBSecurityPolicy-2016-08 1.0, 1.1, 1.2 Do not use: it allows TLS 1.0

TLS termination means the ALB decrypts the traffic and decides what to do with the request in clear. It is the only way to route by path or by header: to read /api/* you have to be able to read the request. From the ALB to the instances, traffic can travel in clear (faster, and it travels over AWS's private network inside your VPC) or be encrypted again. MercadoFresco re-encrypts it: the target group uses HTTPS on 443, because internal policy demands end-to-end encryption even when the hop is private.

SNI (Server Name Indication) allows several certificates on the same listener: the client states which domain it wants in the TLS handshake and the ALB presents the right certificate. That way a single ALB can serve mercadofresco.example and, in the future, mercadofresco.pt with different certificates, without extra load balancers.

# Add a second certificate to the same listener
aws elbv2 add-listener-certificates --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" \
  --certificates CertificateArn="$CERT_ARN_PT"

Sticky sessions and why it is better not to need them

Sticky sessions (stickiness) make a given client always go to the same instance. The ALB inserts a cookie (AWSALB, or one belonging to the application) and uses it to direct the following requests.

aws elbv2 modify-target-group-attributes --profile mercadofresco-dev --region eu-west-1 \
  --target-group-arn "$TG_TIENDA" \
  --attributes \
    Key=stickiness.enabled,Value=true \
    Key=stickiness.type,Value=lb_cookie \
    Key=stickiness.lb_cookie.duration_seconds,Value=3600

It is useful when the application keeps session state in the server's memory: the shopping basket, for example. And it brings four serious problems:

Problem Real consequence for MercadoFresco
Uneven distribution On Friday, the new instances start empty and the old ones keep every client stuck to them. You pay for capacity that relieves nobody
Session loss If the ASG terminates that instance as the peak drops, the customer loses the basket
Destructive deployments Every update throws all the users off the instance being replaced
Useless scaling Adding instances does not help the users already connected

That is why MercadoFresco has stickiness.enabled=false and the application is stateless: the session is kept outside the instance. The options:

Where to keep the session Advantage Drawback
ElastiCache (Redis) Fast, milliseconds, the industry standard One more service to operate — covered in 06-05
DynamoDB Serverless, no operations, automatic TTL Slightly higher latency — covered in 06-02
Signed cookie on the client Zero infrastructure Limited size, the data travels on every request
Relational database It already exists Unnecessary load on RDS

MercadoFresco will end up using ElastiCache. In the meantime, sticky sessions are an acceptable patch if documented as technical debt; what is not acceptable is enabling them and forgetting why.

Access logs and metrics

Access logs. The ALB can dump one line per request into S3, with the client's IP, the path, the response code, the processing times and the target that served it. They are switched off by default.

aws elbv2 modify-load-balancer-attributes --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN" \
  --attributes \
    Key=access_logs.s3.enabled,Value=true \
    Key=access_logs.s3.bucket,Value=mercadofresco-registros-web \
    Key=access_logs.s3.prefix,Value=alb \
    Key=idle_timeout.timeout_seconds,Value=60 \
    Key=routing.http.drop_invalid_header_fields.enabled,Value=true

The bucket mercadofresco-registros-web needs a policy authorising the service to write to it; without it the command appears to work but no file ever shows up. A discreet cost warning: in a shop with traffic these logs grow fast. Apply the lifecycle rule we saw in 02-03 to move them to Glacier after 30 days.

Key ALB metrics in CloudWatch:

Metric What it measures Why it matters at MercadoFresco
RequestCount Requests served Confirms the Friday 17:00 to 21:00 peak with data
TargetResponseTime How long the instances take If it rises before CPU does, the bottleneck is RDS
HTTPCode_Target_5XX_Count Errors generated by the application The application is failing
HTTPCode_ELB_5XX_Count Errors generated by the load balancer There are no healthy targets: the classic 503
HealthyHostCount Healthy targets per AZ If it drops below 2, redundancy has been lost
UnHealthyHostCount Unhealthy targets Spots broken instances before customers do
ActiveConnectionCount Open connections Sizing and detecting exhaustion
RejectedConnectionCount Connections rejected on reaching a limit The ALB has not scaled in time for a sharp spike

The distinction between HTTPCode_Target_5XX_Count and HTTPCode_ELB_5XX_Count is the most useful in the table: the first means "the application returned an error", the second "there was nobody to ask". Creating alarms and dashboards on these metrics is covered in 05-01; here it is enough to know what to look at.

Full creation from the CLI

We start from the 03-01 and 03-02 variables ($VPC_ID, $PUB_A, $PUB_B, $SG_ALB, $SG_TIENDA).

# 1. The load balancer, in BOTH public subnets
ALB_ARN=$(aws elbv2 create-load-balancer \
  --profile mercadofresco-dev --region eu-west-1 \
  --name alb-mercadofresco-tienda \
  --type application \
  --scheme internet-facing \
  --ip-address-type ipv4 \
  --subnets "$PUB_A" "$PUB_B" \
  --security-groups "$SG_ALB" \
  --tags Key=Name,Value=alb-mercadofresco-tienda \
         Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=tienda Key=Propietario,Value=marta \
         Key=CentroCoste,Value=operaciones \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)

# 2. The target group, with the health check properly tuned
TG_TIENDA=$(aws elbv2 create-target-group \
  --profile mercadofresco-dev --region eu-west-1 \
  --name tg-mercadofresco-tienda \
  --protocol HTTPS --port 443 \
  --vpc-id "$VPC_ID" \
  --target-type instance \
  --health-check-protocol HTTPS \
  --health-check-path /salud \
  --health-check-interval-seconds 15 \
  --health-check-timeout-seconds 5 \
  --healthy-threshold-count 2 \
  --unhealthy-threshold-count 3 \
  --matcher HttpCode=200 \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=tienda Key=Propietario,Value=marta \
         Key=CentroCoste,Value=operaciones \
  --query 'TargetGroups[0].TargetGroupArn' --output text)

# 3. Target group attributes
aws elbv2 modify-target-group-attributes --profile mercadofresco-dev --region eu-west-1 \
  --target-group-arn "$TG_TIENDA" \
  --attributes \
    Key=deregistration_delay.timeout_seconds,Value=60 \
    Key=load_balancing.algorithm.type,Value=least_outstanding_requests \
    Key=stickiness.enabled,Value=false

# 4. HTTPS listener (requires the certificate; see 03-05 for the validation)
LISTENER_443=$(aws elbv2 create-listener --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN" --protocol HTTPS --port 443 \
  --certificates CertificateArn="$CERT_ARN" \
  --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --default-actions "Type=forward,TargetGroupArn=$TG_TIENDA" \
  --query 'Listeners[0].ListenerArn' --output text)

# 5. HTTP listener that only redirects
aws elbv2 create-listener --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN" --protocol HTTP --port 80 \
  --default-actions '[{"Type":"redirect","RedirectConfig":{
      "Protocol":"HTTPS","Port":"443","Host":"#{host}","Path":"/#{path}",
      "Query":"#{query}","StatusCode":"HTTP_301"}}]'

# 6. Attach the ASG and make it trust the load balancer
aws autoscaling attach-load-balancer-target-groups \
  --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --target-group-arns "$TG_TIENDA"

aws autoscaling update-auto-scaling-group \
  --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --health-check-type ELB --health-check-grace-period 300

# 7. Wait for the ALB to become active and get its DNS name
aws elbv2 wait load-balancer-available --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arns "$ALB_ARN"

aws elbv2 describe-load-balancers --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arns "$ALB_ARN" \
  --query 'LoadBalancers[0].DNSName' --output text

The last command returns something along the lines of alb-mercadofresco-tienda-1234567890.eu-west-1.elb.amazonaws.com. That name is MercadoFresco's new front door. Making mercadofresco.example point at it is exactly the job of lesson 03-05.

Checking the health of the targets, the command used most on a daily basis:

aws elbv2 describe-target-health --profile mercadofresco-dev --region eu-west-1 \
  --target-group-arn "$TG_TIENDA" \
  --query 'TargetHealthDescriptions[].{
      Instance:Target.Id, Port:Target.Port,
      State:TargetHealth.State, Reason:TargetHealth.Reason,
      Detail:TargetHealth.Description}' \
  --output table

The reasons that show up most and what they really mean:

Reason Meaning Where to look
Elb.RegistrationInProgress Just registered, not yet checked Wait
Elb.InitialHealthChecking Being checked for the first time Wait
Target.Timeout Does not answer in time Security group (03-02) or the application is down
Target.FailedHealthChecks Answers, but not as expected HTTP code different from the Matcher
Target.ResponseCodeMismatch Returns 302, 404, 500… The /salud path does not exist or redirects
Target.NotInUse The group is not attached to any listener Step 4 is missing
Target.DeregistrationInProgress Draining connections Normal when scaling in

Target.Timeout is nearly always the same thing: the sg-mercadofresco-tienda group does not accept 443 from sg-mercadofresco-alb. If you followed lesson 03-02, it is already solved.

ALB cost and clean-up

⚠️ Cost warning: the ALB charges by the hour even if it receives no requests at all

In eu-west-1 the price has two components:

  • Load balancer hour: ≈ 0.027 USD/h → ≈ 20 USD a month just for existing.
  • LCU (Load Balancer Capacity Unit): ≈ 0.008 USD per LCU-hour.

An LCU is the maximum of four dimensions measured per hour: 25 new connections per second, 3,000 active connections per second, 1 GB/hour processed, or 1,000 rule evaluations per second. Only the highest dimension is billed, not the sum.

Estimate for MercadoFresco: around 2 LCUs on average → 2 × 0.008 × 730 ≈ 12 USD/month. In total, about 32 USD a month. Compared with the 70 USD of the two NAT gateways in 03-01, it is cheap for what it gives, but it is not in the free tier.

To practise: build the ALB, check that it distributes traffic and delete it the same day. A forgotten ALB costs 20 USD a month indefinitely and will blow the 10 USD budget we set up in 01-02 towards [email protected].

Clean-up, in order:

# 1. Detach the ASG (otherwise it would register instances again)
aws autoscaling detach-load-balancer-target-groups \
  --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --target-group-arns "$TG_TIENDA"

# 2. Return the ASG to the basic health check
aws autoscaling update-auto-scaling-group --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda --health-check-type EC2

# 3. Delete the load balancer (this removes its listeners too)
aws elbv2 delete-load-balancer --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arn "$ALB_ARN"
aws elbv2 wait load-balancers-deleted --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arns "$ALB_ARN"

# 4. Delete the target group (not possible before: it depends on the listener)
aws elbv2 delete-target-group --profile mercadofresco-dev --region eu-west-1 \
  --target-group-arn "$TG_TIENDA"

# 5. Check that none is left alive in the region
aws elbv2 describe-load-balancers --profile mercadofresco-dev --region eu-west-1 \
  --query 'LoadBalancers[].[LoadBalancerName,State.Code]' --output table

The ACM certificate costs nothing, so it can be left in place.

Friday's traffic, end to end

flowchart TB
    U["Customers<br/>900 orders/hour (Friday 17-21 h)"]
    DNS["mercadofresco.example<br/>(Route 53, lesson 03-05)"]

    subgraph VPC["vpc-mercadofresco"]
        subgraph PUBS["Public subnets"]
            NA["ALB node<br/>eu-west-1a"]
            NB["ALB node<br/>eu-west-1b"]
        end
        TG["tg-mercadofresco-tienda<br/>/salud check every 15 s<br/>least_outstanding_requests"]
        subgraph APPS["Application subnets (no public IP)"]
            I1["tienda-01 · 600 orders/h"]
            I2["tienda-02 · 600 orders/h"]
            I3["tienda-03 · peak"]
            I4["tienda-04 · peak"]
        end
        RDS[("mercadofresco-pedidos<br/>data subnets")]
    end

    U --> DNS --> NA
    DNS --> NB
    NA --> TG
    NB --> TG
    TG --> I1
    TG --> I2
    TG -.->|"added by<br/>pico-viernes-tarde"| I3
    TG -.-> I4
    I1 --> RDS
    I2 --> RDS

And Friday's arithmetic, now complete:

Moment Healthy instances Capacity Demand Margin
Tuesday 11:00 2 1,200 orders/h ~100 orders/h 12×
Friday 16:45 (pico-viernes-tarde action) 4 2,400 orders/h ~200 orders/h Ready
Friday 19:00 (the real peak) 4 2,400 orders/h 900 orders/h 2.6×
Friday 19:00 with one instance down 3 1,800 orders/h 900 orders/h
Friday 19:00 with the whole 1a AZ down 2 1,200 orders/h 900 orders/h 1.3× — it holds
Friday 22:00 (cpu-objetivo-60 scales in) 2 1,200 orders/h ~150 orders/h

The second-to-last row is what justifies all the work of these three lessons: losing a whole Availability Zone in the middle of the Friday peak no longer takes MercadoFresco down. Problem 1 is solved.

Common Mistakes and Tips

Putting the load balancer in a single subnet. AWS requires at least two AZs, but if you register instances in only one of them, you lose all the redundancy with no warning at all.

Using / as the health check path. The home page queries the database, loads the catalogue and takes hundreds of milliseconds. If the database slows down, every instance fails at once and the ALB returns 503 with a perfectly healthy application. Use a local, trivial /salud.

Setting too short a grace period on the ASG. With --health-check-grace-period 60 and a two minute start-up, the ASG kills every instance just before it finishes starting, in a loop, spending money without serving anything.

Leaving the deregistration delay at 300 seconds. Scaling in after the peak takes five minutes longer. Tune it to the duration of your longest request.

Enabling sticky sessions to "fix" a session problem. It works today and breaks scaling tomorrow. It is technical debt: document it and plan to move the session out of the instance.

Forgetting the log bucket's policy. The enabling command gives no error, but no file ever appears in mercadofresco-registros-web.

Confusing HTTPCode_ELB_5XX_Count with HTTPCode_Target_5XX_Count. The first means there are no healthy targets; the second, that the application is returning errors. They are different incidents and they are fixed in different places.

Requesting the ACM certificate in the wrong region. For an ALB, in the ALB's region. For CloudFront, always in us-east-1. We will see it in 03-04.

Leaving a test ALB running. 20 USD a month for a resource nobody uses. Check describe-load-balancers in every region where you have been practising.

The golden tip: before connecting the ASG, register one instance by hand in the target group and check that it turns healthy. If it does not, the problem is the security group or the health check, and it is far easier to diagnose with a single instance than with four.

Exercises

Exercise 1: diagnosing an ALB that returns 503

Marta deploys the ALB and, on opening its DNS name, gets 503 Service Unavailable. In describe-target-health both instances show as unhealthy with reason Target.Timeout. List at least five possible causes, ordered from most to least likely, and the specific command that confirms or rules out each one.

Exercise 2: designing the complete routing

MercadoFresco needs, under a single ALB: the shop at the root; the API on /api/* with a target group of its own; the panel on admin.mercadofresco.example reachable only from the office IP 81.45.20.7; a 410 Gone for the old path /tienda-antigua/*; and redirection of all HTTP traffic to HTTPS. Write the rules with their priorities and justify the order.

Exercise 3: calculating the cost and checking the capacity

Using MercadoFresco's data (2 instances during normal hours, 4 on Fridays from 17:00 to 21:00, 900 orders/hour at the peak, 600 orders/hour per instance), work out: the monthly cost of the ALB assuming 3 LCUs on average, how many instances would be needed if the business grew to 3,000 orders/hour, and whether the current ASG would cope.

Solutions

Solution 1

Causes, from most to least likely:

1. The instances' security group does not accept traffic from the load balancer. It is the number one cause of Target.Timeout.

aws ec2 describe-security-groups --profile mercadofresco-dev --region eu-west-1 \
  --group-ids "$SG_TIENDA" \
  --query 'SecurityGroups[0].IpPermissions[].{P:FromPort,Groups:UserIdGroupPairs[].GroupId}'

Port 443 with $SG_ALB in UserIdGroupPairs must appear.

2. The application is not listening on the target group's port. The group points at 443 but the application listens on 8080.

aws ssm start-session --profile mercadofresco-dev --region eu-west-1 --target "$INSTANCE_ID"
# once inside:  ss -lntp | grep -E ':(443|8080)'

3. The /salud path does not exist. It would give Target.ResponseCodeMismatch with a 404, but if the server does not answer that path at all, it shows up as Target.Timeout.

# From another instance in the VPC
curl -k -i https://10.0.32.15/salud

4. The instances are in subnets with no route and the ALB cannot reach them. Check that the ASG's subnets are in the same VPC as the load balancer and that the ALB's AZs include the instances'.

aws elbv2 describe-load-balancers --profile mercadofresco-dev --region eu-west-1 \
  --load-balancer-arns "$ALB_ARN" --query 'LoadBalancers[0].AvailabilityZones[].[ZoneName,SubnetId]'

5. A NACL is blocking the return traffic. The ephemeral port range is missing on the application subnet's outbound rules (lesson 03-02). It is confirmed in the Flow Logs by an inbound ACCEPT and an outbound REJECT.

6. Less likely but real: the ASG's grace period is short and the instances are being replaced before they start; you see it in describe-scaling-activities with continuous replacements.

Solution 2

Priority Condition Action Why in that order
10 host-header = admin.mercadofresco.example AND source-ip = 81.45.20.7/32 forwardtg-mercadofresco-admin Must be evaluated before rule 20, which denies the rest
20 host-header = admin.mercadofresco.example fixed-response 403 Catches panel access from any other IP
30 path-pattern = /tienda-antigua/* fixed-response 410 Before /api/* and before the default action
40 path-pattern = /api/* forwardtg-mercadofresco-api After the specific rules
(default) forwardtg-mercadofresco-tienda Everything else

Justification of the order: the key is the 10/20 pair. Rules are evaluated in ascending priority and the first match wins; if 20 had the lower priority, it would also catch the office's legitimate traffic and the panel would be unreachable for everybody. The condition on rule 10 combines two fields with AND, which is how an ALB's multiple conditions work.

aws elbv2 create-rule --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" --priority 10 \
  --conditions '[
    {"Field":"host-header","HostHeaderConfig":{"Values":["admin.mercadofresco.example"]}},
    {"Field":"source-ip","SourceIpConfig":{"Values":["81.45.20.7/32"]}}]' \
  --actions "[{\"Type\":\"forward\",\"TargetGroupArn\":\"$TG_ADMIN\"}]"

aws elbv2 create-rule --profile mercadofresco-dev --region eu-west-1 \
  --listener-arn "$LISTENER_443" --priority 20 \
  --conditions '[{"Field":"host-header","HostHeaderConfig":{"Values":["admin.mercadofresco.example"]}}]' \
  --actions '[{"Type":"fixed-response","FixedResponseConfig":{
      "StatusCode":"403","ContentType":"text/plain","MessageBody":"Restricted access"}}]'

The port 80 listener keeps the 301 redirect as its default action, with no rules: that way all the HTTP traffic, including the panel's, ends up on HTTPS.

An honest caveat: filtering by source-ip on the ALB works, but if CloudFront is put in front tomorrow (lesson 03-04), the ALB will see CloudFront's IP and not the client's. You would then have to filter in WAF (04-05) or on the X-Forwarded-For header.

Solution 3

Monthly cost of the ALB:

  • Hours: 0.027 USD/h × 730 h = 19.71 USD
  • LCUs: 3 LCUs × 0.008 USD × 730 h = 17.52 USD
  • Total ≈ 37.23 USD/month

Compared with the complete network infrastructure: ALB ≈ 37 USD + 2 NAT gateways ≈ 70 USD = 107 USD a month on networking alone, without counting instances or the database. That is the argument for reviewing in 11-03 whether the two NATs are worth it.

Instances needed for 3,000 orders/hour:

  • Strict capacity: 3,000 ÷ 600 = 5 instances.
  • But you have to survive the loss of a whole Availability Zone. With instances spread across 2 AZs, losing one leaves half. For the surviving half to handle 3,000 orders/h you need 10 instances, 5 per AZ.
  • A reasonable compromise, accepting degradation in the worst case: 8 instances (4 per AZ). With one AZ down you are left with 4 × 600 = 2,400 orders/h, 80 % of demand: the shop is slow but stays up.

Would the current ASG cope? No. asg-mercadofresco-tienda has a maximum of 4, that is, 2,400 orders per hour. With 3,000 of demand it would fall 20 % short even with no failures. The changes needed:

aws autoscaling update-auto-scaling-group --profile mercadofresco-dev --region eu-west-1 \
  --auto-scaling-group-name asg-mercadofresco-tienda \
  --min-size 4 --desired-capacity 4 --max-size 10

And before calling the change good, three more things have to be checked: that RDS can cope with 10 instances opening connections (or a pool is needed, or Aurora, covered in 06-03), that the subnets have free IPs (with a /20 there are plenty) and that the account's instance quota allows it. Scaling the web tier is the easy part; the bottleneck moves to the database.

Conclusion

MercadoFresco finally has a single front door. You know why a load balancer is needed beyond the sharing out itself: health checks, high availability across zones, TLS termination in one place and the possibility of instances living in private subnets with no public IP. You can tell the four types apart —ALB for HTTP and HTTPS with content-based routing, NLB for layer 4, microsecond latency and a fixed IP, GWLB for virtual security appliances, and the CLB you only ever inherit— and you can justify why MercadoFresco needs an ALB.

You know its anatomy —load balancer, listeners, rules and target groups— and the detail that the health check lives on the target group, not on the load balancer. And above all you have understood the dangerous part: a health check that queries the database can mark every instance unhealthy at once and return 503 with a perfectly healthy application, or push the ASG into a replacement loop. That is why /salud is local and trivial, /salud/profunda is for monitoring, and the ASG's grace period is 300 seconds.

You have connected asg-mercadofresco-tienda to the tg-mercadofresco-tienda group so that registration and deregistration are automatic, you have changed the ASG's check to ELB so that it detects dead applications and not just dead machines, and you have tuned connection draining to 60 seconds, the length of the longest order. You know how to route by path and by host, combine conditions, use fixed responses to cut off malicious requests at the load balancer and redirect HTTP to HTTPS with a 301 that preserves the path. You have requested a free wildcard certificate from ACM, chosen the TLS 1.2/1.3 policy and you understand what TLS termination is and what SNI is for —knowing that the DNS validation is still pending until the next lesson. You know why sticky sessions are a patch that breaks scaling and where the session should really live. You have enabled access logs to mercadofresco-registros-web and you know what RequestCount, TargetResponseTime and HealthyHostCount mean, and the difference between the target's 5XX and the load balancer's 5XX.

And the arithmetic works out: with four healthy instances spread across two zones, MercadoFresco handles Friday's 900 orders per hour even after losing an entire Availability Zone. Problem 1, the Friday outages, is solved.

That leaves the money. In lesson 02-03 we did a calculation that hurt: 97 % of the S3 cost is outbound transfer, because every catalogue photo is downloaded in full from Ireland each time somebody looks at it, and now on top of that all the dynamic traffic goes through an ALB that charges per GB processed. In lesson 03-04, "Amazon CloudFront", we will put a content delivery network in front of everything: the photos will be served from the edge location nearest the customer, the mercadofresco-catalogo-fotos bucket will stop being directly reachable thanks to Origin Access Control, and we will see in numbers how far the bill drops. The ALB you have just built will still be there behind it, but receiving only what genuinely cannot be cached.

© Copyright 2026. All rights reserved