Lab 14: The Health Check That Sent Binary
The target is running and curling it directly returns the right response. The target group says unhealthy, so the load balancer has nothing to route to. The security group permits the load balancer, the port is right, and the health check path exists.
- Debugging time
- ~25 min
- Reading time
- 10 min
- Reported by
- API Platform
- Tier
- Professional
All targets unhealthy behind the internal NLB after enabling client IP visibility
Reported by API Platform
Every target behind the internal NLB went unhealthy after a change last night. Clients get nothing.
The application is running. I can curl the target's private address on 8080 from another host in the
VPC and it returns the expected body. The security group allows 8080 from the whole VPC CIDR, so it
covers the load balancer's addresses. The health check path is /, which exists.
The change was enabling proxy protocol on the target group so the application could log real client IPs. That should not affect health checks — health checks come from the load balancer, not from clients.
Target health reason is Target.FailedHealthChecks. That is all we get.
What you are working with
One VPC, one Availability Zone, one internal Network Load Balancer, one target.
| Resource | Configuration |
| --- | --- |
| NLB | Internal, TCP listener on 80 |
| Target group | TCP, port 8080, target type instance |
| Target group attribute | proxy_protocol_v2.enabled = true |
| Health check | HTTP on /, port traffic-port, matcher 200 |
| Target | python3 -m http.server 8080, serving index.html |
| sg-target | Inbound 8080 from 10.120.0.0/16 |
The health check is HTTP while the listener is TCP. That combination is normal and supported, and it is also the reason this failure is as sharp as it is.
- EC2
NLB node
10.120.1.x — opens TCP to the target on 8080
- FILTER
sg-target
inbound 8080 from the VPC CIDR — permitted
- VPCE
Proxy protocol v2 header
12-byte binary signature, then the header block
- DEST
python http.server
cannot parse a request line, answers 400
Dropped — The health check expects 200. A 400 is a valid HTTP response and a failed check, so the target is marked unhealthy while being entirely healthy.
Nothing is dropped at the network layer. The connection succeeds, the exchange completes, and the answer is the wrong number.
Scope and constraints
- In scope: why the health check fails when a direct request succeeds.
- Out of scope: the security group, the subnet, the listener, the health check path, and the application. All correct.
- The target really is healthy. The reporter is right about that.
- This is not Lab 06. Client IP preservation is a different attribute with a different failure. Here the connection reaches the target and the target answers.
Deploy the broken state
cd lab-14-nlb-proxy-protocol
terraform init
terraform apply
aws ssm start-session --target "$(terraform output -raw client_instance_id)"The load balancer takes a few minutes to provision. Health checks begin once it is active.
TG=$(terraform output -raw target_group_arn)
TARGET_IP=$(terraform output -raw target_private_ip)
NLB=$(terraform output -raw nlb_dns_name)Confirm the symptom
aws elbv2 describe-target-health --region us-east-1 --target-group-arn "$TG" \
--query 'TargetHealthDescriptions[].[Target.Id,TargetHealth.State,TargetHealth.Reason]' \
--output table
# ------------------------------------------------------------------------
# | i-0aa11bb22cc33dd44 | unhealthy | Target.FailedHealthChecks |
# ------------------------------------------------------------------------And from the client host, the load balancer has nothing to give:
curl -s -m 10 -o /dev/null -w '%{http_code}\n' "http://$NLB/"
# 000Confirm the target is healthy
curl -s -m 5 "http://$TARGET_IP:8080/"
# lab-14-application-okTwo hundred, correct body, from inside the VPC, through the same security group the load balancer uses. The application is fine and reachable.
Reproduce the health check exactly
The health check is an HTTP GET / on the traffic port. Do that by hand:
curl -s -o /dev/null -w '%{http_code}\n' "http://$TARGET_IP:8080/"
# 200Still 200. So a manual HTTP request succeeds and the load balancer's HTTP request fails. The two requests must differ, and the only thing that differs is what the load balancer puts on the wire before the request.
Read the application's log
This is where the answer is, and it is unambiguous. Open a session on the target:
aws ssm start-session --target "$(terraform output -raw target_instance_id)"
sudo journalctl -u lab-app -n 12 --no-pager10.120.1.204 - - [23/Sep/2026 15:52:41] code 400, message Bad request version ('\x00\r\nQUIT\n\x21\x11\x00\x0c')
10.120.1.204 - - [23/Sep/2026 15:52:41] "\r\n\r\n\x00\r\nQUIT\n\x21\x11\x00\x0c..." 400 -
10.120.1.204 - - [23/Sep/2026 15:52:51] code 400, message Bad request version ('\x00\r\nQUIT\n\x21\x11\x00\x0c')The server is receiving binary and reporting 400 Bad request version. That string —
\r\n\r\n\x00\r\nQUIT\n — is the proxy protocol v2 signature. It is twelve bytes that exist
specifically so a receiver can recognise the header, and to a server that does not look for it they are
the first twelve bytes of a request line.
Note the source: 10.120.1.204 is the load balancer's private address, and every one of these is ten
seconds apart, matching the health check interval.
Watch it on the wire
If you want to see the bytes rather than the log's escaping:
sudo timeout 15 tcpdump -ni any -A "tcp port 8080 and tcp[tcpflags] & tcp-push != 0" -c 215:53:11.882 IP 10.120.1.204.28104 > 10.120.1.99.8080: Flags [P.]
....
QUIT
!.....
....GET / HTTP/1.1
Host: 10.120.1.99:8080The GET is there. It is preceded by the header block, so it is not the first thing on the stream.
Confirm the attribute
aws elbv2 describe-target-group-attributes --region us-east-1 --target-group-arn "$TG" \
--query "Attributes[?Key=='proxy_protocol_v2.enabled']" --output table
# ------------------------------------------------
# | proxy_protocol_v2.enabled | true |
# ------------------------------------------------This debrief is part of Labs Pro
The root-cause analysis, packet-flow walkthrough, and Terraform remediation diff for this lab are available to Labs Pro members. One payment of $49, no subscription, and it covers every Pro lab now and later.
The brief, the reproduction steps, and the Terraform stay free — you can still solve this one yourself.
Already bought it? Sign in and it unlocks.