负载均衡与健康检查
单机部署时 Nginx 只是一个代理;一旦同一份应用起了两个以上实例,upstream 就从「写个地址」变成「流量调度器」——算法决定请求怎么分、健康检查决定坏实例多久被摘掉、权重决定灰度放多少量。这三件事配错,扩容反而会让可用性下降。本章讲清楚 upstream 的写法、健康检查的真相,以及多实例环境下的平滑发布流程。
要解决的问题
- 两个实例怎么分流?机器配置不同怎么办?
- 实例挂了,Nginx 多久能发现并停止转发?
- 开源版 Nginx 到底有没有「主动健康检查」?
- 长连接(
keepalive)为什么配了不生效? - 新版本要放量,怎么按比例灰度而不是全量切换?
- 发布时怎么做到用户无感(摘一台发一台)?
upstream 基本写法
# /etc/nginx/conf.d/upstream.conf
upstream app_cluster {
least_conn; # 负载算法,也可换成 ip_hash 或直接用默认轮询
server 10.0.0.11:8080 weight=3 max_fails=2 fail_timeout=10s;
server 10.0.0.12:8080 weight=1 max_fails=2 fail_timeout=10s;
server 10.0.0.13:8080 backup; # 备用,前两台全挂才启用
server 10.0.0.14:8080 down; # 手动下线:维护、发版、压测
keepalive 32; # 每 worker 与上游保持的空闲长连接数
}
upstream 只写一次,放在 http 块里(用 include 引进来),这样多个 server 可以共用,改一处就全站生效。
| 算法 | 写法 | 适用场景 | 注意 |
|---|---|---|---|
| 轮询(默认) | 不写 | 实例同构、请求耗时接近、无状态 | 机器配置不同会造成慢实例堆积 |
| 加权轮询 | weight=3 | 机器配置不同 | 权重按 CPU/内存比例给,别凭感觉 |
| 最少连接 | least_conn | 请求耗时差异大(导出、报表) | 与 weight 可组合 |
| IP 哈希 | ip_hash | 会话粘滞,且没有共享 session | 同一出口 NAT 的整批用户会压到一台 |
| 一致性哈希 | hash $request_uri consistent | 上游有本地缓存,追求命中率 | 加实例时数据迁移最少 |
| 随机二选一 | random two least_conn | 实例特别多(几十台以上) | 避免了轮询的同步开销 |
选择原则:能用无状态共享 session 就别用 ip_hash。粘滞会带来两个后果——某台机器故障时大量用户会话丢失、扩缩容时负载严重不均。
健康检查:被动与主动
| 类型 | 实现 | 判定依据 | 能力边界 |
|---|---|---|---|
| 被动检查 | 开源版内置,max_fails + fail_timeout | 真实业务请求返回错误/超时 | 只能靠用户请求「试」出故障,有损 |
| 主动检查 | nginx_upstream_check_module(Tengine/三方模块)或 Nginx Plus | 周期性发探测请求 | 能提前摘除,但需额外编译模块 |
被动检查的实际行为:fail_timeout=10s 内失败达到 max_fails=2 次,就把这台标记为不可用,并在接下来的 fail_timeout 时间内不再转发;时间到了会放一个请求进去试探,成功则恢复。默认值是 max_fails=1 fail_timeout=10s。
# 主动健康检查:需要三方模块(nginx_upstream_check_module),开源版默认没有
upstream app_cluster {
server 10.0.0.11:8080;
server 10.0.0.12:8080;
check interval=3000 rise=2 fall=3 timeout=1000 type=http;
check_http_send "HEAD /healthz HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx http_3xx;
}
没装模块时不要照抄这段,nginx -t 会直接报 unknown directive "check"。多数中小项目的正确做法是:开源版被动检查 + 一个真实检查依赖的 /healthz,配合外部拨测兜底即可。
keepalive 的三个前提
keepalive 32 是 Nginx 与上游之间的连接复用,能显著降低 TIME_WAIT 和握手开销,但必须同时满足三个条件,缺一个都白配:
| 条件 | 写法 | 缺失后果 |
|---|---|---|
| 用 HTTP/1.1 | proxy_http_version 1.1; | HTTP/1.0 默认短连接 |
| 清空 Connection | proxy_set_header Connection ""; | 默认传 Connection: close,连接用完就关 |
| 上游支持 keepalive | 应用服务器开启长连接空闲超时 | 上游先关连接,Nginx 复用时收到 RST |
另外 keepalive 的值是「每个 worker 保留的空闲连接数」,不是总连接数上限。32 是常见起点,实例多、QPS 高时可以按「峰值 QPS ÷ worker 数 ÷ 单连接承载」估算。
平滑发布:摘一台,发一台
# 前提:upstream 单独放在 /etc/nginx/conf.d/upstream.conf 里便于脚本改
# 1) 把 11 号机标记为 down,reload 后新请求不再进它
sudo sed -i 's/server 10.0.0.11:8080 /server 10.0.0.11:8080 down /' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo systemctl reload nginx
# 2) 在 11 号机上发布新版本,并本机自测
ssh 10.0.0.11 'cd /srv/app && git fetch --tags && git checkout v1.4.0 && sudo systemctl restart myapp'
ssh 10.0.0.11 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/healthz'
# 3) 观察这台机器的错误日志与指标,确认没问题再放回流量
sudo sed -i 's/server 10.0.0.11:8080 down /server 10.0.0.11:8080 /' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo systemctl reload nginx
# 4) 对 12 号机重复同样流程
摘流量要在 reload 后稍等几秒再发版,让已建立的连接自然结束;直接 restart 应用而不摘流量,正在处理请求的用户就会收到 502。
灰度按权重放量
upstream app_cluster {
server 10.0.0.11:8080 weight=95; # 稳定版
server 10.0.0.21:8080 weight=5; # 新版本,先吃 5% 流量
keepalive 32;
}
| 阶段 | 新版本权重 | 观察时长 | 观察指标 |
|---|---|---|---|
| 首放 | 5(约 5%) | 15~30 分钟 | 5xx 比例、P95 延迟、日志异常数 |
| 加量 | 25 | 30 分钟 | 同上 + 业务核心指标(下单/登录成功率) |
| 半量 | 50 | 30~60 分钟 | 同上 |
| 全量 | 100(下线旧实例) | 留观 24 小时 | 回滚包保留到确认稳定 |
权重灰度要求「新旧版本接口兼容」:老版本实例还在处理请求,数据库结构、缓存结构、接口字段都不能有破坏性变更,具体做法见第 26 章的三步法。
验证方法
# 看 upstream 生效配置
nginx -T | grep -A10 'upstream app_cluster'
# 请求里带上上游地址,才能看出流量分布(把 $upstream_addr 加进 log_format)
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# 慢速验证:连续发 30 个请求,观察状态码与耗时
for i in $(seq 1 30); do curl -s -o /dev/null -w '%{http_code} %{time_total}\n' https://api.example.com/healthz; done
# 模拟一台挂掉:停掉 11 号机,确认请求全部成功且日志里不再出现该地址
ssh 10.0.0.11 'sudo systemctl stop myapp'
grep -c '10.0.0.11:8080' /var/log/nginx/error.log
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
配了 keepalive 没清空 Connection | 长连接不生效,TIME_WAIT 依旧很多 | 加 proxy_http_version 1.1 与 proxy_set_header Connection "" |
fail_timeout 设得过短 | 偶发超时就摘机,实例反复抖动 | 按错误率而非单次失败判断,10s 起步 |
/healthz 只返回 200 不查依赖 | 库挂了仍被认为健康,流量照进 | 健康检查里真连一次数据库/缓存 |
ip_hash 当默认选项 | 负载严重不均,一台挂掉会话全丢 | 优先共享 session,非必要不用 |
upstream 写域名不写 IP | 换 IP 后 Nginx 仍打旧地址 | 用 IP,或配 resolver 并配合变量动态解析 |
| 不摘流量直接 restart | 发布期间用户看到 502 | 先标 down 并 reload,再发版 |
用 backup 当常态分流 | 备用机长期空转 | 备用就是备用,常态分流用权重 |
小结:upstream 的价值不只是分流,而是「能安全地替换机器」;算法按实例是否同构、请求是否耗时均匀来选择,能用共享 session 就别用 ip_hash;开源版只有被动健康检查,靠 max_fails + fail_timeout 加一个真实探测依赖的 /healthz 就够用;keepalive 必须与 HTTP/1.1 和 Connection "" 一起配;发布的标准动作是「摘一台、发一台、验一台、放回去」,灰度则用权重一档一档放量。