Load Balancer + Rate Limiter Lab: HAProxy Stick-Tables for Connection Rate Limiting
Why Rate-Limit at the Load Balancer
Application-level rate limiting catches abuse after the request has already consumed backend resources -- TCP connection, TLS handshake, request parsing. HAProxy stick-tables let you shed load at the edge before any of that happens.
The Docker Compose Lab
# docker-compose.yml
version: '3.8'
services:
haproxy:
image: haproxy:2.9
ports:
- "80:80"
- "8404:8404" # stats
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
- backend1
- backend2
- backend3
backend1:
build: ./backend
environment:
- BACKEND_ID=1
backend2:
build: ./backend
environment:
- BACKEND_ID=2
backend3:
build: ./backend
environment:
- BACKEND_ID=3
The backend is a minimal Go HTTP server:
// backend/main.go
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
id := os.Getenv("BACKEND_ID")
http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
// Simulate 20ms of work
time.Sleep(20 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"backend":"%s","status":"ok"}`, id)
})
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, "ok")
})
http.ListenAndServe(":8080", nil)
}
HAProxy Config with Stick-Table Rate Limiting
# haproxy.cfg
global
log stdout format raw local0
defaults
mode http
log global
timeout connect 5s
timeout client 30s
timeout server 30s
option httplog
frontend http_front
bind *:80
# Stick-table: track request rate per source IP
# 100k entries, 30s TTL, track HTTP request rate over 10s window
stick-table type ip size 100k expire 30s store http_req_rate(10s)
# Track the client
http-request track-sc0 src
# Deny if more than 50 requests in 10s (5 req/s sustained)
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 50 }
# Add rate limit headers for observability
http-response set-header X-RateLimit-Remaining %[sc0_http_req_rate(10s),sub(50)] if { sc_http_req_rate(0) gt 0 }
default_backend app_servers
backend app_servers
balance leastconn
option httpchk GET /healthz
http-check expect status 200
server backend1 backend1:8080 check inter 3s fall 3 rise 2
server backend2 backend2:8080 check inter 3s fall 3 rise 2
server backend3 backend3:8080 check inter 3s fall 3 rise 2
frontend stats
bind *:8404
stats enable
stats uri /stats
stats refresh 5s
The key section is the stick-table. type ip size 100k creates an in-memory table keyed by source IP. store http_req_rate(10s) tracks a sliding window of request rates. The http-request deny line checks if the rate exceeds 50 requests per 10 seconds and returns 429 before the request ever reaches a backend.
Benchmarking with wrk
Start the lab and run wrk at increasing concurrency:
docker compose up -d
# Baseline: 10 concurrent connections
wrk -t4 -c10 -d30s http://localhost/api/data
# Expected: all 200s, ~20ms avg latency
# Over limit: 100 concurrent connections
wrk -t4 -c100 -d30s http://localhost/api/data
# Expected: mix of 200s and 429s
# Heavy load: 500 concurrent connections
wrk -t4 -c500 -d30s -L http://localhost/api/data
# Expected: mostly 429s, but p99 latency stays low
# because HAProxy rejects before forwarding
The -L flag prints the full latency distribution. Compare p50, p99, and p999 across the three runs.
Without stick-table rate limiting, the 500-connection test would show p99 latency climbing as backends queue requests. With it, p99 stays flat because excess requests never reach backends.
Checking the Stick-Table Live
HAProxy exposes stick-table contents via the stats socket or the stats page at :8404/stats. You can also query it from the command line:
# Show all tracked IPs and their request rates
docker compose exec haproxy \
socat stdio /var/run/haproxy/admin.sock <<< "show table http_front"
This outputs each tracked IP with its current request rate, which is useful for debugging why a specific client is getting denied.
Key Differences from Application-Level Rate Limiting
- No backend resources consumed: HAProxy denies at the connection level before proxying
- Shared state by default: one HAProxy instance sees all traffic, no Redis needed for single-LB deployments
- No Retry-After header: stick-tables do not natively calculate when the window resets, so clients get a bare 429. Add application-level rate limiting behind this for richer error responses.
- Coarser granularity: stick-tables work on IP, not on user identity or API key. Use them as the first layer, with app-level per-user limits behind them.
This two-tier approach matches what Day 19 recommended: coarse limits at the edge, fine-grained limits in the application.
Comments
No comments yet. Start the discussion.