背景与问题
很多系统一开始都很“朴素”:
- 一个 Web 服务实例
- 一个固定入口 IP
- 一个数据库主库
- 一个大家都觉得“先这么跑着”的 Nginx
系统在业务早期往往没问题,但一旦访问量上来,或者机器重启、网络抖动、磁盘打满,单点故障就会直接把服务带走。
我自己踩过一个很典型的坑:业务已经做了多实例部署,应用看起来“有集群”,但前面只有一个负载均衡节点。结果某次内核升级后负载均衡机器重启,后端十几台应用全都健康,却还是整体不可用。这个场景说明一件事:
高可用不是“多部署几台机器”,而是要把入口、流量分发、健康检查、状态同步、故障转移一起设计。
本文从 troubleshooting(排障) 的角度来讲,重点回答几个实际问题:
- 为什么“看起来有集群”还是会单点故障?
- 负载均衡和故障转移分别解决什么问题?
- 主备切换为什么会误判、脑裂、抖动?
- 出故障后,应该怎么定位:是应用挂了、LB 挂了、VIP 飘了,还是探活策略错了?
- 如何用一套可运行示例快速搭出一个可验证的高可用入口?
先看一个典型架构演进
从单点到高可用的最小闭环
flowchart LR
U[用户请求] --> VIP[VIP/统一入口]
VIP --> LB1[Nginx + Keepalived 主]
VIP -. 故障切换 .-> LB2[Nginx + Keepalived 备]
LB1 --> A1[App-1]
LB1 --> A2[App-2]
LB2 --> A1
LB2 --> A2
上图是最常见、也最容易落地的一种方案:
- Nginx 负责七层负载均衡
- Keepalived 负责 VIP 漂移与主备切换
- 后端应用多实例 负责承接流量
它的目标不是“永不故障”,而是:
- 单个应用实例挂掉,业务不受明显影响
- 单个负载均衡节点挂掉,入口自动切换
- 发生故障后,恢复路径明确、观测手段足够
核心原理
这一部分不讲空话,直接围绕排障最常见的几个误区。
1. 负载均衡解决的是“分发”,不是“高可用全部问题”
负载均衡的主要工作:
- 将请求分发到多个后端
- 通过健康检查避免把流量打到坏节点
- 在不同策略下做流量控制,如轮询、最少连接、权重
但如果只有一个负载均衡器,它本身还是单点。
所以很多系统的问题不是“没有负载均衡”,而是:
- 应用层有冗余
- 入口层没冗余
2. 故障转移解决的是“入口存活”
Keepalived 常见做法是使用 VRRP:
- 主节点持有 VIP
- 备节点监听主节点状态
- 主节点故障时,备节点接管 VIP
这个过程的核心不是“机器宕机才切换”,而是“满足策略条件才切换”。这些条件可能包括:
- Keepalived 进程是否还活着
- Nginx 是否可用
- 自定义健康检查脚本结果
- 网络接口状态是否正常
故障转移状态图
stateDiagram-v2
[*] --> BACKUP
BACKUP --> MASTER: 未检测到优先级更高主节点
MASTER --> BACKUP: 探活失败/优先级降低
MASTER --> FAULT: 网卡异常/脚本异常
FAULT --> BACKUP: 服务恢复
BACKUP --> MASTER: 抢占成功
这里最容易被忽略的是:主备切换不是二元判断,而是状态机。
如果探活脚本写得粗糙,就会出现频繁切换,也就是大家常说的“抖动”。
3. 健康检查的本质是“你定义什么叫活着”
这是实际生产里最关键也最容易出事的地方。
一个错误示例:
- 只检查 Nginx 进程存在
- 不检查 Nginx 是否还能处理请求
- 不检查后端是否全部失败
结果是:
- 进程还在,但 worker 卡死
- 80 端口还在监听,但 upstream 全挂
- Keepalived 认为“正常”,VIP 不切换
- 用户看到的是持续 502/504
更合理的健康检查应该至少回答:
- 负载均衡进程是否存活?
- 对外端口是否可用?
- 是否还能返回预期 HTTP 状态?
- 是否还有可用后端?
4. 会话保持与无状态设计的冲突
负载均衡一旦把请求分散到多个节点,就会遇到登录态、购物车、临时会话等问题。
常见解决方式:
- Session 粘滞:简单,但故障转移时体验差
- Session 外置到 Redis:更适合集群
- 应用无状态化:最推荐,但需要业务配合
中级读者做架构时,最值得记住的一条是:
高可用的前提,通常是尽量无状态;越依赖本地状态,越难稳定切换。
现象复现
为了让排障更有抓手,我们先复现几个典型现象。
现象 1:后端挂一台,用户偶发 502
原因通常有:
- Nginx upstream 没有正确剔除失败节点
- 健康检查间隔太长
- 应用只是“半死不活”,TCP 通但 HTTP 失败
现象 2:LB 主节点挂了,但 VIP 没漂移
常见原因:
- Keepalived 配置优先级逻辑有问题
- 防火墙拦截了 VRRP
- 健康检查脚本没触发降权
- 备节点网卡或绑定配置错误
现象 3:主备反复切换,业务时好时坏
常见原因:
- 探活脚本执行慢、超时短
- 网络短抖动导致误判
- 开启抢占但优先级设计不合理
- 两边都认为自己该接管 VIP,接近脑裂
实战代码(可运行)
下面给一套能本地快速验证思路的示例:
- 两个 Python 后端服务
- 一个 Nginx 做负载均衡
- 一个健康检查脚本
- 两份 Keepalived 配置示例
说明:Keepalived 需要 Linux 环境和管理员权限运行。
Python 与 Nginx 部分可直接本机测试,Keepalived 部分更适合两台 Linux 虚机演练。
1. 两个后端服务
app1.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import socket
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok", "instance": "app1"}).encode())
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
data = {
"message": "hello from app1",
"host": socket.gethostname(),
"path": self.path
}
self.wfile.write(json.dumps(data).encode())
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8001), Handler)
print("app1 listening on :8001")
server.serve_forever()
app2.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import socket
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok", "instance": "app2"}).encode())
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
data = {
"message": "hello from app2",
"host": socket.gethostname(),
"path": self.path
}
self.wfile.write(json.dumps(data).encode())
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8002), Handler)
print("app2 listening on :8002")
server.serve_forever()
启动:
python3 app1.py
python3 app2.py
验证:
curl http://127.0.0.1:8001/health
curl http://127.0.0.1:8002/health
2. Nginx 负载均衡配置
nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream backend_cluster {
least_conn;
server 127.0.0.1:8001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8002 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 8080;
server_name localhost;
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_pass http://backend_cluster;
}
location /lb-health {
access_log off;
return 200 "lb ok\n";
}
}
}
启动并测试:
nginx -c /path/to/nginx.conf
curl http://127.0.0.1:8080/
多请求几次,你会看到请求在 app1 / app2 间分发。
3. Keepalived 健康检查脚本
这个脚本会检查:
- Nginx 进程是否存在
- 8080 端口的
/lb-health是否返回 200
check_nginx.sh
#!/usr/bin/env bash
set -e
if ! pgrep -x nginx >/dev/null 2>&1; then
echo "nginx process not found"
exit 1
fi
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/lb-health || true)
if [ "$code" != "200" ]; then
echo "nginx health endpoint failed, code=$code"
exit 1
fi
exit 0
赋权:
chmod +x /etc/keepalived/check_nginx.sh
4. Keepalived 主节点配置示例
keepalived-master.conf
global_defs {
router_id LB_MASTER
}
vrrp_script chk_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 2
weight -20
fall 2
rise 2
timeout 2
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 120
advert_int 1
authentication {
auth_type PASS
auth_pass 123456
}
virtual_ipaddress {
192.168.1.100/24
}
track_script {
chk_nginx
}
}
5. Keepalived 备节点配置示例
keepalived-backup.conf
global_defs {
router_id LB_BACKUP
}
vrrp_script chk_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 2
weight -20
fall 2
rise 2
timeout 2
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass 123456
}
virtual_ipaddress {
192.168.1.100/24
}
track_script {
chk_nginx
}
}
6. 故障转移验证步骤
验证后端实例故障
先访问:
curl http://127.0.0.1:8080/
然后停掉一个后端:
pkill -f app1.py
再连续访问:
for i in {1..10}; do curl -s http://127.0.0.1:8080/; echo; done
你应该看到请求逐步只落到 app2。
验证 LB 健康检查
停掉 Nginx:
nginx -s stop
检查脚本会失败,Keepalived 会降低本机优先级,触发备机接管 VIP。
验证 VIP 漂移
在主、备机分别执行:
ip addr show
确认 192.168.1.100 是否漂移到备机。
故障转移时序图
下面这张图很适合帮助你定位“到底是哪一步没发生”。
sequenceDiagram
participant Client as 客户端
participant VIP as VIP
participant M as LB主节点
participant B as LB备节点
participant App as 应用集群
Client->>VIP: 发起请求
VIP->>M: 当前VIP在主节点
M->>App: 转发请求
App-->>M: 返回响应
M-->>Client: 返回结果
Note over M: 主节点Nginx异常
B->>M: VRRP心跳检测
B->>B: 提升为MASTER并绑定VIP
Client->>VIP: 新请求到达
VIP->>B: VIP已漂移到备节点
B->>App: 转发请求
App-->>B: 返回响应
B-->>Client: 返回结果
定位路径
当你遇到“服务不稳”“切换失败”“偶发 502”时,我建议按下面这条链路查,不要一上来就改配置。
第一步:先看入口到底在哪台机器
ip addr show
看 VIP 当前是否存在于预期机器上。
如果 VIP 不在任何节点:
- Keepalived 可能都没起来
- 配置文件有误
- 网卡接口名写错
- 权限或内核参数有问题
如果 VIP 在某节点,但访问仍失败:
- Nginx 可能没起来
- 端口未监听
- 防火墙未放通
- 上游应用全挂
第二步:确认 Keepalived 是否正常
systemctl status keepalived
journalctl -u keepalived -n 100 --no-pager
重点看:
- 是否频繁切换 MASTER/BACKUP
- 是否提示脚本超时
- 是否有接口异常
- 是否有认证不匹配
第三步:确认 Nginx 自己是否能处理请求
curl -v http://127.0.0.1:8080/lb-health
ss -lntp | grep 8080
ps -ef | grep nginx
如果本地都不通,不要再去怀疑后端。
第四步:确认 upstream 是否可达
curl http://127.0.0.1:8001/health
curl http://127.0.0.1:8002/health
如果后端偶尔超时,再看:
ss -s
top
dmesg | tail
排查是否存在:
- 连接数耗尽
- CPU 飙高
- 内存不足
- 被 OOM Killer 杀进程
第五步:确认网络层而不是应用层问题
如果主备看起来都正常,但 VIP 不漂移,重点排查:
- VRRP 是否被交换机或防火墙限制
- 安全组是否放行协议
- 同网段配置是否一致
arp缓存是否更新异常
常见坑与排查
这一节是最贴近实战的部分。
坑 1:只检查进程,不检查服务能力
表现
- Nginx 进程在
- Keepalived 不切换
- 用户持续报错
原因
进程活着不代表业务可用。worker 卡死、upstream 全失败时都可能出现。
建议
健康检查至少访问一个本地 HTTP 端点,必要时增加后端可用性判定。
坑 2:interface 写错
表现
- Keepalived 启动正常
- 但 VIP 不上浮或不漂移
原因
现在很多系统网卡名不是 eth0,可能是 ens33、enp0s3 等。
排查
ip link show
把配置中的 interface 改成真实网卡名。
坑 3:防火墙拦截 VRRP
表现
- 主备都认为自己健康
- 但互相收不到通告
- 可能导致双主
风险
这是非常危险的,双主会导致 VIP 争抢,网络表现会非常诡异。
建议
明确放通 VRRP 所需协议,测试时先临时关闭主机防火墙验证,再做精细规则。
坑 4:探活过于敏感,导致频繁切换
表现
- 日志里频繁 MASTER/BACKUP 变更
- 用户感觉“时好时坏”
原因
interval 太短、fall/rise 太小、脚本超时太紧,都会放大短暂抖动。
建议
常见思路:
interval 2fall 2~3rise 2~3timeout不要比脚本实际耗时更短
不要追求“1 秒内必切换”,先保证稳定。
坑 5:后端无状态没做好
表现
- 切换后用户重新登录
- 部分请求成功、部分失败
- 应用节点间行为不一致
原因
会话状态存在本地,负载均衡把请求打到另一台就失效。
建议
- Session 外置
- 文件上传、缓存、任务状态不要落本地
- 节点配置统一管理
坑 6:以为有主备就一定安全
表现
- 单机故障可以恢复
- 但机房网络故障时整体不可用
原因
主备在同一台交换机、同一可用区、同一故障域里,抗风险能力有限。
建议
真正的高可用要考虑故障域隔离:
- 不同物理机
- 不同可用区
- 不同电源/网络路径
止血方案
有时候线上出问题,第一目标不是“完美修复”,而是先恢复业务。
场景 1:Keepalived 抖动严重
先做止血:
- 临时关闭抢占
- 提高
fall/rise - 固定一个主节点承载流量
等流量稳定后再分析脚本与网络抖动原因。
场景 2:VIP 漂移异常
先确认哪台 LB 可用,然后临时将入口 DNS 或上游路由切到该节点固定 IP。
这不是长期方案,但能快速恢复服务。
场景 3:后端大量 502
先把异常节点从 upstream 中摘除,哪怕只保留一台健康实例,也比全量错误扩散更好。
安全/性能最佳实践
这一部分建议你当成上线前检查清单。
安全实践
1. 不要让健康检查脚本过度暴露内部信息
/lb-health 最好只返回简单状态,不暴露版本、主机名、内部路径。
2. Keepalived 认证不要使用弱口令
示例里为了演示用了简单密码,实际环境应使用更强的认证配置,并限制管理面访问。
3. 限制管理接口来源
Nginx 管理页、状态页、监控端点都应限制来源 IP,避免被随意探测。
4. 配置变更要可审计
LB 和 Keepalived 配置建议纳入版本控制,避免“某位同事在线手改后谁也说不清”。
性能实践
1. 合理设置超时
超时太长会拖死连接池,太短会误伤慢请求。
常见建议:
proxy_connect_timeout较短proxy_read_timeout根据业务特性调整- 健康检查超时略小于业务超时
2. 长连接要配套后端能力
Nginx keepalive 可以提升性能,但要确认后端服务连接管理能力,否则会把问题隐藏得更深。
3. 负载策略要结合业务
- round robin:通用默认
- least_conn:适合请求耗时差异大时
- ip_hash:有会话需求时可过渡使用,但不推荐长期依赖
4. 监控指标不能只盯 CPU
高可用架构里更应该关注:
- VIP 所属节点
- 主备切换次数
- upstream 健康节点数
- 5xx 比例
- 请求时延分位数
- 连接数、重传、丢包
方案边界与取舍
这套 Nginx + Keepalived 方案很常见,但不是万能的。
适用场景
- 中小型业务入口高可用
- 局域网内主备切换
- 成本敏感、希望快速落地
不适用或需要增强的场景
- 跨地域全局流量调度
- 超大规模七层流量治理
- 复杂灰度发布、服务网格控制
- 多活架构
如果业务已经进入多机房、多地域阶段,就要考虑:
- DNS/GSLB
- 云 LB 托管服务
- BGP/Anycast
- 多活与数据一致性设计
也就是说,故障转移解决的是入口可用,不等于全链路高可用已经完成。
总结
从单点故障走向高可用,最容易犯的错就是“以为部署了多台应用就是集群”。真正能扛故障的系统,至少要把这几件事串起来:
- 后端多实例:避免应用单点
- 负载均衡:把流量正确分发出去
- 健康检查:识别“真活着”还是“假活着”
- 故障转移:入口层主备切换
- 无状态设计:让切换成本足够低
- 可观测与排障路径:出问题能快速定位
如果你准备实战落地,我建议按这个顺序推进:
- 先把单机 Nginx + 多后端跑通
- 再补健康检查
- 再加 Keepalived 做 VIP 漂移
- 最后做故障注入测试:停应用、停 Nginx、断网卡、杀进程
最后给一个很务实的结论:
高可用设计的质量,不看架构图画得多漂亮,而看你是否真的演练过故障、是否知道故障发生时第一步该查哪里。
如果只能先做一件事,我建议你先把 健康检查与故障演练 做起来。因为多数“高可用失效”,本质上不是方案不存在,而是方案从来没有被真正验证过。