HAProxy 是我在“把这些 TCP 或 HTTP 后端做负载均衡,并且别让我意外”时会伸手去拿的代理。它不是 nginx 或 Envoy 那种功能全家桶,也无意如此。它是什么:一个很快的 L4/L7 负载均衡器,而按我的经验,它拥有我运维过的所有代理里最好的健康检查故事。本文是一次实战走查——下面每条命令、每一行输出,都来自一个临时拉起的 haproxy:3.0-alpine 容器,挡在两个极小后端前面,用完即拆。如果你想深入 nginx 的机制,我写在 nginx 配置手册;想看反向代理在另一个方向的对比,有 Caddy vs nginx。
$ docker exec hap haproxy -v
HAProxy version 3.0.29-de0ed89 2026/09/28 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2029.
环境:两个极小后端,一个代理
为了让演示落到实处,后端必须诚实作答:能自报身份、能被命令“生病”、能被变慢或噤声。一个极小的 Python origin 三样都能做到,所以我就把它放在 HAProxy 后面。每个响应都带 X-Backend,慢请求会 sleep,/healthz 会一直返回 503 直到我把它“治好”——最后这一点正是让我不杀进程也能演示健康检查的关键。
# server.py —— 临时后端(每个宿主机跑一个)
NAME = os.environ["NAME"]; SLEEP_MS = int(os.environ.get("SLEEP_MS", "400"))
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send(self, code, body=b""):
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Backend", NAME) # 我是谁
self.send_header("Connection", "close") # 每请求一条新连接
self.end_headers(); self.wfile.write(body)
def do_GET(self):
p = self.path.split("?")[0]
if p == "/healthz": self._send(200 if HEALTHY else 503, b"ok\n")
elif p == "/slow": time.sleep(SLEEP_MS/1000); self._send(200, b"slow\n")
elif p == "/hang": time.sleep(15) ; self._send(200, b"hang\n") # > timeout server
elif p == "/down": HEALTHY=False; self._send(200, b"healthz -> 503\n")
elif p == "/up": HEALTHY=True ; self._send(200, b"healthz -> 200\n")
else: self._send(200, ("hello from "+NAME+"\n").encode())
ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
be1 以 SLEEP_MS=4000(/slow 故意慢)运行,be2 以 SLEEP_MS=400 运行——正是这点不对称,让后面的算法对比看得见。在一个用户自定义网络里用静态 IP,免得任何名字要在关键时刻被重新解析:
$ docker network create --subnet 172.31.0.0/24 hbnet
$ docker run -d --name be1 --network hbnet --ip 172.31.0.11 \
-e NAME=be1 -e SLEEP_MS=4000 -v "$PWD/server.py":/srv/server.py:ro \
python:3.12-alpine python3 /srv/server.py
$ docker run -d --name be2 --network hbnet --ip 172.31.0.12 \
-e NAME=be2 -e SLEEP_MS=400 -v "$PWD/server.py":/srv/server.py:ro \
python:3.12-alpine python3 /srv/server.py
HAProxy 配置模型:defaults、frontend、backend、listen
整个 HAProxy 配置就是四种块,而这种“小”正是要点。defaults 放共享项——timeout、日志、mode、retries。frontend 绑定端口并决定流量去向;backend 装着 server 池和均衡策略;listen 则是把两者熔在一起的自包含写法,比如 stats 页面。下面是驱动下文所有演示的完整配置:
global
log stdout format raw local0
stats socket /run/haproxy.sock mode 660 level admin # CLI admin: show stat 等
defaults
mode http
log global
option httplog
timeout connect 2s
timeout client 30s
timeout server 30s
retries 2
option redispatch
listen stats # /stats 页面 + admin socket
bind 0.0.0.0:9000
stats enable
stats uri /stats
stats auth DemoUser:DemoPass
frontend rr_in
bind 0.0.0.0:8080
default_backend be_rr
backend be_rr
balance roundrobin
option httpchk GET /healthz # 主动 L7 检查
http-check expect status 200
timeout server 3s # 故意调小,用来演示 504
server be1 172.31.0.11:80 check fall 3 rise 2 inter 2s
server be2 172.31.0.12:80 check fall 3 rise 2 inter 2s
有两个习惯立刻见效。把 server 指向稳定 IP(或固定 init-addr),后端重启时 HAProxy 就不必在半空中赌一个 DNS 答案;另外,reload 前先校验——haproxy -c -f haproxy.cfg 解析干净时静默返回 0,而一次实时 docker kill -HUP hap 会打印 Loading success. 且连接零中断。
用 roundrobin 做负载均衡:最朴素的基线
balance roundrobin 是默认:发一条连接、移一下指针、再来一遍。两个 origin 都在线时,六个请求干净轮换,每个响应都报出是哪个节点服务的:
$ for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1:8080/; done
hello from be2
hello from be1
hello from be2
hello from be1
hello from be2
hello from be1
轮询均衡的是连接数,不是负载。它根本不知道一个请求会花 4 ms 还是 4 s。这一句话,就解释了其他那些算法为什么存在。
健康检查故事:看一个后端死掉又复活
这就是我一直回来的原因。每条 server 行都有 check ... inter 2s——那是从 HAProxy 侧每 2 秒发出的主动检查。因为我用了 option httpchk GET /healthz,它是一个真正的 HTTP 探测,必须返回 200;而下面那个 mode tcp 池没配,于是它只检查 TCP 能连上。现在我停掉 be2,随即打八个请求:
$ docker stop be2 && for i in $(seq 8); do curl -s -o /dev/null -w '%{http_code} ' http://127.0.0.1:8080/fast; done; echo
200 200 200 200 200 200 200 200
$ for i in $(seq 8); do curl -s http://127.0.0.1:8080/fast; done | sort | uniq -c
8 hello from be1
没有一个请求失败,且全部落在 be1。在检查尚未失败的这几秒里,be2 仍是候选:轮询指向它,连接被拒,retries 2 + option redispatch 把请求悄悄重发到 be1。等三次检查都 miss(fall 3),它才被彻底踢出。这里真正超时的正是连接本身——注意代价恰好是我们设的 timeout connect 2s:
$ printf 'show stat' | socat - UNIX-CONNECT:/run/haproxy.sock # 踢出后的 be2
pxname srv status check_st code desc
be_rr be1 UP L7OK 200 Layer7 check passed
be_rr be2 DOWN L4TOUT Layer4 timeout
$ docker logs hap | grep -i down
Server be_rr/be2 is DOWN, reason: Layer4 timeout, check duration: 2001ms. 1 active and 0 backup servers left.
2001ms 就是 timeout connect 2s 在起作用:HAProxy 尝试向一个死 IP 开 TCP 连接,两秒后放弃,把节点标 down。再把它拉起来,检查一通过就立刻被迎回:
$ docker start be2 # (它会固定回到 172.31.0.12)
Server be_rr/be2 is UP, reason: Layer7 check passed, code: 200. 2 active and 0 backup servers online.
fall 3),节点退出轮换,负载均衡器无需人工继续对外服务。这件事在运维上的意义:它正是零停机部署得以成立的原因。把一个节点下线、让检查注意到、重启它、让检查重新接纳——池子扛得住,因为追踪状态的是负载均衡器,而不是人。
roundrobin vs leastconn:算法真正起作用的场合
如果轮询无视负载,那谁来补?balance leastconn 选活跃会话数最少的节点。只有在负载不均时差别才显形,所以最干净的测法是:用一条长 /slow 把某个节点占住,再打十个瞬时 /fast。每次跑之前我都用 show stat 核对了线上的算法(be_lc 在 leastconn 与 roundrobin 之间做完整 reload 切换)。
# leastconn,实测线上算法 = leastconn
/slow 占用 be1 期间 /fast x10 -> 10 be2 # 全部给空闲的那个
# roundrobin,实测线上算法 = roundrobin
/slow 占用 be1 期间 /fast x10 -> 5 be1 5 be2 # 照样转给忙的那个
整个道理就这样复现了。当 be1 被一个 4 s 请求缠住时,leastconn 把每条新连接都发给 be2;而轮询不管三七二十一,仍把一半发回 be1。当后端相同且都快时,两者无法区分——我不会睁眼说瞎话——可一旦请求耗时各异,leastconn 能把长尾延迟压住。(第三种常见选择 balance source 把客户端哈希到固定后端,用于会话粘性。)
Timeout 与 retry:把故障影响控制在最小
HAProxy 不会让一个慢后端把客户端当人质。上面 defaults 设了 timeout connect 2s、timeout client 30s、timeout server 30s、retries 2、option redispatch。be_rr 池把 timeout server 压到 3s,于是 /hang——它在上游睡 15 s——触发了它:
$ curl -s -o /dev/null -w 'HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:8080/hang
HTTP 504 in 3.002041s
$ docker logs hap | tail -1
172.31.0.1:41316 [...] rr_in be_rr/be2 0/0/0/-1/3001 504 198 - - sH-- 1/1/0/0/0 0/0 "GET /hang HTTP/1.1"
3.002s 处返回 504,日志也印证:那个 3001 是 server 时间撞上 timeout server 3s 的墙,sH-- 表示后端卡住。timeout connect 管 TCP 握手(之前那个 2001ms 正是它),timeout server 管后端响应,timeout client 管那些不想让它霸占槽位的慢客户端。按你真实的后端画像调这三个值,就是“一个端点慢”和“铺天盖地 504”的分界。
Stats 页面:一个 URL,全部真相
单个 listen stats 同时给你一个网页和 CLI admin socket——上面所有状态都是从这里取的。网页:
$ curl -s -u DemoUser:DemoPass http://127.0.0.1:9000/stats | grep -i -m1 title
<html><head><title>Statistics Report for HAProxy on <worker-id></title>
以及 socket,整理成我关心的几列:
$ printf 'show stat' | socat - UNIX-CONNECT:/run/haproxy.sock
pxname srv status check_st code desc
be_rr be1 UP L7OK 200 Layer7 check passed
be_rr be2 UP L7OK 200 Layer7 check passed
be_lc be1 UP L7OK 200 Layer7 check passed
be_lc be2 UP L7OK 200 Layer7 check passed
be_tcp be1 UP L4OK Layer4 check passed
be_tcp be2 UP L4OK Layer4 check passed
show stat、show info、show servers state 把“为什么慢”这种谜团变成两次 grep。把 /stats;csv 抓进 Prometheus,同一个 socket 还能 set server be_rr/be2 state maint,主动把某台机器排水下线。
mode http 与 mode tcp:HAProxy 能看什么、不能看什么
mode http 会解析请求:它能匹配路径和头部(ACL)、注入 X-Forwarded-For、跑真正读到 200 的 L7 健康检查。mode tcp 只转发字节,这些一概不知——而这正是 MySQL、Redis 或 websocket 隧道想要的。两种健康检查的对比,是本节最有用的东西。HTTP 通过 tcp 隧道照常工作(我的后端对此毫无察觉):
$ curl -s -D - http://127.0.0.1:9443/fast | grep -iE '^HTTP|^X-Backend' # :9443 是 mode tcp
HTTP/1.1 200 OK
X-Backend: be1
现在让 be1 的应用报告不健康——/healthz 开始返回 503——看指向同一个 origin 的两个后端如何分歧:
be_rr be1 DOWN L7STS 503 Layer7 wrong status
be_lc be1 DOWN L7STS 503 Layer7 wrong status
be_tcp be1 UP L4OK Layer4 check passed # 对 503 视而不见
日志说得很直白:Server be_rr/be1 is DOWN, reason: Layer7 wrong status, code: 503, info: "Service Unavailable"。mode tcp 检查只确认端口能开,于是它报 L4OK,继续把流量导向一个已经着火的应用。应用层的健康重要就选 mode http;只有连接本身算数就选 mode tcp——若真选了 mode tcp,不妨考虑 option tcp-check,至少能说上一小段协议握手。
ACL 是最后一个值得知道的 L7 工具。几行代码就能按内容而非按端口路由:
frontend web
bind :80
acl is_api path_beg /api
acl is_static path_end .css .js .png
use_backend api if is_api
use_backend assets if is_static
default_backend web
HAProxy vs nginx vs Envoy:一个公允的比较
三个我都在用,而诚实的划分是按形态、不是按跑分。nginx 是全能 Web 服务器兼反向代理——生态巨大、文档天下第一;它的手册深不见底。Envoy 是动态、可编程的 L7 代理:xDS 驱动、服务网格原生,重试、超时、故障注入、丰富可观测性一应俱全。HAProxy 的赛道,是那最快、最无趣的 L4/L7 负载均衡器:基于连接的算法、一流的主动健康检查、以及一个逐行行为都可预测的单体二进制。要终止带缓存控制的普通 HTTP,我选 nginx;要能自我改配置的网格,选 Envoy;要在数据库、Redis、或一群必须在数秒内被踢出替换的应用 worker 前面,我始终选 HAProxy。
结论
HAProxy “从不让你意外”,靠的是好工具那种朴素:四块组成的 HAProxy 配置、会就“服务器真实状态”做判断的健康检查、以及遏制而非放大故障的timeout。这就是我愿意交给生产、好让我睡觉的负载均衡。轮询、leastconn、mode tcp、retries、admin socket——没有一样是为炫技而存在,而如上所示,它们的表现都与你写下的文件一字不差。