Chapter 57 — Production load balancing: L4 vs L7, health checks, draining, retries
In one sentence
A load balancer is both a traffic director and a failure amplifier. Good health checks, draining, and retry policy make a backend failure invisible. Bad ones turn one slow backend into a global outage.
The problem
Chapter 36 explained what a load balancer is. Running one raises harder questions. How does it decide a backend is dead, and how fast? What happens to in-flight requests during a deploy? Who retries a failed request, and how many times? And how does the backend find out who the client really was?
L4 vs L7
| Type | Sees | Connection handling | Typical use |
|---|---|---|---|
| L4 | IPs, ports, TCP/UDP | Forwards or splices the client's connection; balances per connection | raw TCP, TLS passthrough, very high throughput, non-HTTP protocols |
| L7 | HTTP method/path/headers/status | Terminates the client connection, opens its own to backends; balances per request | routing by hostname/path, auth, compression, HTTP retries, canaries |
The per-connection vs per-request difference is critical for long-lived connections. HTTP/2 and gRPC clients send all their requests over one connection. Behind an L4 balancer, that client is pinned to one backend forever: new pods get no traffic and hot pods melt. gRPC needs an L7 balancer, a service mesh, or client-side balancing (OSI guide, Part 15).
How it actually works: choosing a backend
| Algorithm | How it picks | Good for | Weakness |
|---|---|---|---|
| Round robin | Next in the list | Identical backends, uniform requests | Ignores load; a slow backend gets the same share |
| Least connections / least outstanding requests | Fewest in-flight | Variable request cost | Needs shared state across LB instances |
| Power of two choices | Pick 2 at random, take the less loaded | Large fleets, many LB instances | Slightly less even than a global view |
| Consistent hashing (ring hash, Maglev) | Hash of a key (client IP, user ID) → backend | Cache affinity, sticky sessions | Hot keys overload one backend |
"Power of two choices" is the quiet favourite of modern proxies (Envoy,
NGINX's random two). Picking two at random and taking the better one gives
almost the balance of a global "least loaded" decision without any
coordination between load balancer instances.
Health checks must test what matters
Bad:
GET / -> 200
Better:
GET /readyz -> 200 only if this instance can serve real traffic right now
- Readiness should fail when the instance should be removed from rotation: starting up, draining, or unable to reach a dependency it can't work without.
- Liveness should fail only when the process should be restarted: deadlocked or corrupted. Never make liveness depend on a database. Otherwise a database outage restarts every pod at once, and they all stampede the database when it comes back.
- Hysteresis: eject after N consecutive failures and restore after M successes, so one slow probe doesn't flap a backend in and out.
- Passive health checks (outlier detection): also eject backends based on real traffic, such as 5 consecutive 5xx errors. Active probes run every few seconds, while real traffic notices failures in milliseconds.
Draining
Before shutting down a backend:
- mark it not-ready (fail
/readyz); - wait for the load balancer to stop sending new traffic (at least failure threshold × check interval);
- let in-flight requests finish, up to a deadline;
- exit.
Skipping draining creates random user-visible errors on every deploy.
Build it in Go (45 min) — a draining backend behind a health-checking LB
This lab has two roles in one binary: a backend that implements the four
draining steps, and a round-robin L7 load balancer that health-checks
/readyz with hysteresis. You can kill a backend mid-traffic and count the
errors.
// lbdrain: Chapter 57 in one program -- backends with /readyz and graceful
// draining, and a round-robin load balancer with active health checks.
// Kill a backend gracefully mid-traffic and count the errors (there should be 0).
//
// go build -o lbdrain ./lbdrain
// ./lbdrain backend 127.0.0.1:9081 &
// ./lbdrain backend 127.0.0.1:9082 &
// ./lbdrain lb 127.0.0.1:9080 http://127.0.0.1:9081 http://127.0.0.1:9082 &
// for i in $(seq 1 2000); do curl -s -o /dev/null -w '%{http_code}\n' localhost:9080/; done | sort | uniq -c &
// kill -TERM %1 # graceful: readiness fails first, then in-flight requests finish
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("usage: lbdrain backend ADDR | lbdrain lb ADDR BACKEND_URL...")
os.Exit(2)
}
switch os.Args[1] {
case "backend":
backend(os.Args[2])
case "lb":
loadBalancer(os.Args[2], os.Args[3:])
}
}
// ---------------------------------------------------------------- backend --
func backend(addr string) {
var draining atomic.Bool
mux := http.NewServeMux()
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
if draining.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable) // "stop sending me new work"
return
}
w.Write([]byte("ok"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(50 * time.Millisecond) // pretend to work
fmt.Fprintf(w, "hello from %s\n", addr)
})
srv := &http.Server{Addr: addr, Handler: mux,
ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
log.Printf("backend %s up (pid %d)", addr, os.Getpid())
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
<-ctx.Done()
// The drain sequence from Chapter 57:
log.Printf("backend %s: SIGTERM -> failing readiness", addr)
draining.Store(true) // 1. mark not-ready
time.Sleep(3 * time.Second) // 2. wait for the LB's health checks to notice
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // 3. stop accepting, finish in-flight
log.Printf("forced close: %v", err)
}
log.Printf("backend %s: drained, exiting", addr) // 4. exit
}
// ----------------------------------------------------------- load balancer --
type upstream struct {
url *url.URL
proxy *httputil.ReverseProxy
healthy atomic.Bool
}
func loadBalancer(addr string, targets []string) {
var ups []*upstream
for _, t := range targets {
u, err := url.Parse(t)
if err != nil {
log.Fatal(err)
}
up := &upstream{url: u, proxy: httputil.NewSingleHostReverseProxy(u)}
up.healthy.Store(true)
ups = append(ups, up)
go healthCheck(up)
}
var next atomic.Uint64
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Round-robin over HEALTHY upstreams only.
for range ups {
up := ups[next.Add(1)%uint64(len(ups))]
if up.healthy.Load() {
up.proxy.ServeHTTP(w, r)
return
}
}
http.Error(w, "no healthy upstream", http.StatusServiceUnavailable)
})
log.Printf("lb on %s -> %v", addr, targets)
log.Fatal(http.ListenAndServe(addr, handler))
}
// healthCheck polls /readyz. Two failures to eject, two successes to restore:
// hysteresis stops one slow probe from flapping a backend in and out.
func healthCheck(up *upstream) {
client := &http.Client{Timeout: time.Second}
fails, oks := 0, 0
for range time.Tick(time.Second) {
resp, err := client.Get(up.url.String() + "/readyz")
ok := err == nil && resp.StatusCode == http.StatusOK
if resp != nil {
resp.Body.Close()
}
if ok {
oks, fails = oks+1, 0
} else {
fails, oks = fails+1, 0
}
switch {
case fails == 2 && up.healthy.Load():
up.healthy.Store(false)
log.Printf("lb: EJECT %s", up.url)
case oks == 2 && !up.healthy.Load():
up.healthy.Store(true)
log.Printf("lb: RESTORE %s", up.url)
}
}
}
Build it once, start two backends and the load balancer, send traffic, and
kill one backend gracefully while the traffic is flowing. (Build a binary
rather than using go run, so the signal goes to the program itself and not
to the go command.)
$ go build -o lbdrain ./lbdrain
$ ./lbdrain backend 127.0.0.1:9081 & # job %1
$ ./lbdrain backend 127.0.0.1:9082 &
$ ./lbdrain lb 127.0.0.1:9080 http://127.0.0.1:9081 http://127.0.0.1:9082 &
$ (for i in $(seq 1 300); do curl -s -o /dev/null -w '%{http_code}\n' localhost:9080/; done | sort | uniq -c) &
$ kill -TERM %1
backend 127.0.0.1:9081: SIGTERM -> failing readiness
lb: EJECT http://127.0.0.1:9081
backend 127.0.0.1:9081: drained, exiting
300 200 <- zero errors
Now do the same with kill -9 (no draining):
276 200
24 502 <- every in-flight or not-yet-ejected request fails
What to notice:
- The 3-second sleep between "not ready" and
Shutdownis the most important line. Without it, the backend stops accepting connections while the LB still thinks it's healthy. Kubernetes has exactly this race: pod removal from Service endpoints is asynchronous with SIGTERM, which is why production pods use apreStopsleep (Linux guide, Chapter 60). srv.Shutdown(ctx)closes the listener, closes idle keep-alive connections, and waits for active requests to finish, up to the context deadline.- The 24 errors under
kill -9were 502s fromhttputil.ReverseProxy, which couldn't reach the dead backend until the health check ejected it about 2 seconds later. Passive outlier detection would have cut that window to one failed request.
Who is the client? X-Forwarded-For and the PROXY protocol
Behind any proxy, the backend's TCP peer is the proxy. An L7 proxy can add
a header (X-Forwarded-For: 203.0.113.7). An L4 proxy can't change HTTP,
because it may not even be HTTP or may be encrypted. Instead it uses the
PROXY protocol: one line of text, sent before the client's bytes.
// proxyproto: recover the real client address behind an L4 load balancer.
//
// An L4 proxy (HAProxy, AWS NLB with proxy protocol enabled, Envoy) opens its
// OWN connection to your backend, so RemoteAddr() is the proxy, not the
// client. With the PROXY protocol, the proxy writes one header line first:
//
// PROXY TCP4 203.0.113.7 10.0.1.15 61001 443\r\n
//
// go run ./proxyproto # runs a server and a fake proxy against it
package main
import (
"bufio"
"errors"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
)
// readProxyV1 parses a PROXY protocol v1 header. Only call it on listeners
// that ONLY your load balancer can reach: otherwise any client can claim to
// be any IP address.
func readProxyV1(r *bufio.Reader) (src, dst netip.AddrPort, err error) {
line, err := r.ReadString('\n')
if err != nil {
return src, dst, err
}
if len(line) > 107 || !strings.HasSuffix(line, "\r\n") { // 107 = spec maximum
return src, dst, errors.New("malformed PROXY header")
}
f := strings.Fields(strings.TrimSuffix(line, "\r\n"))
if len(f) < 2 || f[0] != "PROXY" {
return src, dst, errors.New("missing PROXY header")
}
if f[1] == "UNKNOWN" { // e.g. the LB's own health checks
return src, dst, nil
}
if len(f) != 6 || (f[1] != "TCP4" && f[1] != "TCP6") {
return src, dst, fmt.Errorf("unsupported PROXY header %q", line)
}
sip, err1 := netip.ParseAddr(f[2])
dip, err2 := netip.ParseAddr(f[3])
sp, err3 := strconv.ParseUint(f[4], 10, 16)
dp, err4 := strconv.ParseUint(f[5], 10, 16)
if err := errors.Join(err1, err2, err3, err4); err != nil {
return src, dst, fmt.Errorf("bad PROXY header: %w", err)
}
return netip.AddrPortFrom(sip, uint16(sp)), netip.AddrPortFrom(dip, uint16(dp)), nil
}
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 2; i++ {
c, err := ln.Accept()
if err != nil {
return
}
r := bufio.NewReader(c) // keep using r: it may have buffered request bytes
src, _, err := readProxyV1(r)
if err != nil {
fmt.Println("rejected:", err)
c.Close()
continue
}
req, _ := r.ReadString('\n')
fmt.Printf("TCP peer (the proxy): %s\nreal client: %s\nrequest: %q\n",
c.RemoteAddr(), src, req)
c.Close()
}
}()
// Pretend to be the load balancer: header first, then the client's bytes.
c, _ := net.Dial("tcp", ln.Addr().String())
fmt.Fprint(c, "PROXY TCP4 203.0.113.7 10.0.1.15 61001 443\r\nGET / HTTP/1.1\r\n")
c.Close()
// And a client that skipped the proxy and talks to the backend directly.
c, _ = net.Dial("tcp", ln.Addr().String())
fmt.Fprint(c, "GET / HTTP/1.1\r\n")
c.Close()
<-done
}
$ go run ./proxyproto
TCP peer (the proxy): 127.0.0.1:52937
real client: 203.0.113.7:61001
request: "GET / HTTP/1.1\r\n"
rejected: missing PROXY header
Both mechanisms are trivially forgeable by anyone who can reach the
backend directly. Accept PROXY headers or X-Forwarded-For only from your
load balancer's addresses, and for X-Forwarded-For take the address added
by your proxy (the right-most trusted entry), never the left-most, which
the client wrote.
Retry trap
Retries are useful only when:
- the operation is idempotent or has an idempotency key;
- the retry count is bounded, and only one layer retries (client, LB, and service mesh each retrying 3 times means 27 attempts);
- backoff with jitter exists;
- a retry budget caps retries at a fraction of normal traffic (say 10%), so retries can't multiply load during an outage;
- the total deadline still matters: a retry that can't finish in time is pure waste.
Blind retries turn "one slow backend" into "three times the traffic during an outage."
Timeouts must nest
Every hop needs a timeout, and they must fit inside each other:
client 10s > CDN 9s > LB idle 60s / request 8s > app handler 7s > DB query 5s
And for idle keep-alive connections, the rule is reversed: the backend must keep idle connections open longer than the load balancer (e.g. app 65 s > LB 60 s), so the LB always closes first. Otherwise the LB occasionally sends a request on a connection the backend is closing, and the user gets a 502 that appears in no application log. The OSI guide's Layer 5 war story walks through this exact incident.
Common confusions
- "The load balancer is highly available, so we're fine." The LB is often the most reliable part. Bad health checks and missing draining cause most LB-related incidents.
- "Sticky sessions are a feature." They're a workaround for stateful servers, and they break even distribution and deploys. Move the state out (OSI guide, Part 7).
- "502 and 504 mean the app crashed." 502: the proxy got a bad or no response (connection refused or reset, crash, keep-alive race). 504: the backend didn't answer in time. Check the LB log against the app log: if the app never logged the request, the problem is between them.
Check yourself
- Why does gRPC behind an L4 load balancer overload some backends?
- What should a readiness check test, and what must a liveness check not test?
- Write the four draining steps, and say what goes wrong if you skip step 2.
- Why must the backend's keep-alive timeout be longer than the LB's?
- How should a backend decide whether to trust
X-Forwarded-For?
Further reading
- Article: "Load Balancing" chapter of Google's Site Reliability Engineering book (free online), covering subsetting, weighted round robin, and the "lame duck" state (draining).
- Paper: "Maglev: A Fast and Reliable Software Network Load Balancer" (Google, NSDI 2016).
- Go plan: build the full versions: four LB algorithms behind one interface (Day 45), active health checking (Day 46), connection draining (Day 47), and a complete L4 load balancer (Day 48).
- Across the series: the HTTPS guide's load balancer chapter covers rate limiting and global load balancing.