个人站点的「可用性」到底意味着什么
绝大多数个人站长对可用性的理解是「服务器别挂」。这个理解没有错,但太粗了。当你只有一台服务器的时候,这句话的实际含义是「我没有办法」。硬盘坏了你要等机房换盘,内存条挂了你要等重启,机房里那台交换机的固件升级让你宕机四十分钟——所有这些事你都无法控制,你能做的只有一件事:等它自己好。而如果这台机器上还跑着你的邮箱、你的监控、你用来恢复网站的工具,那这次故障就变成了一次真正的孤立事件:你甚至没有办法收到「网站挂了」的通知,因为通知发送的服务本身也在这台挂掉的机器上。
把可用性提升一个台阶,最经典也最经济的路径是「双机热备」:两台廉价的小服务器,一台主一台备,通过虚拟 IP(VIP)对外提供服务。主服务器正常时 VIP 在主上,所有流量都到主;主服务器一旦故障(进程挂了、网卡挂了、整机断电),VIP 在几秒内自动漂移到备服务器上,服务几乎无感地继续。
关键点在于,访问你网站的用户和搜索引擎完全不知道背后有两台机器——他们访问的始终是同一个 IP 地址。你不需要改 DNS、不需要等 TTL 生效、不需要处理「解析切换期间的两套数据不一致」。这就是 Keepalived 加上 VRRP 协议能给你的东西。
本文会完整走一遍这套方案:环境准备、Keepalived 配置、健康检查脚本、脑裂防范、数据同步,以及几个只有真正部署过才会遇到的坑。整套方案在两台 1 核 1G 的机器上就能跑,成本大约是单机方案的两倍,换来的是从「单点故障」到「秒级切换」的质变。
先搞清楚 VRRP 是怎么工作的
在动手之前,花五分钟理解 VRRP 会让你后面少走很多弯路。VRRP(Virtual Router Redundancy Protocol)的设计初衷是给路由器做冗余,它的工作方式很有意思:主备两台机器组成一个「虚拟路由器组」,这个组拥有一个虚拟 IP 和一个虚拟 MAC 地址。主节点会周期性地(默认 1 秒)向组播地址 224.0.0.18 发送 VRRP 通告包,宣告「我还活着,VIP 在我这」。
备节点一直在监听这些通告。如果连续几个周期(默认 3 秒)都没有收到通告,备节点就认为主节点已经死亡,于是接管 VIP——具体动作是在自己的网卡上把虚拟 IP 配上,同时把虚拟 MAC 也接管过来,并发送一个「免费 ARP」(Gratuitous ARP)广播,告诉整个局域网的交换机和路由器:「这个 IP/MAC 现在在我这里」。交换机更新 MAC 地址表,后续流量就自动送到备机上了。
整个过程通常在 3 到 5 秒内完成。这里有三个对个人站长非常重要的推论:
- 两台机器必须在同一个二层网络里。VRRP 依赖组播,跨机房、跨网段是跑不起来的。所以最简单的做法是在同一家云厂商的同一个可用区买两台机器,它们天然在同一个子网。
- VIP 必须是厂商没占用、又在你的子网内的地址。这点是云环境独有的坑——传统 IDC 你可以随便挑,云上必须申请。很多云厂商默认禁止你使用未分配的 IP,表现为 Keepalived 显示 VIP 已添加,但外部完全访问不通。阿里云、腾讯云都提供了「高可用虚拟 IP(HAVIP)」产品来专门解决这个问题。
- 切换不是零丢失,而是秒级恢复。已经建立的 TCP 连接会在切换时断开,正在进行中的请求会失败。用户刷新一下就好了,但对程序来说需要能容忍这种瞬时中断。
环境准备与网络规划
假设我们有两台机器,规划如下:
- 主机 A:
10.0.0.11(MASTER,priority 100) - 主机 B:
10.0.0.12(BACKUP,priority 90) - VIP:
10.0.0.100(对外提供服务,域名解析到这里) - 网卡:
eth0 - VRRP 组 ID:
51
注意 priority 的差值不要设得太小也不要太大。太小(比如 100 和 99)容易因为网络抖动导致不必要的切换;太大则没有意义。10 的差值是一个舒服的区间。同一网段里如果还有其他 VRRP 组,virtual_router_id 一定要互不相同,否则两组会互相干扰,症状是 VIP 在两组之间反复漂移,日志里刷满「received packet with different virtual_router_id」。
# 两台机器都执行
apt-get install -y keepalived
# 确保内核允许绑定非本机 IP(部分云镜像默认开了这个限制)
sysctl net.ipv4.ip_nonlocal_bind
# 如果是 0,需要改成 1
echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/99-vip.conf
sysctl --systemnet.ipv4.ip_nonlocal_bind 这个参数是必须检查的。当 Keepalived 发生状态切换时,Nginx 需要重新绑定到 VIP 上;如果这个参数是 0,内核会拒绝绑定一个「不属于本机」的地址,Nginx 报 Cannot assign requested address,VIP 漂过来了但服务起不来。Keepalived 通常会自己处理这个,但在某些云镜像上显式设置更保险。
Keepalived 主配置
主机的 /etc/keepalived/keepalived.conf:
global_defs {
router_id zz-node-a
# 关闭 VRRP 的 v2/v3 混用告警,减少日志噪音
vrrp_version 2
script_user root
enable_script_security
}
# 健康检查脚本的定义
vrrp_script chk_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 3 # 每 3 秒检查一次
weight -30 # 检查失败时优先级降 30
fall 2 # 连续 2 次失败才认为真的挂了
rise 2 # 连续 2 次成功才认为恢复
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
# 单播模式,后面详细说明
unicast_src_ip 10.0.0.11
unicast_peer {
10.0.0.12
}
authentication {
auth_type PASS
auth_pass Zz1984Vip2026
}
virtual_ipaddress {
10.0.0.100/24 dev eth0 label eth0:vip
}
track_script {
chk_nginx
}
notify_master "/etc/keepalived/notify.sh master"
notify_backup "/etc/keepalived/notify.sh backup"
notify_fault "/etc/keepalived/notify.sh fault"
}备用机的配置几乎一样,只需要改三处:router_id 改成 zz-node-b、state 改成 BACKUP、priority 改成 90,以及 unicast_src_ip / unicast_peer 互换。修改完记得两台机器的 auth_pass 必须完全一致,这个密码只有 8 位有效长度,超出的部分会被忽略——如果你写了两个字面不同的长密码但前 8 位相同,Keepalived 会认为匹配成功,这会让人非常困惑。
关于 unicast_peer:VRRP 默认用组播通信,但很多云厂商的虚拟网络对组播支持不好,甚至直接丢弃。改成单播模式(明确指定对端 IP)能绕开这个问题,也是我推荐所有云上部署都采用的方式。如果没配单播而组播又不通,症状是备机永远收不到通告,两台机器同时认为自己是 MASTER——这就是脑裂。
健康检查脚本:比「进程还在不在」更聪明的判断
Keepalived 自带的检查只看进程是否存活是不够的。一个最常见的故障场景是:Nginx 进程还在,但后端 PHP-FPM 挂了、或者数据库连不上、或者磁盘满了导致 Nginx 返回 502。这时候进程检查会通过,VIP 不会漂移,用户看到的却是一个持续报错的网站——热备完全失去了意义。
#!/bin/bash
# /etc/keepalived/check_nginx.sh
# 返回 0 表示健康,非 0 表示需要降权/切换
set -uo pipefail
# 1) 进程存在
pgrep -x nginx >/dev/null 2>&1 || exit 1
# 2) 本地真的能拿到正常响应(跟随重定向,检查是否有内容)
CODE=$(curl -s -o /tmp/health_body -w '%{http_code}' --max-time 5 \
-H 'Host: www.example.com' http://127.0.0.1/health.php)
[ "$CODE" = "200" ] || exit 1
# 3) 后端返回的不是错误页(防止 Nginx 假活)
grep -qi 'ok' /tmp/health_body || exit 1
# 4) 磁盘别写满——写满了网站也就废了
USE=$(df --output=pcent / | tr -dc '0-9')
[ "$USE" -lt 92 ] || exit 1
exit 0这个脚本的每一层检查都对应一个真实见过的故障。第一层是基础;第二层确保 Nginx 真的在处理请求而不是「端口开着但不响应」;第三层最容易被忽略——当 PHP-FPM 挂掉时,Nginx 依然会返回一个 HTTP 200 的错误页(因为它是从磁盘读的静态错误页面),只看状态码会漏判,所以必须检查内容;第四层则是运维里最经典的「磁盘满导致所有服务连锁故障」。
对应的 health.php 要写得极其轻量,它会被每 3 秒请求一次,绝不能去查数据库:
<?php
// 只做最简单的存活确认,不碰数据库、不读文件系统
header('Content-Type: text/plain');
echo 'ok ' . getmypid();
另外要记得把 /health.php 在 Nginx 里配置成不被写入访问日志,也不要在 robots.txt 里被搜索引擎抓到。3 秒一次的检查会产生大量日志噪音,而且暴露健康检查端点本身也不是好习惯:
location = /health.php {
access_log off;
allow 127.0.0.1;
allow 10.0.0.11;
allow 10.0.0.12;
deny all;
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root/health.php;
}通知脚本:切换必须让你知道
热备最大的风险是「静默降级」——某天主备宕了,VIP 漂到了备机,网站照常运行,你完全不知道;又过了一个月主机也宕了,这次没有任何备用了,网站直接下线,而你以为自己一直有冗余。所以每一次状态切换都必须主动通知。
#!/bin/bash
# /etc/keepalived/notify.sh
STATE=$1
HOST=$(hostname)
VIP=10.0.0.100
TS=$(date '+%F %T')
case "$STATE" in
master) EMOJI="[MASTER]"; SUBJ="VIP 已接管";;
backup) EMOJI="[BACKUP]"; SUBJ="VIP 已让出";;
fault) EMOJI="[FAULT]"; SUBJ="异常,无 VIP";;
esac
MSG="$EMOJI $HOST 在 $TS 切换到 $STATE,VIP=$VIP"
echo "$MSG" | mail -s "$(hostname): $SUBJ" ops@example.com
logger -t keepalived-notify "$MSG"
# 可选:推送到 Telegram / 钉钉 / 企业微信机器人
curl -s -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-d "chat_id=${TG_CHAT}" --data-urlencode "text=$MSG" >/dev/null 2>&1 || true把通知发到站外(邮箱、Telegram)非常重要。如果你只发到本机的邮件队列,而故障恰好是整机断电,那这条通知永远不会送达。用 Telegram Bot 或者企业微信机器人的 webhook 是最省事的做法,几行 curl 就够,不依赖任何本地服务。
脑裂防范:两台机器都以为自己是主
脑裂是热备方案里最危险也最隐蔽的问题。当两台机器之间的 VRRP 通信中断(网络分区、防火墙误封、单播对端配错),双方都收不到对方的通告,于是都宣称自己持有 VIP。结果是同一个 IP 同时在两台机器上生效,流量被随机制导到不同机器上,用户看到的表现是「网站一会儿正常一会儿错乱」,而日志里可能什么异常都没有。
Keepalived 本身提供了 nopreempt 和优先级机制来降低概率,但真正的兜底要靠两个手段:防火墙只允许合法 VRRP,以及用带仲裁的检查脚本。前者是基础:
# 只允许来自对端机器的 VRRP 协议
iptables -A INPUT -p vrrp -s 10.0.0.12 -j ACCEPT
iptables -A INPUT -p vrrp -j DROP
# 注意:如果你把默认策略设成 DROP 又忘了放行对端,那必然脑裂
带仲裁的检查脚本则更智能一些。思路是让健康检查脚本除了自己本机的情况,再去「探测一下对端」,如果发现对端也认为自己是主,就主动降权退出:
#!/bin/bash
# 增强版检查:防止脑裂
PEER=10.0.0.12
MY_IP=10.0.0.11
# 本机基础检查(同上)
pgrep -x nginx >/dev/null || exit 1
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1/health.php)
[ "$CODE" = "200" ] || exit 1
# 探测对端:如果对端也持有 VIP,说明脑裂了
if ping -c 1 -W 1 "$PEER" >/dev/null 2>&1; then
PEER_VIP=$(ssh -o ConnectTimeout=2 -o BatchMode=yes "$PEER" \
"ip -4 addr show eth0 | grep -c 10.0.0.100" 2>/dev/null)
if [ "${PEER_VIP:-0}" -gt 0 ]; then
logger -t keepalived-chk "SPLIT BRAIN detected: peer also has VIP"
exit 1 # 主动降权,让自己让出 VIP
fi
fi
exit 0这个脚本要求两台机器之间能免密 SSH,看起来有点重,但它的价值在于把「网络分区导致的脑裂」变成了一个可检测的状态。需要权衡的是:如果对端网络不通(ping 失败),脚本会选择放行——也就是说网络分区时仍有可能脑裂。要做到完全杜绝脑裂需要一个真正的仲裁者(比如用 etcd、Consul 或者云厂商的 HA 产品),那已经超出个人站长的成本承受范围了。对这个级别的站点来说,把概率降到很低、并且切换时立刻收到通知,已经完全够用。
数据同步:热备的另一半
VIP 漂移解决的是「服务可用」,但如果两台机器的数据不一致,切换过去之后用户看到的就是旧内容——这比宕机更糟,因为它会静默地损坏数据。所以双机热备必须配一套数据同步方案。个人站点通常有三个数据源需要处理:
- 网站文件:可以用前面讲的 inotify + rsync 双向实时同步,或者更简单粗暴地每天定时 rsync 一次(文件变化不频繁的站点完全够用)。
- 数据库:用 MySQL 主主复制或者主从复制。这里有一个原则性问题——主主复制虽然听起来更适合热备,但它有自增 ID 冲突的风险,需要配置
auto_increment_offset和auto_increment_increment,配置复杂且容易出错。对个人站长,我更推荐主从复制 + 手动提升:正常时只有主机可写,备机只读同步;真的需要切换时,进备机执行一次STOP SLAVE; RESET SLAVE ALL;把它提升为主。多了一步人工操作,换来的是绝不可能出现双写冲突。 - 上传目录与会话:如果用户上传的文件只在主机上,切到备机后图片全挂。用 rsync 单向同步主 → 备即可,配合
--delete保证一致。
需要特别提醒的是 --delete 的方向。如果你在备机上误配了一个「备 → 主」的反向同步并带 --delete,那么当备机数据因为任何原因变空时,它会忠实地把主机上的数据也删光。这类事故我见过不止一次,建议在备机的同步脚本里硬编码方向并加上日志记录,绝不做双向的 --delete 同步。
切换演练:不做演练的冗余等于没有冗余
方案配好之后,最关键的收尾工作是演练。不演练的热备,你永远不知道它能不能用。建议至少做这三组测试,并且每次都记录实际切换耗时:
# 测试 1:手动停止主机上的 Nginx,观察 VIP 是否漂移
systemctl stop nginx
# 在备机上持续观察
watch -n1 'ip -4 addr show eth0 | grep 10.0.0.100'
# 观察完恢复
systemctl start nginx
# 测试 2:模拟整机故障(用防火墙挡掉 VRRP 或者直接关机)
# 在主机执行
systemctl stop keepalived
# 在备机观察通知是否收到、VIP 是否接管
# 测试 3:脑裂测试
# 两台都停掉 keepalived,手动给两台都加上 VIP,模拟脑裂状态
# 确认你的检测脚本和通知能发现它
# 全程在客户端持续发起请求,统计失败次数
while true; do
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
-H 'Host: www.example.com' http://10.0.0.100/ || echo FAIL
sleep 0.5
done第三组测试尤其值得做,因为脑裂是这类方案里唯一「不出错则已、一错就是数据损坏」的问题。演练时你会发现一些意料之外的事——比如 Nginx 没有配置 net.ipv4.ip_nonlocal_bind 时切换后起不来,比如防火墙规则把 VRRP 也挡了导致根本不会切换,比如通知脚本因为本机没有 MTA 而静默失败。这些问题在演练中暴露出来是幸运,在生产故障中暴露出来就是事故。
演练完成后,把「切换步骤」和「恢复步骤」写成一份不超过一页的清单放在手边。人在凌晨三点处理故障时的判断力是打折扣的,一份可以照着念的操作清单,比任何漂亮的架构图都更有价值。