找回密码
立即注册
搜索
发回帖 发新帖

6373

积分

0

好友

809

主题
发表于 5 小时前 | 查看: 4| 回复: 0

一、三个真实工单

Keepalived 大概是运维生涯里最"配置五分钟、排障五小时"的组件之一。配置文件很短,短到让人觉得没什么可出错的;但一旦出问题,表现得又极其反直觉。下面三个工单是同类问题里最有代表性的。

工单 A:VIP 不漂移。 双节点负载均衡,lb1 是 MASTER 持有 VIP 10.x.x.100,lb2 处于 BACKUP 并正常接收通告。凌晨 lb1 上的 nginx 崩溃后被 systemd 反复拉起又崩溃,但 VIP 一直没切到 lb2,业务中断 40 分钟直到人工介入。事后检查发现两个节点上 keepalived 进程都活着,lb1 的日志里连一条健康检查失败都没有。运维的第一反应是"健康检查脚本写错了",但实际是两个独立问题叠加:一是 lb1 上健康检查脚本在一次备份恢复后丢了执行位,Keepalived 只在启动时记了一条警告就直接跳过了它,之后再也没有检查过;二是同一周的防火墙加固在 lb1 上丢弃了本机发出的 VRRP 通告,导致即使检查失败,优先级变化也传不到 lb2。详细的日志证据链见第十节的案例一。

工单 B:双主(脑裂)。 监控突然报 10.x.x.100 的 IP 冲突并且 MAC 地址频繁变化(ARP 抖动)。登上去看,两个节点都显示 state MASTER,都在往网络上发 VRRP 通告,都在给自己所在的交换机发 GARP。典型故障:两边都认为自己是主。

工单 C:漂移成功但流量不通。 触发切换后 keepalived 日志明确打印了 Entering MASTER STATE,ip addr 也能看到 VIP 在两个节点之间正确搬移,但业务请求仍然报超时。根因不在 keepalived,而在上游网络设备——它的 ARP 表项没有更新,流量还在往已经不持有 VIP 的那台机器发。

这三个工单对应本文的三条主线:VRRP 报文通路、状态机与优先级、健康检查与上层网络。三者里任何一个环节断了,表现都可能是"VIP 不漂移",但排查路径完全不同。所以本文的目标不是给一份能抄的配置,而是给一套能顺着现象往下走、每一步都有明确判断依据的排查方法。

二、先澄清:运维口语里的"双主"有两个完全不同的含义

这个歧义本身就在制造事故,必须在开头掰开。

含义一:双主架构(Active-Active)。 两台机器各自持有不同的 VIP,互为对方的备份。例如 lb1 持有 10.x.x.100、lb2 持有 10.x.x.101,lb1 对 VI_100 是 MASTER 对 VI_101 是 BACKUP,lb2 反之。这是正常且推荐的部署方式,目的是让两台机器的转发能力都用上,而不是一台闲着。

含义二:双主故障(Split-Brain,脑裂)。 两台机器同时认为自己是同一个 VIP 的 MASTER。这是故障,后果是同一个 IP 出现在两个网卡上,ARP 表在交换机上反复翻转,一部分流量打到 A、一部分打到 B,连接大量异常,极端情况下还会因为两个节点都响应同一 VIP 而出现 TCP 会话状态错乱。

本文两个含义都会讲。区分方法很简单也很可靠:

# 在每个节点上执行,看同一个 VIP 是不是只出现在一台机器上
ip -o -4 addr show | awk '{print $2, $4}'

如果 10.x.x.100 在 lb1 和 lb2 上都存在,那就是含义二。注意要区分"应该存在的双主"和"不该存在的双主"——lb1 上有 10.x.x.100、lb2 上有 10.x.x.101 是健康的;两个节点都有 10.x.x.100 才是脑裂。

一个更快的判定方式是看 VRRP 通告的方向。脑裂时两个节点都在发通告,用 tcpdump 能直接看到:

# 在任一节点上抓 3 秒 VRRP 报文,-e 显示 MAC,-n 不做 DNS 反解
sudo timeout 3 tcpdump -i eth0 -n -e vrrp

正常情况:当前 MASTER 在发通告,BACKUP 不发(只监听)。如果抓到两个不同源 IP 都在发通告,脑裂成立。

三、适用场景与不覆盖的内容

本文针对的典型场景:

  • 两节点 Keepalived + VIP 提供负载均衡入口高可用(后面挂 nginx、HAProxy、LVS)。
  • 双主架构:两个 VIP 分别落在两个节点上,故障时互为主备。
  • 需要排查:VIP 不漂移、漂移后不通、脑裂、来回抖动(flapping)、健康检查误判。
  • 环境是自建机房或需要自己管二层网络的云主机。纯托管负载均衡(云厂商 SLB)不在范围内。

不覆盖:Kubernetes 环境下的入口高可用(那属于 Service/Ingress/kube-vip 的领域),以及 LVS 转发模式的调优。LVS 相关的内容只在"注意事项"里点到必要的部分(如 DR 模式的 ARP 参数、连接同步),不做展开。

四、环境与版本假设

版本差异在 Keepalived 上是真实存在的坑,因为配置文件语法和默认行为在不同大版本之间变过。本文的假设如下:

  • Keepalived 2.2.x 为主。这是 RHEL 9 / Rocky Linux 9、Ubuntu 22.04 LTS 发行版仓库里的版本,也是当前生产环境最常见的一支。命令与配置以它为准。
  • 2.3.x 的差异单独标注。2.3.x 主要在 nftables 支持、SNMP 扩展和部分跟踪能力上增强。遇到不确定的差异我会写明"以你的版本为准,用 keepalived -t 校验"。
  • 1.3.x 的差异在对应位置说明。1.3.x 是 EL7 时代的版本,仍有存量环境在用,最明显的差异是 state 关键字必填、netfilter 规则走 iptables 而非 nftables。
  • 操作系统:Rocky Linux 9 / Ubuntu 22.04 LTS,x86_64。涉及包管理差异时同时给出 dnf 和 apt。
  • 防火墙:要么用 firewalld(EL 系默认),要么用裸 nftables(Ubuntu 常见)。两种都给出放行 VRRP 的方法。
  • 网络:两个节点在同一个二层广播域,VIP 与节点同网段。跨网段裸跑 VRRP 必须同二层,本文不推荐也不美化这种做法。
  • 监控:node_exporter 1.7+ 配合 textfile collector、Prometheus 2.4x、Grafana 10.x。

先确认你的版本,后面的所有判断都建立在这上面:

keepalived --version
# 预期输出形如:
#   Keepalived v2.2.8 (01/01,2024)
#   ...Built with kernel headers for Linux 5.14...
#   ...
#   # 注意输出里会列出编译进来的特性,重点看这几项:
#   #   nftables / iptables 支持
#   #   JSON / SNMP 支持
#   # 判断依据:输出里若同时有 nftables 和 iptables,说明规则走 nftables 优先
# 确认包来源与版本,便于对号入座
rpm -q keepalived            # EL 系
dpkg -l keepalived | tail -1 # Debian/Ubuntu 系
# 预期:keepalived-2.2.x-...  或  ii  keepalived  1:2.2.x-...

如果你在输出里看到的是 1.3.x,请把本文里所有涉及 state 的关键字都当必填处理,并且把 netfilter 相关的命令从 nft 换成 iptables。

五、这类问题的排查为什么容易走错方向

在讲协议细节之前,先把方法论说清楚,因为它比任何一个具体命令都重要。Keepalived 的排障有四个典型的错误习惯:

错误一:只看 keepalived 进程在不在。 进程在、CPU 不高的前提下,很多人就认为 Keepalived"没问题"。但 Keepalived 的绝大多数故障不是崩溃,而是逻辑上停止了工作:报文没收到、健康检查恒真、优先级算错、状态机卡在 BACKUP。进程活着和它在工作完全是两件事。

错误二:直接改配置,不看状态机。 看到"不漂移"就调 weight、调 advert_int、加 fall,属于碰运气。正确顺序是先确定当前状态机处于哪个状态、上一次状态迁移是什么时候、迁移的原因是什么。Keepalived 的状态迁移日志写得很清楚,绝大多数情况下它自己就告诉你答案了。

错误三:只在当前节点上查。 VRRP 是两方协议。一台机器的日志只能看到它自己认为的世界。必须同时看两边的状态、两边的日志、两边的优先级,还要看两者之间的报文是否真的到得了。只在一台机器上排查 VRRP,等于只问了半个当事人。

错误四:把"漂移"和"流量切换"当成同一件事。 Keepalived 只负责把 VIP 搬到某个节点上,并在二层发 GARP。流量能不能真的过去,取决于上游交换机/路由器的 ARP 表、依赖的 conntrack 状态、以及 VIP 上的服务是否已经就绪。这三件事都不在 Keepalived 的职责里,但都会表现为"VIP 不漂移"。

所以后面所有的排查都遵循同一套顺序,先说结论便于记忆:

先看状态机(谁认为自己是主)→ 再看报文(两方是否互相看得见)→ 再看健康检查(为什么不切换)→ 最后看上层网络(切了为什么不通)。

先看状态机(谁认为自己是主)→ 再看报文(两方是否互相看得见)→ 再看健康检查(为什么不切换)→ 最后看上层网络(切了为什么不通)。这四步是有依赖的,顺序不能颠倒。比如一个常见的浪费时间的场景:团队花了两小时调健康检查脚本,而实际上 VRRP 报文早就被防火墙拦了,脚本写得再对也不可能有切换动作。

六、核心知识:VRRP 到底在做什么

6.1 VRRP 是一个"投票选主"协议

VRRP(Virtual Router Redundancy Protocol)的设计目标非常单一:让一组机器对外表现为一个虚拟路由器,客户端不需要知道后面有几台机器、哪台在服务。实现方式就是选主加通告。

关键机制只有四条,把这几条吃透,后面所有现象都能推出来:

第一条:优先级决定谁是 MASTER。 优先级范围 0~255。数值越大越优先。配置里通过 priority 指定,默认 100。有三个特殊值必须记住:

  • 255 保留给 VIP 的地址拥有者(address owner),普通节点不能配 255。
  • 0 表示当前 MASTER 主动放弃主身份(例如进程退出时的优雅让位),收到优先级 0 通告的 BACKUP 会立即接管,不等 Master Down Timer 超时。
  • 1~254 是普通取值范围。如果 priority 经过 weight 计算后小于等于 0,该实例进入 FAULT 状态(注意是 FAULT,不是变成优先级 1)。

第二条:MASTER 周期发通告,BACKUP 只监听。 通告间隔由 advert_int 控制,默认 1 秒。BACKUP 如果连续在 Master Down Timer 内没收到通告,就认为 MASTER 死了,自己升主。

Master Down Timer 的计算方式是 VRRP 的经典公式:

Master_Down_Timer = 3 * advert_int + skew_time
skew_time         = (256 - priority) / 256   (单位:秒)

这个公式解释了一个容易困惑的现象:优先级越低的节点,等待时间越长。假设两个节点优先级是 150 和 100,advert_int = 1:

  • 优先级 150 的节点:skew = (256-150)/256 = 0.414s,超时 = 3 + 0.414 = 3.414s
  • 优先级 100 的节点:skew = (256-100)/256 = 0.609s,超时 = 3 + 0.609 = 3.609s

所以当 MASTER 真的挂掉,优先级高的那个会先超时先接管,这就是"用优先级控制接管顺序"的实现原理。如果两个节点优先级相同,超时时间也相同,此时按 VRRP 规定比较主 IP 地址,IP 大的胜出。这条规则很重要:优先级配成一样的两个节点,主备关系是靠 IP 大小决定的,这在扩缩容换机器时会悄悄改变行为。

第三条:抢占(preempt)是默认行为。 当 BACKUP 收到比自己优先级低的通告时,默认会立刻抢占,自己升主。这是大多数"来回抖动"故障的源头。反过来,用 nopreempt 可以让节点"不主动抢回",实现稳定的主备分工。

第四条:接管时要发 GARP。 新 MASTER 升主后必须发送 Gratuitous ARP,告诉二层网络上的所有设备"VIP 现在在这个 MAC 上"。这一步失败或不生效,就是工单 C 的根因。

6.2 报文层面:VRRP 用什么地址通信

这部分是"VIP 不漂移"排查的第一现场,必须精确记住。

项目 VRRP v2(RFC 3768) VRRP v3(RFC 5798)
IP 协议号 112 112
目的组播地址(IPv4) 224.0.0.18 224.0.0.18
目的组播地址(IPv6) 不适用 ff02::12
目的 MAC 01:00:5e:00:00:12 01:00:5e:00:00:12
IP TTL 255 255
通告间隔 整数秒(advert_int) 支持厘秒(v3 的 advert_int 可含小数)

由此直接推出三个排查结论:

  1. VRRP 不是 TCP,也不是 UDP,它没有端口号。 所以"放行 112 端口"这种说法是错的,防火墙必须按协议号放行。这是防错加固后 Keepalived 静默失效的头号原因。
  2. VRRP 要求 TTL = 255,且接收方通常检查 TTL 是否为 255。 跨越任何三层设备(路由器、某些云的虚拟网关、NAT 网关)都会导致 TTL 变化或组播不通。裸跑 VRRP 必须同二层,跨网段不属于 VRRP 的适用场景。
  3. 组播在很多虚拟网络里被丢弃。 云主机、VXLAN/EVPN 网络、部分交换机端口隔离配置下,224.0.0.18 可能到不了对面。这时正确的做法是用 unicast_peer 改成单播,而不是去改协议。

6.3 Keepalived 的状态机

Keepalived 的 VRRP 实例有四个状态,理解它们的迁移条件是所有排查的基础:

         ┌──────┐
         │ INIT │  进程启动 / 配置解析完成
         └──┬───┘
            │ 接口就绪、track 项检查完成
    ┌───────┴────────┐
    │                │
    ▼                ▼
┌────────┐      ┌────────┐
│ BACKUP │◄────►│ MASTER │
└───┬────┘      └───┬────┘
    │                │
    └───────┬────────┘
            ▼
        ┌────────┐
        │ FAULT  │  track_interface 断开 / track_script 使优先级 <= 0
        └────────┘

四个状态的实际含义:

  • INIT:进程刚起、或正在等待接口就绪。这个状态是瞬时的,但如果一直停在 INIT,通常是接口名写错(例如配了 eth0 而实际网卡叫 ens18)或者接口还没 up。
  • BACKUP:监听状态。不响应 VIP 上的流量(默认 HA 模式),不拥有 VIP。
  • MASTER:持有 VIP,周期发通告,响应 VIP 流量。
  • FAULT:被跟踪的资源出问题了,主动放弃参与选举。进入 FAULT 的实例不发通告、不持有 VIP。FAULT 是"主动认输",不是崩溃。

FAULT 状态值得单独说,因为它是两类完全不同故障的分界线:

  • 进入 FAULT 的实例,优先级不参与选举。如果两个节点都进 FAULT,VIP 就谁都不持有,表现为 VIP 完全消失、业务整体不可用(比脑裂还严重,因为没有任何节点在服务)。
  • 从 FAULT 恢复后,实例回到 BACKUP,然后按常规抢占规则重新参与选举。这个恢复动作本身可能触发一次不必要的切换,是抖动的一个来源。

6.4 两类"健康检查"完全不是一回事

这是本文最容易被混淆、也最容易导致排错方向错误的一点。Keepalived 里有两套完全独立的健康检查机制:

第一套:vrrp_script(VRRP 层的健康检查)。 它的动作是改变优先级或把实例推进 FAULT,从而触发 VIP 漂移。用于检查"这台机器还能不能对外提供服务"——nginx 进程是否活着、HAProxy 是否可连、后端是否可达。

第二套:LVS real_server 的检查器(负载均衡层的健康检查)。 它是 MISC_CHECK / TCP_CHECK / HTTP_GET 等,动作是把某个 real server 从 IPVS 的转发列表里摘掉或加回来。VIP 不会因为这类检查失败而漂移。

这两者的区别用一个场景就能说清:

客户端 → VIP(10.x.x.100) → [lb1 或 lb2] → 后端 real server (10.x.x.11, 10.x.x.12)
  • 如果后端 10.x.x.11 上的应用挂了:应该是 real_server 检查失败,10.x.x.11 被摘掉,VIP 仍在原节点。VIP 不应该漂移。
  • 如果本机的负载均衡进程(nginx/HAProxy/LVS 转发模块)挂了:应该是 vrrp_script 失败,VIP 漂移到另一台。real_server 的检查状态与这个决定无关。

做错这个区分会导致两类反向故障:

  • 把"后端挂了"写成 vrrp_script,结果某个后端故障时两个节点的 VIP 来回抢,服务大面积抖动。
  • 把"本机进程挂了"只写在 real_server 检查里,结果本机进程死了 VIP 也不漂移,两台机器都在转发但都转不出去——就是工单 A 的形态。

排查时第一个要问的问题就是:你关心的这个"健康",出问题后希望 VIP 漂移,还是不漂移? 答案决定了检查应该写在哪个段里。

6.5 影响选举的四类"跟踪"配置

vrrp_instance 里除了 priority,还有四类能改变选举结果的配置。它们在配置上很像,行为差异却很大:

配置 作用对象 失败后的动作 典型用途
track_interface 网卡 实例进入 FAULT 跟踪业务网卡、上联口
vrrp_script + weight 自定义脚本 优先级 加减 weight;若结果 <= 0 则进入 FAULT 跟踪 nginx/HAProxy 进程
vrrp_script + weight 0 自定义脚本 脚本失败时实例进入 FAULT(不改优先级) 需要"要么全好要么不做主"的场景
vrrp_sync_group 一组 vrrp_instance 组内实例整体同步迁移 双主架构下多个 VIP 需同进同退

关于 weight 的符号,务必记住方向容易搞反:

vrrp_script chk_nginx {
    script "/usr/local/bin/check_nginx.sh"
    interval 2          # 每 2 秒执行一次
    weight -30          # 脚本失败 → 优先级 -30
    fall 2              # 连续失败 2 次才算失败
    rise 2              # 连续成功 2 次才算恢复
    timeout 3           # 脚本执行超过 3 秒判定失败
}
  • weight 为负:失败时优先级下降。这是最常用的写法——本机进程挂了,优先级降到低于对端,VIP 漂过去。
  • weight 为正:成功时优先级上升。用于"这台机器状态更好,应该优先做主"的场景。
  • 配反符号是极常见的错误:写成 weight 30 后,脚本失败时优先级不变(因为正向权重只在成功时加成),于是"健康检查失败但 VIP 不漂移"。表面看脚本日志一切正常,非常难发现。

vrrp_sync_group 在双主架构里几乎是必需的,原因看第六节的配置就清楚了:双主意味着每台机器上同时存在多个 vrrp_instance(一个持 VI_100,一个备 VI_101)。如果不加同步组,可能出现"VI_100 在 lb1、VI_101 也在 lb1"的中间态——两个 VIP 压在同一台机器上,另一台空转。加上同步组后,组内实例要么全在这边要么全在那边。

6.6 state 与 nopreempt 的真实语义

这两个配置的语义和大多数人的直觉不一致,值得单独讲清。

state 只是"初始状态",不是"永久角色"。 配 state MASTER 的节点启动后会先进入 MASTER 并发通告,但如果收到优先级更高的通告,它会退成 BACKUP。所以不存在"我把 node1 配成 MASTER 它就永远是主"这回事。真正决定角色的是优先级和抢占规则。

nopreempt 只有配 state BACKUP 时才有效。 这一点在官方文档里写得清楚,但配置里不会报错,只是被忽略。所以想实现"稳定主备、故障后不抢回",正确写法是:

vrrp_instance VI_100 {
    state BACKUP          # 必须是 BACKUP,nopreempt 才生效
    nopreempt             # 恢复后不主动抢回,避免来回切换
    priority 100          # 两台可以配成同样的优先级
    ...
}

用 nopreempt 配 state MASTER 时,不同版本的表现不一致:有的只打一条 warning 然后忽略,有的直接进入 MASTER 并频繁抢占。这类"文档说得清、实现不一致"的地方,必须用实际版本验证:

# 1. 校验语法(不同版本的校验严格程度不同)
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf
# 或者用短选项,注意 -t 在某些版本里是 --config-test,在另一些里是别的含义
# 判断依据:退出码为 0 且无输出即为通过;有 "WARNING" 不一定是错误但必须读

# 2. 前台带调试日志启动,观察它到底怎么处理 nopreempt
sudo systemctl stop keepalived
sudo keepalived -n -l -D -f /etc/keepalived/keepalived.conf
# -n = 不 fork(前台)  -l = 日志打到控制台  -D = 详细日志
# 预期:日志里会明确出现 "nopreempt is not allowed with state MASTER" 之类的提示
#       (具体措辞随版本变化,重点是看它是否真的接受了 nopreempt)

6.7 vrrp_strict 与防火墙规则的相互干扰

vrrp_strict 是 RFC 合规强化开关。打开它会同时做几件事:

  • 禁止 unicast_peer(这直接和云环境的需求冲突)。
  • 要求 advert_int 为 1 秒、priority 为 255 的节点必须是地址拥有者。
  • 通过 netfilter 添加规则丢弃不合规的 VRRP 报文。

问题出在最后一条:vrrp_strict 和 Keepalived 自己也会插入 netfilter 规则,这就有两个高频故障:

故障形态一:防火墙 reload 把 Keepalived 的规则冲掉。 Keepalived 在非 MASTER 状态时会插入规则丢弃入向 VRRP 报文(目的是防止 BACKUP 处理不该处理的通知)。当 firewalld 执行 --reload 或 systemctl reload firewalld 时,它会重建整条规则链,把 Keepalived 插入的规则一并删掉。结果是 BACKUP 收到了本该被丢弃的报文,行为变得不可预期。

# 查看 Keepalived 插入的 vrrp 相关规则(nftables 环境)
sudo nft list ruleset | grep -i -A3 -B1 vrrp
# 预期:能看到一条 drop vrrp 的规则,且带有 keepalived 相关的注释
# 异常:完全没有 vrrp 相关规则 → 规则被 reload 冲掉了

# iptables 环境(Keepalived 1.3.x 或显式配置了 vrrp_iptables)
sudo iptables -L INPUT -n -v | grep -i vrrp

判断依据:如果 Keepalived 该有规则却没有,说明被外部工具清理过。 修复方式不是手工把规则加回去(下次 reload 又没了),而是:

  1. 显式指定 Keepalived 管理的链名,避免和 firewalld 冲突:
global_defs {
    # nftables 环境:指定表名
    vrrp_nftables keepalived_vrrp
    # iptables 环境(1.3.x 或旧版):指定链名
    # vrrp_iptables keepalived_vrrp
}
  1. 或者在防火墙侧把两台节点之间的 VRRP 显式永久放行,不再依赖 Keepalived 自己插规则。

故障形态二:加固时拦了协议 112。 这是工单 A 的根因,也是本节最重要的内容。VRRP 没有端口,加固方案里"开放必要端口"的清单通常完全不会包含它,因为清单是按端口写的。

正确的放行方式(按环境选择,只放行对端节点,不要对全网开放):

# ===== firewalld(EL 系)=====
# 方式一:富规则,只允许对端两个 IP 发来的 vrrp 协议
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.x.x.11/32" protocol value="vrrp" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.x.x.12/32" protocol value="vrrp" accept'
sudo firewall-cmd --reload
# 生效验证
sudo firewall-cmd --list-rich-rules

# 方式二:把节点所在网段加入 trusted zone(更宽松,需评估)
sudo firewall-cmd --permanent --zone=trusted --add-source=10.x.x.0/24
sudo firewall-cmd --reload
# ===== 裸 nftables(Ubuntu 22.04 常见)=====
# 先看现有规则,确认 input 链的名字,不要凭猜
sudo nft list tables
sudo nft list chain inet filter input

# 按协议号放行(vrrp 是 nft 内置的协议名,等价于 number 112)
sudo nft add rule inet filter input ip saddr 10.x.x.11 ip protocol vrrp accept
sudo nft add rule inet filter input ip saddr 10.x.x.12 ip protocol vrrp accept
# 注意:nft add 是运行时生效,重启即丢。持久化要写进 /etc/nftables.conf 并 enable nftables.service
# ===== iptables(旧环境)=====
sudo iptables -I INPUT -p 112 -s 10.x.x.11 -j ACCEPT
sudo iptables -I INPUT -p 112 -s 10.x.x.12 -j ACCEPT
# 持久化(EL 系)
sudo service iptables save 2>/dev/null || sudo iptables-save | sudo tee /etc/sysconfig/iptables

验证放行是否真的生效,不要只看规则列表:

# 在 lb1 上抓包,同时看是否收到 lb2 的通告
# 前提:lb2 处于 MASTER 或在被强制发送通告
sudo timeout 5 tcpdump -i eth0 -n -e -vv vrrp
# 预期:能看到 "IP 10.x.x.12 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 100"
# 异常:什么都没有 → 要么对端没发,要么被拦,要么组播不通(需要用 unicast_peer)

这一步的预期输出就是整个排查的分水岭:抓不到报文,后面所有关于优先级、脚本、状态机的分析都是白费力气。

七、整体思路:把故障空间切成四层

前面把机制讲完了,现在给出可复用的排查框架。Keepalived 的所有"VIP 不漂移"问题,都落在下面四层中的某一层。顺序遍历,遇阻即停,不要跳层。

第 1 层:进程与配置   —— Keepalived 起来了吗?配置被接受了吗?实例在哪个状态?
第 2 层:VRRP 报文    —— 两方互相看得见吗?通告在双向流动吗?
第 3 层:健康检查     —— 该触发切换的条件下,检查真的失败了吗?失败后真的改了优先级吗?
第 4 层:上层网络     —— VIP 搬过去了,ARP 更新了吗?连接重建了吗?服务就绪了吗?

每一层都有一个"通过的判据"。只有当前一层的判据成立,才值得往下走:

层 通过判据 不通过的典型现象
1 进程与配置 实例处于 MASTER 或 BACKUP,配置文件校验通过 实例停在 INIT/FAULT;systemctl status 反复重启
2 VRRP 报文 抓包能看到双向通告,状态机稳定不抖动 抓不到通告;只有单向;日志反复 BACKUP/MASTER 切换
3 健康检查 手工执行脚本的退出码与 Keepalived 判断一致 脚本手工跑失败但优先级没变;日志有 Vrrp script ... failed 但状态不变
4 上层网络 从上游设备侧 ping/curl VIP 通 本机 curl 通、跨机不通;ARP 表陈旧

这个框架的最大价值是防跑偏。 工单 A 里团队花了大量时间在第 3 层,而故障在第 2 层,只要在第 2 层抓一次包就能定位。反过来也有大量案例是报文完全正常、第 4 层的交换机 ARP 老化没更新。

八、实战:配置一个正确的双主 + 健康检查环境

下面直接给一份可用于生产的双主配置,然后在解读里说明每一行的作用和常见改法。

8.1 拓扑与前置检查

                   ┌──────────────┐
   客户端 ────────► │  上游交换机   │
                   └───┬──────┬───┘
                       │      │
               VIP .100 │      │ VIP .101
                       │      │
                 ┌─────▼──┐ ┌─▼──────┐
                 │  lb1   │ │  lb2   │
                 │.11     │ │.12     │
                 └────┬───┘ └───┬────┘
                      │         │
                 ┌────▼─────────▼────┐
                 │  real servers     │
                 │  .21  .22  .23    │
                 └───────────────────┘
  • 专用 VIP 段:10.x.x.100(主业务)、10.x.x.101(第二业务)
  • lb1 = 10.x.x.11,lb2 = 10.x.x.12,VIP 在 10.x.x.0/24 内
  • 双主:lb1 持 10.x.x.100、备 10.x.x.101;lb2 反之
  • 负载均衡软件以 HAProxy 为例(同时也给出 LVS 的 virtual_server 写法差异)

前置检查(改配置之前必须跑完,这些是后面的基线):

# 1. 确认两个节点在同一二层、VIP 未被占用
ip -br addr show
ip route get 10.x.x.100
# 预期:两个节点网卡在同一子网;route get 返回本机网卡
# 异常:路由指向别的网关 → VIP 与节点不同网段,VRRP 裸跑不适用

# 2. 确认 VIP 当前没有残留(换机器或重建环境时最容易踩)
ping -c 2 -W 1 10.x.x.100 ; echo "exit=$?"
# 预期:exit=1(不可达)
# 异常:能 ping 通且不是你预期的持有者 → 先查 ARP,可能是别的设备占用了

# 3. 从两个节点互相检查 VRRP 可达性(在加固前先确认基础通路)
sudo timeout 5 tcpdump -i eth0 -n vrrp &
sleep 1
# 在另一个节点上强制发一个通告(临时把 advert_int 调小或用下面的方法观察)
# 更实际的做法:直接启动 keepalived 后观察,见第 2 层的判据

# 4. 确认防火墙状态与是否拦了 112
sudo firewall-cmd --state 2>/dev/null || echo "firewalld not active"
sudo nft list ruleset 2>/dev/null | grep -c . || true
sudo iptables -L INPUT -n 2>/dev/null | head -20
# 判断依据:看到任何默认 DROP/REJECT 策略,都必须显式确认 112 已被放行

# 5. 确认系统时间正常(日志时间戳是排查依据,时间错乱会让日志无法对齐)
timedatectl
# 预期:System clock synchronized: yes

风险提示:如果第 2 步发现 VIP 已被占用,不要直接启动 Keepalived。两个设备同时持有同一个 IP 会立刻造成 IP 冲突和 ARP 抖动,影响面可能超出你自己的系统。必须先定位占用者并处理。

8.2 完整配置:/etc/keepalived/keepalived.conf

先给 lb1 的完整文件。

# /etc/keepalived/keepalived.conf  (lb1 = 10.x.x.11)

global_defs {
    # 邮件通知需要本机有可用的 MTA,没有 MTA 时这部分是静默失效的,见 11.4
    notification_email {
        ops-oncall@example.com
    }
    notification_email_from keepalived-lb1@example.com
    smtp_server 127.0.0.1
    smtp_connect_timeout 30

    # router_id 必须全网唯一,且不要用 IP(换机器后会变,历史日志对不上)
    router_id LB1

    # 显式指定 netfilter 表/链名,避免和 firewalld 互相清理规则。
    # 【版本差异】这两个关键字在 2.x 的不同小版本上支持情况不同,
    # 必须用 keepalived --config-test 校验,报 unknown keyword 说明你的版本不支持。
    vrrp_nftables keepalived_vrrp

    # 关闭 strict 模式的原因:它会禁掉 unicast_peer,
    # 而云环境和跨子网的场景需要单播。自建同二层环境可以打开以增强 RFC 合规性。
    # vrrp_strict

    # 让 Keepalived 使用内核已有的 VIP 而不报 "IP address already exists",
    # 适用于 VIP 被其他工具管理(如云厂商辅助网卡)的场景
    # vrrp_garp_master_delay 5
    # enable_script_security     # 重要,见 8.4 脚本安全段
}

# ============ 健康检查脚本定义 ============
vrrp_script chk_haproxy {
    # 脚本路径必须绝对路径;脚本本身要能被 root 执行且有执行位
    script "/etc/keepalived/scripts/check_haproxy.sh"
    interval 2          # 每 2 秒执行一次
    timeout 3           # 单次执行超过 3 秒视为失败
    weight -30          # 失败时优先级 -30(注意负号方向,见 8.4)
    fall 2              # 连续 2 次失败才判定失败(避免瞬时抖动触发切换)
    rise 2              # 连续 2 次成功才判定恢复
}

vrrp_script chk_backend {
    script "/etc/keepalived/scripts/check_backend.sh"
    interval 5
    timeout 4
    weight -20
    fall 3
    rise 3
}

# ============ 同步组:双主架构的关键 ============
# 把两个实例绑在一起,保证两个 VIP 要么都在本机,要么都在对端,
# 不会出现两个 VIP 压在同一台机器上的中间态
vrrp_sync_group VG_BOTH {
    group {
        VI_100
        VI_101
    }
    # 组内任一实例进入 FAULT,整组跟着进 FAULT(默认行为,可显式写出便于阅读)
    notify_master "/etc/keepalived/scripts/notify.sh master"
    notify_backup "/etc/keepalived/scripts/notify.sh backup"
    notify_fault  "/etc/keepalived/scripts/notify.sh fault"
    # smtp_alert    # 打开后每次状态变化发邮件,量大的环境建议关掉用 Prometheus 告警替代
}

vrrp_instance VI_100 {
    state BACKUP            # 初始状态。真正的角色由优先级和抢占决定
    interface eth0          # 【必须与实际网卡名一致】用 ip -br addr 确认真实名字
    virtual_router_id 100   # 同一 VRID 在整个二层域内必须唯一
    priority 150            # 本机在 VI_100 上更高,所以持 10.x.x.100
    advert_int 1
    nopreempt               # 故障恢复后不抢回,避免来回抖动。要求 state 为 BACKUP

    # 认证:防止同网段的其他 VRRP 组误混入
    authentication {
        auth_type PASS
        auth_pass <此处由密钥管理下发,不要写明文进仓库>
    }

    # 跟踪网卡:业务网卡或上联口 down 时实例进 FAULT
    track_interface {
        eth0
        # eth1 weight -20    # 1.3.x 的写法,2.x 上 weight 语义不同,改配置前先校验
    }

    track_script {
        chk_haproxy         # 失败 → 优先级 150-30=120
        chk_backend
    }

    virtual_ipaddress {
        10.x.x.100/24 dev eth0
    }

    # GARP 参数:二层网络里决定"切了之后流量多久过去"
    garp_master_delay 1     # 升主后延迟多久开始发 GARP
    garp_master_refresh 5   # 持主期间周期性重发 GARP(应对上游 ARP 老化)
    # garp_master_repeat 3  # 每次发几个 GARP

    # 单播模式(云环境/组播被丢的环境用,注意要和 vrrp_strict 冲突)
    # unicast_src_ip 10.x.x.11
    # unicast_peer {
    #     10.x.x.12
    # }
}

vrrp_instance VI_101 {
    state BACKUP
    interface eth0
    virtual_router_id 101
    priority 100            # 本机在 VI_101 上更低,所以备 10.x.x.101
    advert_int 1
    nopreempt

    authentication {
        auth_type PASS
        auth_pass <同上>
    }

    track_interface { eth0 }
    track_script { chk_haproxy }

    virtual_ipaddress {
        10.x.x.101/24 dev eth0
    }
}

lb2 的配置与 lb1 只有三处不同:

# /etc/keepalived/keepalived.conf  (lb2 = 10.x.x.12)—— 只列出差异行
global_defs {
    router_id LB2                      # 差异 1:router_id 必须唯一
}
vrrp_instance VI_100 {
    priority 100                       # 差异 2:在 VI_100 上更低
}
vrrp_instance VI_101 {
    priority 150                       # 差异 2:在 VI_101 上更高
}

8.3 配置解读:几个必须解释清楚的点

为什么两个实例都是 state BACKUP? 因为双主架构下每个节点都是"一半主一半备"。用 state BACKUP + nopreempt 的组合,可以让启动顺序无关紧要——先起的不会因为自己是 MASTER 就把两个 VIP 全抢走,优先级高的那个自然拿到属于它的 VIP。

为什么 priority 是 150 和 100 而不是 101 和 100? 因为脚本权重。chk_haproxy 是 -30、chk_backend 是 -20,最坏情况优先级降到 150-50=100。如果对端是 100,就会打平——优先级相同时按 IP 大小决定,行为变得依赖机器 IP,调试时很迷惑。留足够的间隔(这里 50 分)能让"脚本失败"和"没失败"两种情况产生明确区分:

  • lb1 全部健康:150 vs 100 → lb1 持 VI_100 ✅
  • lb1 上 haproxy 挂了:150-30=120 vs 100 → lb1 仍然持主 ❌ 这是个设计缺陷!

这里必须停下来说清楚,因为它是一个真实且常见的配置错误:weight -30 在优先级 150 对 100 的情况下不足以触发切换。120 仍然大于 100,所以 haproxy 挂了也不会漂移。这就是工单 A 的配置层原因(加固导致的报文拦截是网络层原因,两个问题叠加)。

两种正确的消解方式:

方式一:让权重足够大,覆盖优先级差。 保证"任一脚本失败"后优先级低于对端:

vrrp_script chk_haproxy {
    script "/etc/keepalived/scripts/check_haproxy.sh"
    interval 2
    weight -60          # 150-60=90 < 100,保证失败即让位
    fall 2
    rise 2
}

方式二(更推荐):用 weight 0 让失败直接进 FAULT。 这样脚本一失败就无条件退出选举,不依赖优先级算术,意图也更清晰:

vrrp_script chk_haproxy {
    script "/etc/keepalived/scripts/check_haproxy.sh"
    interval 2
    weight 0            # 关键:weight 为 0 时,脚本失败 → 实例进入 FAULT
    fall 2
    rise 2
}

验证方式(这一步不能省,配完必须实测):

# 在 lb1 上人为让 haproxy 检查失败,观察优先级是否真的下降
sudo systemctl stop haproxy
sudo journalctl -u keepalived -f --since "1 min ago"
# 预期日志(关键行为,措辞随版本略有差异):
#   VRRP_Script(chk_haproxy) failed
#   VRRP_Instance(VI_100) Changing effective priority from 150 to 90
#   VRRP_Instance(VI_100) Entering BACKUP STATE     (或 FAULT,取决于 weight)
# 异常表现:只有 "VRRP_Script(chk_haproxy) failed",没有 priority 变化的行
#          → 权重方向配反了(写成了 weight 30),或者 fall 次数没到

# 确认 VIP 真的搬走了
ip -br addr show eth0 | grep 10.x.x.100 ; echo "lb1-exit=$?"
# 预期:grep 无输出(exit=1),VIP 已不在 lb1
ssh lb2 "ip -br addr show eth0 | grep 10.x.x.100"
# 预期:lb2 上能看到 VIP

# 恢复
sudo systemctl start haproxy
# 因为配了 nopreempt,VIP 不会自动切回 lb1。这是预期行为,不是故障。

最后这一条特别重要:nopreempt 的语义就是"不抢回"。很多团队在演练后发现"服务恢复了但 VIP 没回来",误判为故障。这是设计行为。 如果要自动切回,就得去掉 nopreempt,代价是牺牲抖动抗性。

8.4 健康检查脚本:写对很难

脚本是 Keepalived 里最容易写错的部分,因为它对退出码和输出有隐含要求。先讲规则,再给可直接用的实现。

退出码约定(不同版本对非 0/1/2 的退出码处理有差异,务必只返回这三种):

退出码 Keepalived 的判定
0 成功。weight 为负时优先级不变;weight 为 0 时无动作
1 失败。按 fall/rise 计数,达到阈值后应用 weight 或进 FAULT
2 不改变状态(部分版本支持)。用于"无法判断"的场景,例如依赖服务暂时不可达
其他(如 127) 行为不确定,不要依赖。命令不存在、权限不足时 shell 会返回 126/127

输出约定:脚本的 stdout/stderr 会被 Keepalived 记录到日志。所以脚本应该打印可诊断的简短信息,但不要打印大量内容 —— 每 2 秒执行一次、大量输出会迅速填满日志。

下面是 check_haproxy.sh 的完整实现,涵盖常见的写错点。

#!/usr/bin/env bash
# /etc/keepalived/scripts/check_haproxy.sh
# 用途:检查本机 HAProxy 是否存活且能接受连接
# 依赖:curl(HTTP 方式)、pgrep(进程方式)
# 退出码:0=健康 1=不健康 2=无法判断(不改状态)

set -uo pipefail
# 说明:这里刻意不加 -e。-e 会在 pgrep 返回 1(没找到进程,正是我们要判断的情况)
#       时直接退出整个脚本,导致脚本提前结束、退出码语义混乱。

readonly LOG_TAG="chk_haproxy"
readonly HA_SOCK="/var/run/haproxy/admin.sock"  # HAProxy 2.x 的默认 stats socket
readonly HEALTH_URL="http://127.0.0.1:8404/healthz"

log() {
    # 只输出一行摘要,避免刷日志。优先级用 syslog 风格前缀便于 grep
    printf '%s %s\n' "$LOG_TAG" "$*"
}

# ---------- 检查 1:进程是否存在 ----------
if ! pgrep -x haproxy >/dev/null 2>&1; then
    log "FAIL: haproxy process not found"
    exit 1
fi

# ---------- 检查 2:stats socket 是否可查(最可靠,能拿到真实状态)----------
if command -v socat >/dev/null 2>&1 && [[ -S "$HA_SOCK" ]]; then
    # 用 echo 管道到 socat,避免在脚本里处理交互式输入
    ha_state="$(printf 'show stat\n' | timeout 2 socat - "UNIX-CONNECT:${HA_SOCK}" 2>/dev/null \
                | awk -F, '$1=="backend" && $2=="bk_app" {print $18; exit}')"
    # 第 18 列是 status 字段;字段位置随 HAProxy 版本可能变化,先验证再上线:
    #   printf 'show stat\n' | socat - UNIX-CONNECT:$HA_SOCK | head -2
    case "${ha_state}" in
        UP)      log "OK: backend bk_app UP"; exit 0 ;;
        DOWN|MAINT|NOLB)
                 log "FAIL: backend bk_app state=${ha_state}"; exit 1 ;;
        "")      log "WARN: cannot parse backend state, falling through" ;;
        *)       log "WARN: unknown backend state=${ha_state}, falling through" ;;
    esac
fi

# ---------- 检查 3:HTTP 端点兜底 ----------
if command -v curl >/dev/null 2>&1; then
    # -s 静默  -f HTTP 非 2xx 返回错误  -m 超时  -o /dev/null 丢弃 body
    if curl -sfm 2 -o /dev/null "$HEALTH_URL"; then
        log "OK: ${HEALTH_URL} reachable"
        exit 0
    fi
    log "FAIL: ${HEALTH_URL} unreachable"
    exit 1
fi

# 走到这里说明既没有 socat 也没有 curl —— 属于环境缺陷,不是服务故障
log "WARN: neither socat nor curl available, cannot verify"
exit 2

这段脚本里每一个设计选择都值得说明:

  • set -uo pipefail 而不加 -e:加了 -e 会让 pgrep 的"未找到"直接把脚本干掉,退出码变成 1 但日志里什么都不会打,排查时看不到原因。这是 H8 级别的高频错误。
  • timeout 2:所有外部命令都要限时。Keepalived 的 timeout 参数超时后会 SIGKILL 整个脚本,但 SIGKILL 不会给你清理机会;自己加 timeout 能得到更干净的行为和更好的日志。
  • 退出码 2 的用法:环境缺依赖时返回 2 而不是 1,避免因为"脚本跑不起来"而误触发 VIP 漂移。这是防止"监控系统自身故障导致业务切换"的关键设计。
  • 日志一行:每 2 秒一条,一天 43200 条。多行输出会让 journald 迅速膨胀。
  • command -v 而不是 which:which 在某些最小化系统上不存在,而且它对别名和函数的处理不可靠。

check_backend.sh(检查后端可达性,注意它只应该影响 VIP 的策略要慎重):

#!/usr/bin/env bash
# /etc/keepalived/scripts/check_backend.sh
# 用途:采样检查后端 real server 的可用比例
# 注意:本脚本用于"后端大面积不可达时让本机退出",
#       不要用它判断单个后端 —— 单个后端故障应该由 LVS real_server 检查处理,
#       否则会造成 VIP 在两点间来回漂移(见 6.4)
# 退出码:0=健康 1=不健康 2=无法判断

set -uo pipefail

readonly BACKENDS=("10.x.x.21" "10.x.x.22" "10.x.x.23")
readonly PORT=80
readonly MIN_HEALTHY=2      # 至少 2 个后端健康才算本机健康

healthy=0
for ip in "${BACKENDS[@]}"; do
    # /dev/tcp 需要 bash(不是 sh)。用 -w 指定连接超时
    if timeout 2 bash -c "echo > /dev/tcp/${ip}/${PORT}" 2>/dev/null; then
        healthy=$((healthy + 1))
    fi
done

printf 'chk_backend %d/%d healthy\n' "$healthy" "${#BACKENDS[@]}"

if (( healthy >= MIN_HEALTHY )); then
    exit 0
fi
exit 1

脚本权限与安全(Keepalived 2.x 有专门的安全机制,配错会让脚本静默不执行):

# 1. 属主和权限:必须 root 拥有,且不能对其他用户可写
sudo install -o root -g root -m 0755 /dev/null /etc/keepalived/scripts/check_haproxy.sh
sudo chown root:root /etc/keepalived/scripts/check_haproxy.sh
sudo chmod 0755 /etc/keepalived/scripts/check_haproxy.sh
# 为什么要这么严:Keepalived 以 root 执行这些脚本。
# 如果脚本对其他用户可写,等于给了任何本地用户一个 root 提权通道。

# 2. 校验 Keepalived 是否接受了脚本(2.x 会检查属主/权限/路径)
sudo journalctl -u keepalived -n 50 | grep -i -E 'script|security'
# 异常表现:出现 "Cannot find script" 或
#          "Script is not secure (owned by ...)" / "path is not secure"
# 判断依据:2.x 开启 enable_script_security 后,
#          脚本所在目录链上的每一级都不能是全局可写的(/tmp 下的脚本必被拒)
# 3. 手工验证脚本的三种退出码都符合预期(配完必做)
sudo -u root /etc/keepalived/scripts/check_haproxy.sh ; echo "healthy-case exit=$?"
# 预期:exit=0,且输出一行 "chk_haproxy OK: ..."

sudo systemctl stop haproxy
sudo -u root /etc/keepalived/scripts/check_haproxy.sh ; echo "failed-case exit=$?"
# 预期:exit=1,且输出一行 "chk_haproxy FAIL: ..."
sudo systemctl start haproxy

风险提示:在生产环境上手工停 haproxy 验证脚本会真实影响业务。正确做法是在变更窗口内、且先确认对端能正常接管;或者在一个与生产隔离的演练环境上验证脚本逻辑。如果是主备架构,可以只在一个节点上停服务,切换后立即恢复并确认状态。

8.5 通知脚本与观测点

notify_* 脚本是排查时最有价值的观测点,但也很容易被写坏。几个硬要求:

#!/usr/bin/env bash
# /etc/keepalived/scripts/notify.sh
# 参数:$1 = 状态(master|backup|fault)
# Keepalived 会按 type 传不同数量的参数,不要假设参数个数固定
set -uo pipefail

readonly STATE="${1:-unknown}"
readonly TS="$(date -Is)"
readonly LOGFILE="/var/log/keepalived/notify.log"

# 关键:目录必须先存在,否则重定向失败、通知静默丢弃
[[ -d "$(dirname "$LOGFILE")" ]] || mkdir -p "$(dirname "$LOGFILE")"

# Keepalived 传进来的完整参数全部记录下来,便于事后还原现场
{
    printf '%s state=%s args=%s\n' "$TS" "$STATE" "$*"
} >> "$LOGFILE"

# 【重要】通知脚本必须快速返回。默认 timeout 是 60 秒(可配),
# 但通知脚本里面做重活会推迟状态迁移,造成"日志有记录但 VIP 迟迟不到位"。
# 所以:只做记录和异步投递,不要在里面 curl 外部接口、不要做健康检查。
exit 0

三个必须注意的点:

  1. $LOGFILE 所在目录要先存在。 未创建目录时 shell 重定向失败,脚本可能返回非 0,而这个失败不会让 Keepalived 报警,只会让通知静默消失。这是"演练时发现通知没发出来"的常见原因。
  2. 不要在通知脚本里发 HTTP 请求到外部系统。 网络抖动会让脚本阻塞,进而推迟状态迁移。要投递指标就用 Prometheus 的 textfile collector,让抓取方来拉。
  3. 通知脚本的 timeout:2.x 里可以通过 script_timeout 或按脚本设置控制。默认值较大(60s),在需要快速迁移的场景要显式调小。

九、日志与指标:让状态可见

Keepalived 的日志是排查的第一手材料,但它默认的日志量和信息量都不理想:要么太吵,要么正好缺了你需要的那一行。

9.1 打开详细日志

默认情况下 Keepalived 以 -D 才输出详细日志,而系统服务单元文件通常不带这个选项。查看当前实际用的启动参数:

# 查看服务单元里实际传了什么参数
systemctl cat keepalived | grep -E 'ExecStart|ExecReload'
# 预期输出形如:
#   ExecStart=/usr/sbin/keepalived $KEEPALIVED_OPTIONS
#   ExecReload=/bin/kill -HUP $MAINPID
# 注意:很多发行版通过 /etc/sysconfig/keepalived 或 /etc/default/keepalived 传参
cat /etc/sysconfig/keepalived 2>/dev/null || cat /etc/default/keepalived 2>/dev/null
# 预期:KEEPALIVED_OPTIONS="-D"  或含 -D

开启详细日志的方法(用覆盖文件,不要直接改发行版自带的单元文件,升级会被覆盖):

sudo systemctl edit keepalived
# 在打开的编辑器里写入(注意 systemd 的覆盖语法需要 [Service] 段):

# [Service]
# Environment="KEEPALIVED_OPTIONS=-D -l"
# 说明:-D 详细日志  -l 日志同时输出到 syslog 的 LOG_LOCAL0(不同发行版配置不同)
sudo systemctl daemon-reload
sudo systemctl restart keepalived

风险提示:restart Keepalived 会触发一次真实的状态迁移——进程退出时如果配了 notify_*,会执行 fault 通知;重新启动后实例从 BACKUP 开始重新选举。在双主架构下,这通常意味着 VIP 会在两点间搬一次。必须在变更窗口执行,并提前确认对端健康。 更温和的替代方案是用 reload:

# reload 不中断 VRRP 实例,只重新读取配置(部分配置项 reload 不生效,见 10.3)
sudo systemctl reload keepalived
# 或者直接发 SIGHUP
sudo kill -HUP "$(cat /run/keepalived.pid)"

9.2 日志里必须能读出的四类事件

Keepalived 的日志里有四类关键事件。用 journalctl 配合 grep 提取,比翻全文高效得多:

# 1. 状态迁移(最重要)
sudo journalctl -u keepalived --since "2 hours ago" | grep -E 'Entering (MASTER|BACKUP|FAULT) STATE'
# 预期输出示例:
#   VRRP_Instance(VI_100) Entering MASTER STATE
#   VRRP_Instance(VI_101) Entering BACKUP STATE
# 判断依据:
#   - 短期内在同一实例上反复出现 MASTER/BACKUP 交替 → 抖动,见故障案例 B
#   - 一直只有 BACKUP 没有任何 MASTER → 从来没抢到过主,看优先级和报文

# 2. 优先级变化(健康检查是否真的生效)
sudo journalctl -u keepalived --since "2 hours ago" | grep -E 'effective priority|priority'
# 预期输出示例:
#   VRRP_Instance(VI_100) Changing effective priority from 150 to 120
# 判断依据:
#   - 完全没有这一行,但脚本失败日志有 → 权重方向配反了
#   - 变化后仍然高于对端 → 权重不够大,不会触发切换

# 3. 健康检查结果
sudo journalctl -u keepalived --since "2 hours ago" | grep -E 'VRRP_Script'
# 预期输出示例:
#   VRRP_Script(chk_haproxy) succeeded
#   VRRP_Script(chk_backend) failed
# 判断依据:如果连 "VRRP_Script" 都搜不到,说明脚本根本没被调度,
#          去查 script 段是否被解析(--config-test)和权限是否被拒

# 4. VRRP 报文相关(收包、丢弃、校验失败)
sudo journalctl -u keepalived --since "2 hours ago" | grep -iE 'VRRP_Packet|packet|checksum|digest|Received'
# 预期/异常:
#   "VRRP_Instance(VI_100) Received advert with higher priority" → 正常竞争
#   "VRRP_Instance(VI_100) Dropping received VRRP packet" → 报文被丢弃,看原因
#   "invalid checksum" / "invalid TTL" → 报文被中间设备改写或跨了三层

按时间对齐两个节点的日志,这是排查 VRRP 问题的关键动作,因为 VRRP 是两方协议:

# 在两个节点上分别导出同一时间窗的日志,然后并排比较
# 前提:两节点时间同步(timedatectl 确认过)
ssh lb1 "sudo journalctl -u keepalived --since '2026-04-02 03:00' --until '2026-04-02 03:10' -o short-iso" > /tmp/lb1.log
ssh lb2 "sudo journalctl -u keepalived --since '2026-04-02 03:00' --until '2026-04-02 03:10' -o short-iso" > /tmp/lb2.log
# -o short-iso 让时间戳是 ISO 格式,便于排序和 diff
# 把两个文件按时间戳合并排序,人工查看事件交错
sort -k1,2 /tmp/lb1.log /tmp/lb2.log | grep -E 'Entering|priority|VRRP_Script' | head -40

这一步能直接暴露"两方对同一件事的描述不一致" ——例如 lb1 说自己进了 FAULT,lb2 却说收到了 lb1 的通告。那说明有第三方在冒充 lb1 发通告(见故障案例 B 的根因)。

9.3 指标采集

Keepalived 本身可以从多个维度暴露状态。三种方式,按可靠性排序:

方式一(推荐):从通知脚本写 textfile,由 node_exporter 暴露。 这是最可靠的,因为它是"事件驱动",不依赖状态查询的采样时机。

#!/usr/bin/env bash
# /etc/keepalived/scripts/notify.sh  (指标部分)
# 追加到 8.5 的 notify.sh 里
set -uo pipefail

readonly STATE="${1:-unknown}"
readonly TEXTFILE="/var/lib/node_exporter/textfile/keepalived_state.prom"
readonly TMPFILE="$(mktemp "${TEXTFILE}.XXXXXX")"
# 说明:先写临时文件再原子 mv,避免 node_exporter 读到写了一半的文件
# 注意 tmp 文件必须和目标在同一文件系统,否则 mv 不是原子的

# 状态映射成数值,便于告警表达式直接比较
case "$STATE" in
    master)  code=2 ;;
    backup)  code=1 ;;
    fault)   code=0 ;;
    *)       code=-1 ;;
esac

{
    printf '# HELP keepalived_vrrp_state VRRP instance state (0=fault 1=backup 2=master -1=unknown)\n'
    printf '# TYPE keepalived_vrrp_state gauge\n'
    printf 'keepalived_vrrp_state{instance="VI_100"} %s\n' "$code"
    printf 'keepalived_vrrp_state{instance="VI_101"} %s\n' "$code"
} > "$TMPFILE"

# 权限:node_exporter 以 node_exporter 用户运行,要能读到
chmod 0644 "$TMPFILE"
mv -f "$TMPFILE" "$TEXTFILE"
exit 0

注意这段是简化示意:真实环境里 notify_master 只会在 VI_100 变 master 时调用,无法同时写出 VI_101 的状态,所以指标会在两个实例之间互相覆盖。正确做法有两种:

  • 给每个实例配独立的通知脚本(VI_100 用 notify_master_100),各自只写自己的指标。
  • 或者不用事件驱动,改用周期性采集(方式二),避免状态互相覆盖。

方式二:用检查脚本周期性写指标。 直接让一个 interval 10 的脚本查询当前 VIP 归属并写指标:

#!/usr/bin/env bash
# /etc/keepalived/scripts/export_state.sh
# 不由 VRRP 的 notify 驱动,而是独立 timer 或 interval 调度
# 采集当前机器上实际持有的 VIP,这是"结果导向"的指标,比状态更可信
set -uo pipefail

TEXTFILE="/var/lib/node_exporter/textfile/keepalived_vip.prom"
TMPFILE="$(mktemp "${TEXTFILE}.XXXXXX")"

{
    printf '# HELP keepalived_vip_held Whether this node currently holds the VIP\n'
    printf '# TYPE keepalived_vip_held gauge\n'
    for vip in 10.x.x.100 10.x.x.101; do
        if ip -o -4 addr show | grep -qw "$vip"; then
            printf 'keepalived_vip_held{vip="%s"} 1\n' "$vip"
        else
            printf 'keepalived_vip_held{vip="%s"} 0\n' "$vip"
        fi
    done
} > "$TMPFILE"
chmod 0644 "$TMPFILE"
mv -f "$TMPFILE" "$TEXTFILE"

这个指标比状态指标更有价值,原因是它反映事实(网卡上到底有没有这个 IP),而不是 Keepalived 的自我认知。脑裂时两个节点都会报 "held=1",告警立刻能抓到;而状态指标在脑裂时两边都说自己是 MASTER,单看一台机器的指标看不出异常。

node_exporter 启动时必须带 textfile collector 并指向该目录:

# 检查 node_exporter 是否已启用 textfile collector
ps aux | grep node_exporter | tr ',' '\n' | grep -E 'textfile|collector'
# 预期:能看到 --collector.textfile.directory=/var/lib/node_exporter/textfile
# 异常:没有该参数 → 指标不会被暴露,需要修改启动参数

# 验证指标确实暴露了
curl -s http://127.0.0.1:9100/metrics | grep keepalived_
# 预期:keepalived_vip_held{vip="10.x.x.100"} 1
#       keepalived_vip_held{vip="10.x.x.101"} 0
# 异常:无输出 → 目录权限不对,或 node_exporter 未配置 textfile collector
ls -la /var/lib/node_exporter/textfile/
# 判断依据:文件属主/权限必须让 node_exporter 用户可读

方式三:直接抓 ip addr 用 blackbox 或自写 exporter。 灵活性最高,但需要额外维护。仅在方式一、二不能满足时使用。

9.4 告警规则

下面这些规则直接对应本文讨论的故障类型。阈值是起点,必须结合你的业务基线和切换实测耗时调整:

# /etc/prometheus/rules/keepalived.yml
groups:
- name: keepalived
  rules:
  # ---- 脑裂:最高优先级告警 ----
  # 同一 VIP 被多个节点持有。用 sum by (vip) 判断,出现 2 或以上即为脑裂
  - alert: KeepalivedSplitBrain
    expr: sum by (vip) (keepalived_vip_held) > 1
    for: 10s
    labels:
      severity: critical
    annotations:
      summary: "疑似脑裂:VIP {{ $labels.vip }} 被多个节点同时持有"
      runbook: "立即执行:两端 ip -br addr 确认;抓包看是否双向 VRRP 通告;检查是否有多余节点使用同一 VRID"
  # for: 10s 而不是 5m。脑裂影响面大且每一秒都在产生异常连接,
  # 宁可偶发误报也要快速发现。误报通常来自采集抖动,可配合后续规则确认。

  # ---- VIP 完全无人持有 ----
  # 所有节点都不持有该 VIP 才是异常。注意要有至少一个节点有人持有
  - alert: KeepalivedVipMissing
    expr: |
          (sum by (vip) (keepalived_vip_held) == 0)
          and on(vip) (count by (vip) (up{job="node"} == 1) >= 2)
    for: 30s
    labels:
      severity: critical
    annotations:
      summary: "VIP {{ $labels.vip }} 没有任何节点持有"
      runbook: "查两端 journalctl -u keepalived | grep -E 'FAULT|priority';常见原因是两端都进了 FAULT"

  # ---- 状态抖动 ----
  # 10 分钟内状态变化超过 5 次视为抖动。keepalived_vrrp_state 需要是事件累加的,
  # 用 changes() 统计序列变化次数(要求指标是周期性采集的,不能用事件覆盖式)
  - alert: KeepalivedFlapping
    expr: changes(keepalived_vrrp_state[10m]) > 5
    for: 0m
    labels:
      severity: warning
    annotations:
      summary: "VRRP 实例 {{ $labels.instance }} 在 10 分钟内切换超过 5 次"
      runbook: "查健康检查是否在临界点抖动(fall/rise 配得太小);查网络是否丢包"

  # ---- 长时间处于 BACKUP ----
  # 如果某个实例应该持主却长期 BACKUP,说明选举结果不符合设计
  - alert: KeepalivedStuckBackup
    expr: max_over_time(keepalived_vrrp_state{instance="VI_100"}[30m]) == 1
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "VI_100 已持续 30 分钟处于 BACKUP,与预期不符"
      runbook: "确认是否符合 nopreempt 的预期;检查优先级算术和 track_script"

  # ---- 健康检查失败 ----
  - alert: KeepalivedScriptFailed
    expr: keepalived_script_ok == 0
    for: 3m
    labels:
      severity: warning
    annotations:
      summary: "健康检查脚本 {{ $labels.script }} 持续失败"
      runbook: "手工执行脚本确认退出码;确认 weight 方向;确认 VIP 是否已按预期迁移"

  # ---- 处于 FAULT ----
  - alert: KeepalivedInFault
    expr: keepalived_vrrp_state == 0
    for: 1m
    labels:
      severity: critical
    annotations:
      summary: "VRRP 实例 {{ $labels.instance }} 处于 FAULT 状态"
      runbook: "FAULT 表示实例主动放弃服务。查 track_interface 的网卡是否 down、track_script 是否返回 1"

关于 keepalived_script_ok 指标:它不是 Keepalived 原生暴露的,需要你在健康检查脚本里顺手写 textfile(和 export_state.sh 同样的做法)。如果你不采集这个指标,KeepalivedScriptFailed 这条规则会被 no data 状态困住——Prometheus 的 no data 不等于健康,这条规则的实际覆盖要靠 absent() 补充,或者干脆依赖 KeepalivedStuckBackup 来兜底。

9.5 用 ip / arp 命令做现场确认

指标和日志会滞后或撒谎,ip 命令不会。任何怀疑时刻都应该直接确认现场:

# 当前持有哪些 VIP —— 这是唯一可信的事实来源
ip -o -4 addr show | awk '{print $2, $4, $5}'
# 输出示例:eth0 10.x.x.11/24 brd ...  和  eth0 10.x.x.100/24 ...
# 判断依据:VIP 是否真的在任何网卡的地址列表里。没有就是没有,不要相信日志

# 确认 VIP 的网卡与 scope
ip addr show dev eth0 | grep -A2 '10.x.x.100'
# 预期:inet 10.x.x.100/24 scope global secondary eth0
# 异常:scope host → 配成了 /32 且没有 dev 声明,ARP 不会应答,上游设备学不到 MAC

# 查看本机 ARP / 邻居表(确认能学到对端)
ip neigh show dev eth0 | grep -E '10.x.x.1[12]'
# 预期:10.x.x.12 dev eth0 lladdr xx:xx:xx:xx:xx:xx REACHABLE
# 异常:FAILED / INCOMPLETE → 二层不通,VRRP 就不可能正常

# 确认 VIP 对应的 MAC 是否为本机期望的网卡
ip link show eth0 | grep link/ether
# 预期:切主后,上游交换机上 VIP 应指向这个 MAC(需要在交换机侧确认)
# 检查内核是否还有残留的 VIP(进程被 kill -9 时可能出现)
ip addr show | grep -c '10.x.x.100'
# 预期:0 或 1(取决于本机是否持有)
# 异常:某个节点上有 VIP 但 Keepalived 进程已挂 → 残留地址,
#       会导致下次启动时出现 "IP address already exists" 并可能双持

# 残留处理【高风险操作,必须先确认没有其他节点持有同一 VIP】
# 影响范围:删除本机网卡上的 VIP,依赖该 VIP 的本机连接会立刻中断
# 前置检查:确认对端确实持有该 VIP(否则会造成服务完全不可用)
sudo ip addr del 10.x.x.100/24 dev eth0
# 验证:ip -br addr show eth0 | grep 10.x.x.100 → 无输出
# 回滚:sudo ip addr add 10.x.x.100/24 dev eth0  (需要 root,立即生效)

十、故障排查实战:五个闭环案例

下面每个案例都按同一套闭环走:现象 → 初判 → 检查 → 指标 → 根因 → 修复 → 验证 → 回滚预案 → 复盘。案例来自真实工单,细节做了脱敏。

10.1 案例一:VIP 不漂移(健康检查被静默禁用)

现象:lb1 上的 HAProxy 进程崩溃,被 systemd 反复拉起又崩溃。lb2 一直处于 BACKUP,VIP 10.x.x.100 始终在 lb1 上。业务通过 lb1 全部失败,中断约 40 分钟直至人工介入。lb1 的 Keepalived 日志里没有任何健康检查失败的记录,只有启动时的一行 Entering MASTER STATE。

初判:VIP 不漂移有两种可能:健康检查没触发,或者触发了但选举没完成。既然日志里连"检查失败"都没有,先查第一类。

检查过程(按四层顺序走):

# ---- 第 1 层:状态机与配置 ----
sudo journalctl -u keepalived --since "2 hours ago" | grep -E 'Entering|VRRP_Script|priority'

输出只有三行,且都是很久以前的:

VRRP_Instance(VI_100) Entering MASTER STATE
VRRP_Script(chk_haproxy) succeeded

关键判断:chk_haproxy 的最后一条记录是 succeeded,此后再无任何输出。但脚本的 interval 是 2 秒,正常情况下日志里应该有大量 succeeded 记录(Keepalived 每次执行都会记一行)。沉默本身就是异常信号。

# 既然怀疑脚本没有被调度,直接搜启动阶段的脚本相关日志
sudo journalctl -u keepalived -b | grep -iE 'script|permission|security|cannot|disabl'

这一步直接给出了根因:

Keepalived_vrrp[4821]: VRRP_Script(chk_haproxy) cannot be executed: Permission denied
Keepalived_vrrp[4821]: VRRP_Script(chk_haproxy) removing script from instance

判断依据:Keepalived 在启动阶段尝试加载脚本时发现它不可执行,于是把这个脚本从实例上移除,并且在之后的整个生命周期里不再尝试执行它。日志只打这一次,所以定期查看日志的人会完全错过。

# 确认脚本权限,找到为什么不可执行
sudo ls -la /etc/keepalived/scripts/
-rw-r--r--. 1 root root 1284 Apr  1 03:12 check_haproxy.sh    ← 缺少执行位
-rwxr-xr-x. 1 root root  978 Apr  1 03:12 check_backend.sh

根因:check_haproxy.sh 没有执行位(0644 而不是 0755)。追溯变更记录发现,这台机器在前一周做过一次从备份恢复的操作,恢复脚本用的是 tar -xzf 且没有带 -p 参数(--preserve-permissions),导致所有文件的权限位被重置为 umask 的结果。check_backend.sh 因为恢复后有人手工 chmod 过而幸免。

根因成立的关键证据链(缺一条都不能定论):

证据 命令 本次取值
脚本在启动时被拒绝 journalctl -u keepalived -b | grep -i script cannot be executed: Permission denied
脚本文件确实没有执行位 ls -la /etc/keepalived/scripts/ -rw-r--r--
检查确实没被调度 日志中 VRRP_Script(chk_haproxy) 记录的时间间隔 最后一条在两天前,之后全无
状态机的行为与"检查未生效"一致 lb1 优先级日志 始终 150,从未出现过 Changing effective priority

修复:

# 第一步:恢复执行位
sudo chown root:root /etc/keepalived/scripts/check_haproxy.sh
sudo chmod 0755 /etc/keepalived/scripts/check_haproxy.sh

# 第二步:手工验证脚本行为符合预期(三种退出码都要试)
sudo -u root /etc/keepalived/scripts/check_haproxy.sh ; echo "healthy exit=$?"
# 预期:exit=0 且输出一行 OK

一个必须说清楚的行为差异:修改文件权限不会让 Keepalived 重新加载脚本。原因就是上面的日志——脚本在启动阶段已被"从实例上移除"。所以仅仅 chmod 是不够的,必须让 Keepalived 重新读取配置:

# 先校验配置,再重启
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf ; echo "config-test exit=$?"
# 预期:exit=0

# 【高风险操作】重启会触发一次真实的状态迁移
# 前置检查:
#   1) 确认 lb2 健康且能接管
ssh lb2 "systemctl is-active keepalived haproxy && ip -br addr show eth0 | grep -c 10.x.x.100"
#   2) 备份当前配置与脚本目录
sudo cp -a /etc/keepalived/keepalived.conf /backup/keepalived/keepalived.conf.$(date +%Y%m%d-%H%M)
sudo tar czf /backup/keepalived/scripts.$(date +%Y%m%d-%H%M).tar.gz -C /etc/keepalived scripts
# 影响范围:lb1 持有的 10.x.x.100 会短暂中断并[漂移到 lb2](https://yunpan.plus/t/1916-1-1)
# 执行窗口:业务低峰期。权限:root 或受限 sudo
sudo systemctl restart keepalived

# 确认脚本这次真的被加载了
sudo journalctl -u keepalived -b --since "1 min ago" | grep -iE 'VRRP_Script|script'
# 预期:不再有 "cannot be executed";随后每 2 秒出现一条 VRRP_Script(chk_haproxy) succeeded

验证(这一步必须做,否则不能确认修复有效):

# 1. 确认脚本在被周期调度(等 10 秒看是否有多条记录)
sleep 10
sudo journalctl -u keepalived --since "1 min ago" | grep -c 'VRRP_Script(chk_haproxy)'
# 预期:≥ 4(interval 2 秒,10 秒内约 5 次)
# 异常:0 → 脚本仍未被调度,回去看启动日志

# 2. 演练真实切换
sudo systemctl stop haproxy
sleep 10
sudo journalctl -u keepalived --since "1 min ago" | grep -E 'effective priority|Entering'
# 预期:
#   VRRP_Script(chk_haproxy) failed
#   VRRP_Instance(VI_100) Changing effective priority from 150 to 90
#   VRRP_Instance(VI_100) Entering BACKUP STATE
ip -br addr show eth0 | grep 10.x.x.100 ; echo "lb1-exit=$?"
# 预期:lb1-exit=1(VIP 已离开 lb1)
ssh lb2 "ip -br addr show eth0 | grep 10.x.x.100"
# 预期:lb2 上持有 VIP

# 3. 流量侧验证(不要只验证 VIP,要验证跨网段真的通)
curl -sf -o /dev/null -w '%{http_code}\n' --max-time 5 http://10.x.x.100/healthz
# 预期:200

# 4. 恢复
sudo systemctl start haproxy
# 配了 nopreempt,VIP 不会自动切回,这是预期行为

修复过程中发现的第二个缺陷(防火墙丢弃协议 112):

在验证阶段抓包时发现了另一个问题:

# 演练期间在 lb1 上抓包,本意是确认通告正常发出
sudo timeout 10 tcpdump -i eth0 -n -e vrrp

结果:只在 lb1 上抓到了 lb1 自己发的通告,而同一时刻在 lb2 上抓不到任何 VRRP 报文。这说明 lb2 从来没收到过 lb1 的通告。

# 在 lb2 上确认是否有规则丢弃 VRRP
sudo firewall-cmd --list-rich-rules | grep -i vrrp
sudo nft list ruleset 2>/dev/null | grep -i -B2 -A2 vrrp

输出显示加固脚本加入的规则:

chain input {
    type filter hook input priority filter; policy drop;
    ...
    ip protocol vrrp drop
}

为什么这个缺陷在修复前没暴露出来:lb2 收不到通告时,它的 Master Down Timer(约 3.4 秒)会超时,然后自己升主——那会表现为脑裂而不是静默。之所以没发生,是因为防火墙规则是在上次 lb1 重启之后才加的,而 lb2 在那之后的日志里已经出现过一次 Entering MASTER STATE(说明它确实误升过主),随后又被 lb1 的通告压回 BACKUP——因为 lb1 的出向通告没被拦。也就是说,这个缺陷当时已经造成过几秒钟的 VIP 双持,只是没被监控抓到。

这是本次排查里最有价值的一个发现:它印证了 9.3 节的观点——状态指标会撒谎,keepalived_vip_held 这种结果导向的指标才抓得住这类问题。

修复(防火墙部分):

# 前置检查:导出当前完整规则集作为回滚依据
sudo firewall-cmd --list-all > /backup/firewalld/all-$(date +%Y%m%d-%H%M).txt
sudo nft list ruleset > /backup/nft/ruleset-$(date +%Y%m%d-%H%M).nft
# 记录现有业务端口,reload 后逐项对比
sudo firewall-cmd --list-ports ; sudo firewall-cmd --list-services

# 在两台机器上放行 VRRP,只允许对端 IP(最小权限,不要对全网开放)
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.x.x.12/32" protocol value="vrrp" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.x.x.11/32" protocol value="vrrp" accept'
sudo firewall-cmd --reload
# 生效验证
sudo firewall-cmd --list-rich-rules

如果环境用的是裸 nftables(加固脚本直接写 nft):

# 注意 nft 的 add rule 默认追加到链尾,而链的默认策略是 drop,
# 所以必须用 insert 插到 drop 之前,或者显式指定位置
sudo nft insert rule inet filter input ip saddr 10.x.x.11 ip protocol vrrp accept
sudo nft insert rule inet filter input ip saddr 10.x.x.12 ip protocol vrrp accept
# 验证插入位置正确(必须出现在 drop 之前)
sudo nft list chain inet filter input
# 【持久化】运行时 nft 规则重启即丢,必须写进 /etc/nftables.conf
sudo nft list ruleset | sudo tee /etc/nftables.conf >/dev/null
sudo systemctl enable --now nftables

验证(防火墙部分):

# 在 lb2 上抓包,确认能收到 lb1 的通告
sudo timeout 10 tcpdump -i eth0 -n -e vrrp
# 预期:能看到 10.x.x.11 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 150

# 关键验证:确认不再出现"误升主"
sudo journalctl -u keepalived --since "10 min ago" | grep -c 'Entering MASTER STATE'
# 预期:0(稳定期间不应有任何升主动作)
# 异常:> 0 → 通告仍不完整,继续查

回滚预案:

  • 脚本权限回滚:sudo chmod 0644 /etc/keepalived/scripts/check_haproxy.sh。注意回滚会让检查重新被禁用,等于回到故障状态,所以这个回滚只应在"新权限导致脚本行为异常"时使用,而不是常规回退手段。
  • 防火墙回滚:sudo firewall-cmd --permanent --remove-rich-rule='...' && sudo firewall-cmd --reload。nftables 环境回灌备份:sudo nft -f /backup/nft/ruleset-<时间戳>.nft。
  • 执行窗口与灰度:防火墙变更先在 lb2 上做(它是备节点),确认抓包正常后再做 lb1。两台不要同时改。
  • 权限控制:防火墙与 Keepalived 变更都需要 root 或受限 sudo,走审批流程。

复盘:

  1. 两个独立缺陷叠加:检查被静默禁用(导致故障未被发现),以及 VRRP 报文被拦(导致误升主)。前者解释了"不漂移",后者解释了历史上那次未被察觉的 VIP 双持。如果只找到第一个,故障还会以另一种形态复现。
  2. 静默失效是本次事件最危险的属性:脚本不可执行只打了一行日志;加固规则拦 112 完全没有日志。两者都不会让任何监控告警。
  3. 改进项:
# 需要有日志采集(如 promtail/loki 或自写 exporter)把关键字转成指标
increase(keepalived_script_load_failure_total[10m]) > 0
  • 加固流程增加"无端口协议"清单:VRRP(112)、ICMP、GRE(47)、IPIP(4) 等没有端口的协议必须在清单里显式列出。只在加固后做一次真实切换演练,才能确认没有被拦。
  • 坚持采集 keepalived_vip_held(第 9.3 节)。故障期间它本可以立刻暴露 VIP 双持。
  • 定期做切换演练,并且演练后必须检查"是否有非预期的状态迁移",而不只是看"切换成功了吗"。本次的防火墙缺陷就是在演练的附加检查里发现的。
  • 恢复流程必须带 -p,或者用配置管理工具重新下发文件而不是裸 tar 解包。同时在恢复后加一步权限校验。
  • 加一条启动期校验:每次 Keepalived 启动后检查日志里是否有 cannot be executed / removing script,有则告警。这条规则的价值极高,因为它是唯一能在"检查静默失效"时立刻发现的机制。

10.2 案例二:双主(脑裂)——多出一个"幽灵节点"

现象:监控在凌晨 2 点连续告警:10.x.x.100 出现 IP 冲突,且 ARP 对应 MAC 每隔几秒变化一次。业务侧表现为偶发 502、部分长连接被重置。

初判:ARP 抖动 + 同 VIP 双持 = 脑裂。但要先确认是"两个节点都认为自己是主"还是"有一个不该存在的第三方"。

检查过程:

# ---- 第 1 层:两端各自的状态 ----
for h in lb1 lb2; do
    echo "===== $h ====="
    ssh "$h" "sudo journalctl -u keepalived -n 20 | grep -E 'Entering|priority'"
    ssh "$h" "ip -br addr show eth0 | grep 10.x.x.100"
done

结果:两端都显示 Entering MASTER STATE,两端都持有 10.x.x.100。脑裂成立。

# ---- 第 2 层:抓包看通告来源 ----
sudo timeout 8 tcpdump -i eth0 -n -e vrrp

输出里有三段不同的源地址:

10.x.x.11 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 150
10.x.x.12 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 100
10.x.x.13 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 150

10.x.x.13 —— 这是一台不在 Keepalived 拓扑里的机器。它也用 vrid 100 且优先级 150,与 lb1 打平(按 IP 大小,13 大于 11,所以 13 认为它应该赢)。

# 确认 10.x.x.13 是什么
ping -c 1 -W 1 10.x.x.13
nmap -sn 10.x.x.13 2>/dev/null || echo "nmap 不可用,用 arping 替代"
sudo arping -I eth0 -c 2 10.x.x.13
# 判断依据:能响应说明这台机器活着。接着要查它是谁

# 通过 ARP 表拿到 MAC,再从交换机侧定位接入端口
ip neigh show 10.x.x.13

根因:这是一台开发人员自己搭的测试机,用于验证一套负载均衡方案,直接从生产环境克隆了 keepalived.conf,没有改 virtual_router_id 也没有改 router_id。它开机自启了 Keepalived,于是成了一个"幽灵节点",用同一个 VRID 参与选举,优先级还和生产主节点打平。

日志证据:lb1 的日志里有大量这样的记录:

VRRP_Instance(VI_100) Received advert with lower priority 100
VRRP_Instance(VI_100) Received advert with higher priority 150   ← 来自幽灵节点

为什么 lb1 会频繁让位:当 lb1 收到优先级 150(与它自己相同)的通告时,按 VRRP 规则比较主 IP,10.x.x.13 > 10.x.x.11,所以 lb1 会退让;但 10.x.x.13 收到 lb1 的通告时也会做同样的比较并把 lb1 视为对等……两边都在"让位",然后发现没人接管又都升回主。这就是 ARP 抖动的来源。

修复:

# 第一步【止血】:隔离幽灵节点,不是先改配置。
# 在接入交换机侧关闭该端口(最快的止血手段),或者在幽灵节点上停掉 keepalived
# ---- 在 10.x.x.13 上(如果能拿到它的登录权限)----
sudo systemctl stop keepalived
sudo systemctl disable keepalived
# 影响范围:仅该测试机,无业务影响
# 如果拿不到权限 → 在交换机上 shutdown 对应端口,联系安全团队处理

# 第二步:确认生产两端恢复稳定
ssh lb1 "sudo journalctl -u keepalived -f" | grep -E 'Entering|higher priority'
# 预期:幽灵节点消失后,不再出现 "Received advert with higher priority"

验证:

# 1. 通告源只有两个
sudo timeout 8 tcpdump -i eth0 -n vrrp | awk '{print $3}' | sort -u
# 预期:只有 10.x.x.11 和 10.x.x.12

# 2. VIP 单持
for h in lb1 lb2; do echo "$h: $(ssh $h "ip -br addr show eth0 | grep -c 10.x.x.100")"; done
# 预期:一个为 1,另一个为 0(合计为 1)

回滚预案:本案例的修复是"停止一个不该运行的进程",没有配置回滚问题。但隔离动作本身有风险——如果误判,停掉的是某个真正需要的节点,会造成 VIP 完全无人持有。所以隔离前必须完成"确认它不在拓扑清单里"这一步(查 CMDB、查机器归属),不能只凭 IP 不认识就停。

复盘:

  1. 根因是环境隔离机制缺失。测试机与生产机在同一二层域,且能直接访问生产网络的 VRRP 组播地址。
  2. 三个改进项:
# 需要自定义 exporter 或 textfile 上报"通告源数量"
# 期望值为拓扑中的节点数(本例为 2)
keepalived_advert_source_count != 2
  • 测试环境网络隔离:开发测试机不能与生产机同广播域。这是最根本的手段。
  • virtual_router_id 分配清单:把 VRID 和 router_id 纳入 CMDB 统一分配,避免冲突;并在配置管理里加校验(router_id 必须唯一)。
  • 加一条 VRRP 拓扑完整性告警:定期抓包统计通告源 IP,出现不在白名单里的源就告警。这条规则能提前发现所有"幽灵节点"。
  1. 不要靠 priority 拉开差距来防脑裂。把优先级改成 200 只解决了与幽灵节点的竞争,解决不了 VRID 冲突本身。VRID 冲突会导致更严重的问题:两个不同业务的 VIP 用同一 VRID 时,认证密码相同就会互相干扰,这是很多"莫名其妙的切换"的隐藏原因。

10.3 案例三:配置改了但没生效(reload vs restart)

现象:运维把 chk_haproxy 的 weight 从 -30 改成 -60,执行 systemctl reload keepalived,观察日志确认 reload 成功。之后做故障演练,停掉 haproxy,但 VIP 依然不漂移。优先级日志显示:

VRRP_Instance(VI_100) Changing effective priority from 150 to 120

优先级只降了 30,不是 60 —— 配置没生效。

初判:reload 是否真的重新读取了 vrrp_script 的 weight?这需要看 Keepalived 对 SIGHUP 的处理逻辑。

检查过程:

# 1. 确认磁盘上的配置确实是新值
sudo grep -A6 'vrrp_script chk_haproxy' /etc/keepalived/keepalived.conf
# 预期:weight -60
# 如果这里是 -30 → 改的是另一个文件(多实例部署时常见),或者有 include 覆盖

# 2. 确认配置文件语法正确
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf ; echo "exit=$?"
# 预期:exit=0

# 3. 确认进程实际加载的是哪份配置
sudo cat /proc/"$(pgrep -o keepalived)"/cmdline | tr '\0' ' ' ; echo
# 预期:能看到 -f /etc/keepalived/keepalived.conf
# 异常:fi 指向另一路径(例如 /etc/keepalived/keepalived.conf.d/ 下的合并文件)

# 4. 关键:确认 reload 是否重新读取了脚本定义
sudo journalctl -u keepalived | grep -iE 'reload|Reloading|SIGHUP|re-reading'
# 输出示例:
#   Reloading Keepalived configuration file...
# 注意:有这一行不代表所有配置项都被重新应用

根因:Keepalived 收到 SIGHUP 后会重新解析配置文件,但部分配置项只在启动时生效,不会在 reload 时重新应用。vrrp_script 的 weight 就是其中之一(它影响的是实例的跟踪结构,属于初始化时建立的绑定关系)。

判断依据(可复现的验证方法):对比 reload 前后进程的启动时间与配置文件修改时间:

# 配置文件修改时间
stat -c '%y %n' /etc/keepalived/keepalived.conf
# 进程启动时间(注意 MainPID 才是主进程)
systemctl show keepalived -p MainPID -p ExecMainStartTimestamp
# 判断依据:如果配置修改时间晚于进程启动时间,而行为仍是旧值 →
#           reload 没有应用这项配置,必须 restart

修复:

# 必须用 restart,不能用 reload
# 前置检查(重要):
#   1. 确认对端健康(restart 会触发本机退出选举,VIP 会漂到对端)
ssh lb2 "systemctl is-active keepalived haproxy && ip -br addr show eth0 | grep -E '10.x.x.10[01]'"
#   2. 确认现在是业务低峰
#   3. 备份当前配置(回滚依据)
sudo cp -a /etc/keepalived/keepalived.conf /backup/keepalived/keepalived.conf.$(date +%Y%m%d-%H%M)

# 执行(在变更窗口内,一端一端做,不要同时重启两端)
sudo systemctl restart keepalived
sudo journalctl -u keepalived -f

# 预期日志:
#   VRRP_Instance(VI_100) Entering BACKUP STATE   (重启后先从 BACKUP 起)
#   VRRP_Instance(VI_100) Entering MASTER STATE   (如果本机优先级更高)

风险提示:restart 的影响范围是本机持有的全部 VIP 都会短暂中断并漂移到对端。在双主架构下,两台机器各持一个 VIP,重启其中一台会让它持有的那个 VIP 漂到对端(对端变成双持),此时对端承担全部流量。所以必须确认对端有足够的容量承接,否则会造成对端过载。这是"高可用切换反而引发雪崩"的典型场景。

验证:

# 演练一次,确认新权重生效
sudo systemctl stop haproxy
sleep 6
sudo journalctl -u keepalived -n 30 | grep -E 'effective priority'
# 预期:Changing effective priority from 150 to 90    (150-60=90)
ip -br addr show eth0 | grep 10.x.x.100 ; echo "exit=$?"
# 预期:exit=1,VIP 已漂移
sudo systemctl start haproxy

回滚预案:

  • 配置回滚:把备份文件拷回并 restart。注意回滚同样需要 restart,同样会触发一次漂移。
  • 如果重启后 Keepalived 起不来(配置语法错误),进程会退出,VIP 完全消失,服务中断。所以配置校验必须在 restart 之前做:keepalived --config-test。
  • 更安全的做法:用两端滚动的方式,先重启备端(它只持有次要 VIP),确认正常后再重启主端。

复盘:

  1. "reload 能应用所有配置"是一个广泛存在的错误认知。 明确的规则是:涉及 vrrp_script 定义、virtual_router_id、interface、track_* 结构的变更都必须 restart;只改变通知脚本内容和日志级别等外围项时 reload 足够。
  2. 改进项:
  • 在配置管理工具里把"哪些变更需要 restart"写成显式规则,而不是让运维凭经验判断。
  • 每次变更后强制执行一次演练验证,并把演练结果记入变更单。本案例正因为做了演练才发现问题,如果没有演练,配置错误的后果会在真实故障中才暴露。
  • 把 --config-test 作为 CI 流水线的一部分,配置变更提交时自动校验语法。

10.4 案例四:漂移成功但流量不通(上游 ARP 未更新)

现象:规划内的主备切换演练。lb1 主动让位,lb2 顺利接管。两端 ip addr 都正确,lb2 上的日志明确打印 Entering MASTER STATE,GARP 也发出去了。但业务侧监控显示请求成功率从 99.9% 掉到 60%,持续约 3 分钟后自行恢复。

初判:既然 VIP 迁移和日志都正常,且能自行恢复,指向上游网络设备的 ARP 表更新延迟。但需要证据,不能凭猜测下结论。

检查过程:

# ---- 第 4 层:从不同位置验证 VIP 可达性 ----
# 1. 在 lb2 本机(回环和本机接口,一定通)
curl -sf -o /dev/null -w '%{http_code} %{time_total}\n' http://10.x.x.100/healthz
# 预期:200,很快。这一步只能证明本机服务正常,证明不了流量可达

# 2. 在同一网段的第三台机器上测(关键:绕开上游设备)
ssh app-host "curl -sf -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 3 http://10.x.x.100/healthz"
# 预期:如果这台机器与 lb2 同网段且能直接 ARP,应该通

# 3. 在跨网段的客户端上测(这才是业务真实路径)
ssh client-host "curl -sf -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 3 http://10.x.x.100/healthz; echo exit=$?"
# 预期:如果这里失败而第 2 步成功 → 问题在网关/上游设备的 ARP 或路由

结果:第 1 步通、第 2 步通、第 3 步超时。这组结果直接定位到上游设备。

# 4. 取证:确认 VIP 的 MAC 是否正确,以及客户端侧的 ARP 缓存
# 在失败时刻从客户端抓包(用 tcpdump 存 pcap 便于事后分析)
ssh client-host "sudo timeout 10 tcpdump -i eth0 -n host 10.x.x.100 -w /tmp/vip.pcap"
# 事后分析:看 ARP 请求的目标 MAC 是否仍是旧节点的 MAC
# 5. 从客户端侧看 ARP 缓存(Linux 客户端)
ip neigh show 10.x.x.100
# 异常表现:lladdr 是 fb1:旧的 MAC,状态 REACHABLE
# 判断依据:客户端的邻居表还指着旧节点 → 它发出的包走错地方

# 6. 关键证据:确认新节点的 MAC,和客户端缓存对比
ssh lb2 "ip link show eth0 | awk '/link\/ether/{print \$2}'"
# 预期:客户端缓存里的 MAC 应该等于这个值。不等就是 ARP 未更新

根因:lb2 升主后发送了 GARP(garp_master_delay 1),但上游三层交换机配置了 ARP 表项的老化策略和防 ARP 欺骗的端口安全(port security),对 GARP 的处理被限速。同时跨网段客户端的 ARP 缓存里是网关的 MAC(不是 VIP 的 MAC),所以客户端侧看不到问题——真正需要更新的是网关设备上的 ARP 表项,它把 VIP 映射到了旧的 MAC。

约 3 分钟后自愈,是因为网关的 ARP 老化定时器到点,重新发起 ARP 请求并学到了新 MAC。

修复与缓解(分层次,从立即生效到长期方案):

# ---- 立即缓解:增强 GARP 的发送强度和频率 ----
vrrp_instance VI_100 {
    ...
    # 升主后立刻开始发 GARP,不要延迟
    garp_master_delay 0

    # 每次发多个 GARP(不同版本默认值不同,显式写出)
    garp_master_repeat 5

    # 持主期间周期性重发,对抗上游的 ARP 限速和缓存老化
    # 语义:距上次发 GARP 超过这个秒数且期间没发过通告,就再发一次
    garp_master_refresh 10
    garp_master_refresh_repeat 2

    # 如果上游对 ARP 限速严格,可以额外让 Keepalived 用更"暴力"的方式
    # 注意:某些交换机会把这种模式识别为攻击并端口隔离,启用前必须在测试环境验证
    # vrrp_garp_interval 0
    # vrrp_gna_interval 0
}
# ---- 上游设备侧(需要网络团队配合,必须有变更审批)----
# 在网关交换机上确认并调整:
#   1. 查看 ARP 表项老化时间(不同厂商命令不同,以 Cisco 为例)
#      show ip arp 10.x.x.100
#      show run interface Vlan<id> | include arp
#   2. 确认端口安全(port security)是否对 GARP 做了限速
#      show port-security interface <port>
#   3. 如果需要,把 VIP 做成静态 ARP 绑定(最可靠但需要运维成本)
#      arp 10.x.x.100 <lb-mac> ARPA

验证:

# 1. 再次演练,从跨网段客户端持续探测,记录成功率曲线
# 用循环探测而不是单次,才能看到切换窗口内的丢包情况
for i in $(seq 1 60); do
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://10.x.x.100/healthz)
    printf '%s code=%s\n' "$(date +%T)" "$code"
    sleep 1
done
# 预期:切换瞬间可能有 1~3 个失败(TCP 重建 + ARP 更新),随后全部 200
# 异常:持续数十秒大量失败 → GARP 没生效,或者上游设备没接受 GARP

# 2. 从网关侧确认 ARP 表项已指向新 MAC(需要网络团队执行)
# show ip arp 10.x.x.100   →  MAC 应为 lb2 的 MAC

# 3. 用 Prometheus 记录整个切换窗口的指标变化
#    (关键:把探测做成 blackbox_exporter 的探针,长期保留数据)
# blackbox_exporter 的探测配置(prometheus.yml 片段)
# 目的:把"VIP 可达性"变成一个长期可查的 SLI,而不是靠临时 curl
scrape_configs:
- job_name: 'vip-probe'
  metrics_path: /probe
  params:
    module: [http_2xx]
  static_configs:
  - targets:
    - http://10.x.x.100/healthz
    - http://10.x.x.101/healthz
  relabel_configs:
  - source_labels: [__address__]
    target_label: __param_target
  - source_labels: [__param_target]
    target_label: instance
  - target_label: __address__
    replacement: 127.0.0.1:9115  # blackbox_exporter 地址
  # 采集间隔设短一些,切换窗口只有几秒,30s 采样会漏掉
  scrape_interval: 5s

对应的告警:

- alert: VipProbeFailing
  expr: probe_success{job="vip-probe"} == 0
  for: 1m
  labels: {severity: critical}
  annotations:
    summary: "VIP {{ $labels.instance }} 探测连续失败超过 1 分钟"
    runbook: "先看是否在切换窗口内;若不在,检查 ARP/GARP 和上游设备"

# 切换窗口内的短暂失败是可接受的,用这个规则区分"切换"和"真故障"
- alert: VipProbeFlapping
  expr: changes(probe_success{job="vip-probe"}[15m]) > 4
  labels: {severity: warning}
  annotations:
    summary: "VIP {{ $labels.instance }} 探测结果频繁变化,可能存在 ARP 抖动"

回滚预案:garp_master_* 参数属于低风险调整,回滚就是改回原值并 restart(注意 restart 本身会触发一次切换)。上游设备的静态 ARP 绑定如果需要回滚,必须有网络团队参与并有回滚脚本——静态绑定配错会导致 VIP 完全不可达,因为没有 ARP 应答。

复盘:

  1. "VIP 迁移成功"不等于"流量切换成功"。这两件事之间隔着二层 ARP 更新,而 ARP 更新依赖上游设备的配合。
  2. 改进项:
  • 把切换成功率的定义从"日志出现 MASTER"改为"跨网段探测成功率恢复"。后者才是用户可感知的指标。
  • 用 blackbox_exporter 做长期探测,记录每次演练的丢包窗口时长,形成基线。异常时要能对比。
  • 给 garp_master_refresh 设一个非零值。默认值因版本而异,显式配置能避免"默认值在我的环境恰好是最差的"这种问题。

10.5 案例五:VIP 来回漂移(flapping)

现象:白天业务高峰期,VIP 在两个节点之间每隔 10~30 秒切换一次,持续近一小时。两端日志都有大量 Entering MASTER/BACKUP STATE。业务表现为间歇性 502。

初判:抖动(flapping)通常有三个来源:健康检查在临界点抖动、网络丢包导致超时、两个节点的优先级过于接近。需要先确定是"检查结果在变"还是"报文没到"。

检查过程:

# ---- 第 3 层优先:健康检查是否在抖动 ----
sudo journalctl -u keepalived --since "1 hour ago" | grep -E 'VRRP_Script' | tail -40

输出显示两个脚本都在成功和失败之间反复跳:

VRRP_Script(chk_haproxy) failed
VRRP_Script(chk_haproxy) succeeded
VRRP_Script(chk_backend) failed
VRRP_Script(chk_backend) succeeded

而 fall 2 / rise 2 的配置意味着 2 次连续失败就该切换,2 次连续成功就该恢复——按每 2 秒一次的执行频率,这个抖动周期正好是 4~8 秒,与观察到的切换频率吻合。

# 手工执行脚本,看真实情况
for i in $(seq 1 10); do
    sudo -u root /etc/keepalived/scripts/check_haproxy.sh
    printf 'exit=%s\n' "$?"
    sleep 1
done

结果:大部分是 0,偶尔出现 1。说明脚本本身有时候真的会失败。

# 找到脚本失败的真正原因 —— 看它到底卡在哪一步
# 用 strace 或者加详细日志手工跑
sudo bash -x /etc/keepalived/scripts/check_haproxy.sh 2>&1 | tail -30
# 关注哪个命令耗时最久或返回非零

# 检查 HAProxy 的 stats socket 响应时间(脚本最可能卡在这里)
time (printf 'show stat\n' | timeout 3 socat - UNIX-CONNECT:/var/run/haproxy/admin.sock | wc -l)
# 异常表现:耗时接近或超过 2 秒 → 脚本的 timeout 2 会随机触发失败

根因:check_haproxy.sh 里用 socat 查询 HAProxy 的 stats socket,而 socat - UNIX-CONNECT:... 在 socket 响应慢时会一直等待直到超时。业务高峰期 HAProxy 的连接数很高,show stat 的响应时间从平时的几毫秒涨到 1.5~2.5 秒,撞上了脚本里的 timeout 2,于是随机失败。因为 fall 2,两次连续失败就触发切换;切换后流量转移到另一节点,那台机器的 HAProxy 负载升高,show stat 也变慢,于是它也失败——形成了自我强化的振荡。

这是最典型也最隐蔽的抖动模式:健康检查的开销本身成为了故障源。

指标证据(从 HAProxy 和系统指标对齐时间):

# HAProxy 连接数在抖动期间是高位(这个指标由 haproxy_exporter 提供)
haproxy_frontend_connections_rate{frontend="fe_app"}
# 与脚本执行时间对齐后可以确认:连接数越高,脚本越容易超时
# 关键证据:脚本失败率的上升与连接数上升同步
# 需要在脚本里上报执行耗时(见下方修复)
rate(keepalived_script_duration_seconds_count{script="chk_haproxy"}[5m])

修复(三层,从治标到治本):

# ---- 第一层:调大 timeout 并放宽 fall/rise(快速止血)----
vrrp_script chk_haproxy {
    script "/etc/keepalived/scripts/check_haproxy.sh"
    interval 2
    timeout 5          # 从 3 提到 5:容纳峰值时的 stats 查询延迟
    weight -60
    fall 4             # 从 2 提到 4:连续 4 次失败(约 8 秒)才判定失败
    rise 3
}
vrrp_instance VI_100 {
    ...
    # 调大 advert_int 能降低抖动敏感度,但会增加故障检测时间
    # 这是权衡,不是免费的。2 秒意味着最长 6 秒以上才检测到故障
    advert_int 2
}
# ---- 第二层:消除脚本的阻塞点(治本)----
# 把"查询 stats socket"换成更轻量的检查方式:
# 1) 只检查进程存在 + 端口监听(不需要 stats socket)
# 2) stats socket 查询放在后台任务里,用缓存结果
#!/usr/bin/env bash
# /etc/keepalived/scripts/check_haproxy_fast.sh
# 轻量版:只用内核能直接回答的问题,不依赖 socket 往返
# 退出码:0=健康 1=不健康 2=无法判断

set -uo pipefail

readonly PORT=80

# 检查 1:进程存在(几乎零开销)
if ! pgrep -x haproxy >/dev/null 2>&1; then
    printf 'chk_haproxy FAIL: process not found\n'
    exit 1
fi

# 检查 2:监听端口存在(读 /proc/net/tcp,比 connect 更轻)
# 十六进制 80 = 0x0050
if ! awk 'NR>1 {split($2,a,":"); if (a[2]=="0050" && $4=="0A") found=1} END{exit !found}' /proc/net/tcp; then
    printf 'chk_haproxy FAIL: no listener on port %s\n' "$PORT"
    exit 1
fi

# 检查 3:TCP 连接能否真正建立(一次 connect,1 秒超时)
# 这是最接近真实业务路径的检查,开销可控
if timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${PORT}" 2>/dev/null; then
    printf 'chk_haproxy OK: port %s accepts connections\n' "$PORT"
    exit 0
fi

printf 'chk_haproxy FAIL: port %s refuses connection\n' "$PORT"
exit 1
# ---- 第三层:给脚本加可观测性(防止下次再靠猜)----
# 在脚本里记录执行耗时,写入 textfile 供 Prometheus 采集
# 在脚本开头和结尾加计时(追加到上面的 fast 版本里)
_start_ns=$(date +%s%N)
# ... 检查逻辑 ...
_end_ns=$(date +%s%N)
_dur_ms=$(( (_end_ns - _start_ns) / 1000000 ))

_metric_dir="/var/lib/node_exporter/textfile"
_tmp="$(mktemp "${_metric_dir}/chk_haproxy.XXXXXX")"
{
    printf '# HELP keepalived_script_duration_ms Last execution duration of a check script\n'
    printf '# TYPE keepalived_script_duration_ms gauge\n'
    printf 'keepalived_script_duration_ms{script="chk_haproxy"} %s\n' "$_dur_ms"
} > "$_tmp"
chmod 0644 "$_tmp"
mv -f "$_tmp" "${_metric_dir}/chk_haproxy.prom"
# 注意:这个指标是"最后一次执行耗时",抓取时机与执行时机不同步,
# 但用于观察趋势足够,且成本极低

验证(抖动是否真的停止):

# 持续监控状态迁移次数,正常应该为 0
watch -n 5 'sudo journalctl -u keepalived --since "5 min ago" | grep -c "Entering MASTER STATE"'
# 预期:稳定在 0(或 1,刚启动那一次)
# 异常:持续增长 → 抖动未消除,回到第 3 层继续查

# 同时观察脚本耗时指标
curl -s http://127.0.0.1:9100/metrics | grep keepalived_script_duration_ms
# 预期:在几十毫秒量级。若上百毫秒,说明检查本身太重,还要继续优化

回滚预案:

  • vrrp_script 参数调整需要 restart,会触发漂移。回滚同样是改参数 + restart。
  • 更换脚本内容本身可以在 restart 后通过修改脚本文件即时生效(Keepalived 每次执行都会重新调用脚本文件),所以脚本的替换可以在一端先试;但 timeout/fall/rise 这些参数在 vrrp_script 段里,改动需要 restart。
  • 这是一个可以利用的灰度点:先在 lb1 上替换脚本(改文件即可),观察一段时间;确认稳定后再处理 lb2 的参数。

复盘:

  1. 根因是"健康检查的开销在高负载下超过了它的超时阈值",导致检查结果与真实健康状况脱钩。这类问题的普遍规律是:检查越重,抖动风险越高。
  2. 判断依据不能只看"脚本失败",必须同时看"脚本执行耗时"。后者才是根因的直接证据。
  3. 改进项:
  • 健康检查脚本的设计原则:优先用内核能直接回答的问题(进程存在、端口监听、一次 connect),避免依赖需要应用层协议往返的重查询。
  • timeout 的取值应该基于实测的 P99 执行耗时,而不是凭感觉给一个数。经验值:timeout >= 3 × P99。
  • fall / rise 的次数与 interval 的乘积决定了抖动抗性,也决定了故障检测时间。必须在两者之间显式权衡并记录决策理由。本例中 fall 4 × interval 2 = 8 秒 是最长检测时间,这是可接受的;如果业务要求 3 秒内切换,就不能靠加大 fall 来抗抖动,只能优化脚本本身。
  • 把"脚本执行耗时"变成常规监控指标,并在耗时 P99 上升时告警——这能在抖动发生之前就发现问题。

十一、生产注意事项

11.1 双主架构的三个前提

双主(Active-Active)看着比主备更"高级",但它有三个前提条件,缺一个就不要用:

前提一:两个节点要能互相接管全部 VIP。 意味着每个节点都要有足够的容量同时承载两个 VIP 的流量。如果按"各承担 50%"做容量规划,切换后一台机器要扛 100%,这会直接过载。容量必须按 100% 准备。

前提二:业务连接要能容忍 IP 变化。 VIP 迁移后,已建立的 TCP 连接会中断(除非用 LVS 的 connection sync 做会话同步)。如果业务是长连接且不能重连,双主不适用。

前提三:必须有 vrrp_sync_group。 否则会出现两个 VIP 压在同一台机器上的中间态,让容量规划失效。

11.2 virtual_router_id 与 router_id 必须纳入清单管理

案例二已经说明后果。具体要求:

  • virtual_router_id(1~255)在同一个二层广播域内必须全局唯一。跨网段的多个 VRRP 组可以用相同的 VRID,但为了省心,建议全局唯一。
  • router_id 是通知和日志里标识节点的字符串,必须唯一且稳定。不要用 IP 当 router_id——换机器后历史日志会对不上,排障时会多一层困惑。
  • 把这两个值写进 CMDB 并做唯一性校验。这是低成本、高收益的治理动作。
# 上线前扫描同网段是否存在相同 VRID 的 VRRP 通告(做一次基线)
sudo timeout 15 tcpdump -i eth0 -n vrrp 2>/dev/null | \
  awk '{print $5, $7}' | sort -u
# 预期输出示例:
#   vrid 100, 10.x.x.11
#   vrid 101, 10.x.x.12
# 判断依据:每个 VRID 只应对应一个源 IP(当前持有者)。
#          若某个 VRID 出现多个源 IP → 有冲突,必须先解决再上线

11.3 认证密码:不要有安全感,但必须配

VRRP 的 auth_type PASS 不是加密,是明文摘要保护。它只能防"意外的误配混入",防不了有意的攻击——攻击者能抓到通告就能提取信息。所以:

  • 仍然要配,因为它能过滤掉同网段其他团队误配的 VRRP 组(这类误配非常常见)。
  • 密码不能很弱(VRRPv2 的认证字段长度有限,但这不代表可以用 123456)。
  • 密码不能明文写在配置仓库里。这在实践上有点麻烦,因为 Keepalived 的配置需要是静态文件。可行方案:
  1. 用配置管理工具(Ansible Vault / SOPS)加密,部署时解密写入 0600 权限的文件。
  2. 或者用 include 把密码部分拆到单独文件,该文件不进版本库。
# 密码文件的权限要求
sudo chown root:root /etc/keepalived/vrrp.auth
sudo chmod 0600 /etc/keepalived/vrrp.auth
# 判断依据:Keepalived 以 root 运行,配置里不应有任何非 root 可读的凭据
ls -la /etc/keepalived/
# 异常:keepalived.conf 是 0644 且内含 auth_pass → 任何本地用户能读到

VRRPv3 的差异:v3 可以用 auth_type AH(IPsec 认证头),但支持度和复杂度都更高。生产环境仍然普遍用 PASS。这一点必须在文档里写清楚,避免有人以为配了 PASS 就"安全了"。

11.4 邮件通知:默认失效的坑

global_defs 里的 notification_email 需要本机有可用的 MTA(sendmail/postfix)。大多数最小化安装的系统没有 MTA,此时 Keepalived 不会报错,只是通知静默丢失。

# 检查是否有 MTA
command -v sendmail postfix exim 2>/dev/null
systemctl is-active postfix 2>/dev/null || echo "postfix not active"
# 判断依据:都没有 → notification_email 是无效配置

# 确认 Keepalived 是否在尝试发邮件
sudo journalctl -u keepalived | grep -iE 'smtp|mail'
# 异常:出现 "SMTP connection failed" / "cannot connect to smtp server"

建议:生产环境不要依赖 Keepalived 的邮件通知。改用通知脚本 + Prometheus 告警。邮件通知的问题不只是可能失效,还有:无重试、无分级、无抑制、无法做值班轮转。它是 2000 年代的方案。

11.5 高并发下的调整项

当 VIP 后面的服务并发很高时,有几个参数需要动:

# 内核参数:VIP 迁移时允许绑定到非本地地址(LVS 场景常见)
# 注意:这属于"允许绑定非本地 IP",会带来安全影响,只应在前端机器上开启
# /etc/sysctl.d/99-keepalived.conf
# net.ipv4.ip_nonlocal_bind = 1
# net.ipv4.ip_forward = 1          # 仅 LVS 转发节点需要

# ARP 相关:VIP 迁移时的 ARP 应答行为
# net.ipv4.conf.all.arp_ignore = 1
# net.ipv4.conf.all.arp_announce = 2
# 说明:arp_ignore=1 表示只应答目标 IP 是本地地址的 ARP 请求;
#      arp_announce=2 表示总是使用最佳本地地址做 ARP 通告。
#      这两个参数在 LVS DR 模式(real server 侧)是必需的,
#      在普通 Keepalived + nginx/HAProxy 场景不是必需的,
#      但配上可以减少"VIP 迁移后出现来源地址错误的 ARP"这类问题。
# 应用的优雅降级:VIP 迁移前让服务先停止接受新连接
# 这是最难但最有效的一环,需要应用配合
# 思路:notify_backup / notify_fault 脚本里触发"排空"逻辑
#!/usr/bin/env bash
# /etc/keepalived/scripts/drain.sh  —— 在让位前排空连接
# 只在 notify_backup / notify_fault 中调用,通知脚本本身必须快速,
# 所以这里把耗时的排空动作放到后台并立即返回
set -uo pipefail

readonly HA_SOCK="/var/run/haproxy/admin.sock"

# 后台执行,不阻塞通知脚本
(
    # 1. 把后端标记为维护状态,停止接受新连接
    printf 'set server bk_app/srv1 state maint\n' | \
        timeout 3 socat - "UNIX-CONNECT:${HA_SOCK}" >/dev/null 2>&1 || true

    # 2. 等待既有连接自然结束(有上限,不能无限等)
    for _ in $(seq 1 10); do
        conns=$(printf 'show info\n' | timeout 2 socat - "UNIX-CONNECT:${HA_SOCK}" 2>/dev/null \
                | awk '/CurrConns/{print $2}')
        [[ -z "${conns:-}" || "${conns}" == "0" ]] && break
        sleep 1
    done
) &
# 关键:& 让它后台运行,父脚本立刻退出
exit 0

风险提示:排空逻辑如果写错,会导致 VIP 已经漂移但旧节点还在接新连接(双写),或者旧节点永久不恢复。所以这个脚本必须有超时上限,并且在预发环境充分验证。不建议在第一次上线时就引入排空逻辑,应该先确保基础的漂移工作正常,再逐步加入。

11.6 变更窗口与灰度

Keepalived 相关的变更几乎都属于高风险,因为它们直接决定入口可用性。执行规范:

必须走变更窗口:restart keepalived、修改 priority/weight/advert_int、修改 virtual_router_id、防火墙规则变更、内核参数变更。

可以随时做:日志级别调整、通知脚本内容修改(不改参数)、Prometheus 告警规则调整。

灰度执行顺序(两端架构):

  1. 先在备端(持次要 VIP 的那台)执行变更。此时若出问题,影响面是次要 VIP。
  2. 观察至少 24 小时,覆盖一个完整业务周期。观察指标:VIP 归属正确、脚本成功率、切换次数为 0。
  3. 再对主端执行变更,执行前确认备端健康且容量充足。
  4. 变更后立即做一次演练验证(用 stop 服务触发真实切换),这是唯一能确认配置真的生效的方法。

权限控制:

# 建议用 sudo 限制具体命令,而不是给整个 shell 的 root 权限
# /etc/sudoers.d/keepalived-ops
# %ops ALL=(root) NOPASSWD: /usr/bin/systemctl restart keepalived
# %ops ALL=(root) NOPASSWD: /usr/bin/systemctl reload keepalived
# %ops ALL=(root) NOPASSWD: /usr/sbin/keepalived --config-test *
# 注意:不要给 NOPASSWD: /usr/bin/systemctl * (等于给了整个服务的控制权)
#      也不要给 NOPASSWD: /usr/bin/bash(等于给了 root shell)
sudo visudo -c -f /etc/sudoers.d/keepalived-ops
# 预期:/etc/sudoers.d/keepalived-ops: parsed OK

11.7 备份与回滚准备

每次变更前必须准备(不是"最好准备"):

# 1. 配置备份(带时间戳,保留至少 30 天)
sudo mkdir -p /backup/keepalived
sudo cp -a /etc/keepalived/keepalived.conf \
    "/backup/keepalived/keepalived.conf.$(date +%Y%m%d-%H%M%S)"
# 2. 脚本目录一起备份(脚本和配置强耦合)
sudo tar czf "/backup/keepalived/scripts.$(date +%Y%m%d-%H%M%S).tar.gz" \
          -C /etc/keepalived scripts
# 3. 记录当前状态作为回滚基线
{
    echo "=== $(date -Is) ==="
    ip -br addr show
    systemctl show keepalived -p MainPID -p ExecMainStartTimestamp
    sudo journalctl -u keepalived -n 20 --no-pager
} | sudo tee "/backup/keepalived/state.$(date +%Y%m%d-%H%M%S).txt" >/dev/null

# 4. 校验备份文件可读(不要跳过)
sudo tar tzf /backup/keepalived/scripts.*.tar.gz | head
# 预期:列出 check_haproxy.sh 等文件

回滚动作:

# 回滚 = 恢复配置 + restart。注意 restart 会再次触发漂移
sudo cp -a /backup/keepalived/keepalived.conf.<时间戳> /etc/keepalived/keepalived.conf
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf || {
    echo "回滚后的配置校验失败,停止操作" >&2
    exit 1
}
sudo systemctl restart keepalived

回滚失败的兜底:如果 restart 后 Keepalived 起不来,VIP 会完全消失。此时的手工兜底是把 VIP 临时加到一台节点上(用最原始的方式保住业务):

# 【高风险】手工接管 VIP。前置检查:确认对端没有持有同一 VIP,否则会造成冲突
ip -br addr show | grep 10.x.x.100 && { echo "VIP 已存在,勿重复添加" >&2; exit 1; }
sudo ip addr add 10.x.x.100/24 dev eth0
sudo ip neigh flush dev eth0     # 触发邻居表重建
# 验证
ping -c 2 -W 1 10.x.x.100
curl -sf -o /dev/null -w '%{http_code}\n' http://10.x.x.100/healthz
# 说明:这是应急手段,必须记入事件报告并尽快恢复 Keepalived 管理

十二、验证清单

部署或变更完成后逐项确认。每一项都给了命令和明确的预期/异常对照,不能只打勾。

12.1 环境与配置基线

# 1. 版本符合假设
keepalived --version | head -1
# 预期:v2.2.x(或你知道的版本)。若是 v1.3.x,注意 state 必填、netfilter 走 iptables

# 2. 配置语法校验通过
sudo keepalived --config-test -f /etc/keepalived/keepalived.conf ; echo "exit=$?"
# 预期:exit=0
# 异常:exit 非 0 → 有语法错误,不要继续。常见是少了一个花括号或写错关键字名

# 3. 网卡名与配置一致
ip -br addr show | awk '{print $1}'
grep -E '^\s*interface' /etc/keepalived/keepalived.conf
# 预期:配置里的网卡名出现在 ip 命令输出里
# 异常:不一致(例如配了 eth0 但实际是 ens18)→ 实例会停在 INIT

# 4. 脚本权限符合安全要求
sudo find /etc/keepalived/scripts -type f -printf '%m %u:%g %p\n'
# 预期:0755 root:root(或 0700)
# 异常:含 2(组可写)或最后一位是 6/7(其他用户可写)→ 2.x 会拒绝执行

# 5. 脚本的三种退出码符合预期(逐个脚本验证)
sudo /etc/keepalived/scripts/check_haproxy.sh ; echo "exit=$?"
# 预期:exit=0(健康时)。停掉服务再试应得 exit=1

12.2 VRRP 报文通路

# 6. 双向能看到 VRRP 通告
sudo timeout 8 tcpdump -i eth0 -n -e vrrp
# 预期:能看到当前 MASTER 的周期通告,形如
#   xx:xx:xx:xx:xx:xx > 01:00:5e:00:00:12, ethertype IPv4
#   10.x.x.11 > 224.0.0.18: VRRPv2, Advertisement, vrid 100, prio 150
# 异常:什么都没有 → 依次排查:对端进程是否在跑、防火墙是否拦 112、
#       组播是否被虚拟网络丢弃(若是,改用 unicast_peer)

# 7. 通告源只有拓扑内的节点
sudo timeout 15 tcpdump -i eth0 -n vrrp 2>/dev/null | awk '{print $3}' | sort -u
# 预期:只有两个节点 IP。多出任何其他 IP → 幽灵节点,见案例二

# 8. VRID 无冲突
sudo timeout 15 tcpdump -i eth0 -n -e vrrp 2>/dev/null | grep -o 'vrid [0-9]*' | sort -u
# 预期:每个 VRID 只对应一个源 IP

# 9. TTL 正确(跨三层的证据)
sudo timeout 8 tcpdump -i eth0 -n -vv vrrp | grep -i ttl
# 预期:ttl 255
# 异常:ttl 不是 255 → 报文跨了三层设备,VRRP 在这种情况下不可靠

12.3 状态机与选举

# 10. 实例状态符合设计(双主:一台持 100、另一台持 101)
for h in lb1 lb2; do
    echo "===== $h ====="
    ssh "$h" "ip -br addr show eth0 | grep -oE '10\.x\.x\.10[01]' | sort -u"
done
# 预期:
#   lb1: 10.x.x.100
#   lb2: 10.x.x.101
# 异常:
#   两端都有 10.x.x.100 → 脑裂,立即按案例二处理
#   两端都没有某个 VIP → VIP 消失,查是不是两端都进了 FAULT

# 11. 无抖动
sudo journalctl -u keepalived --since "30 min ago" | grep -c 'Entering MASTER STATE'
# 预期:0(或 1,如果刚重启过)
# 异常:> 2 → 抖动,按案例五排查

# 12. 优先级算术正确(做一次故障注入)
sudo systemctl stop haproxy
sleep 8
sudo journalctl -u keepalived --since "2 min ago" | grep -E 'effective priority|Entering'
# 预期:出现 "Changing effective priority from 150 to 90" 和 "Entering BACKUP STATE"
# 异常:只有脚本 failed,没有 priority 变化 → weight 方向配反(见 6.5)

# 13. 恢复
sudo systemctl start haproxy
# 注意:配了 nopreempt 时 VIP 不会自动切回,这是预期行为

12.4 切换的端到端验证

# 14. 跨网段探测(最关键的一项:验证流量真的跟着走)
# 从一台真实客户端,按秒探测,记录成功率和时间
for i in $(seq 1 30); do
    printf '%s %s\n' "$(date +%T)" \
        "$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://10.x.x.100/healthz)"
    sleep 1
done
# 预期:切换瞬间 1~3 个失败(000 或超时),随后稳定 200
# 异常:持续大量失败 → ARP 未更新(案例四)或后端未就绪

# 15. 上游设备侧的 ARP 表项(需要网络团队配合)
# 在网关/核心交换机上查看 VIP 的 ARP 表项,确认指向新节点的 MAC
# 验证命令随厂商不同,例如 Cisco: show ip arp 10.x.x.100
# 预期:MAC 等于新持有者网卡的 MAC

12.5 监控与告警

# 16. 指标已采集
curl -s http://127.0.0.1:9100/metrics | grep -E 'keepalived_'
# 预期:keepalived_vip_held{vip="10.x.x.100"} 1
#       keepalived_vip_held{vip="10.x.x.101"} 0
# 异常:无输出 → textfile collector 未启用,或目录权限不对

# 17. 告警规则已被 Prometheus 加载
curl -s http://127.0.0.1:9090/api/v1/rules | grep -o 'Keepalived[A-Za-z]*'
# 预期:列出 KeepalivedSplitBrain / KeepalivedVipMissing / KeepalivedFlapping 等
# 异常:缺失 → 规则文件未加载,检查 prometheus.yml 的 rule_files 和规则语法
promtool check rules /etc/prometheus/rules/keepalived.yml
# 预期:SUCCESS

# 18. 告警能真的触发(在测试环境做故障注入)
# 临时在测试环境制造脑裂场景,确认告警在 30 秒内触发
# 验证后必须还原,并确认告警恢复(Alertmanager 里能看到 resolved 通知)

12.6 故障注入演练

这部分是唯一能验证"预案真的可用"的方法。每季度至少做一次,并记录结果。

演练项 操作 预期结果 观察指标
服务进程故障 systemctl stop haproxy(在一端) VIP 在 8 秒内漂移,跨网段探测成功率恢复 探测失败窗口 ≤ 4 秒
Keepalived 进程故障 systemctl stop keepalived(在一端) 对端接管,无脑裂 通告源只剩一个
网卡故障 ip link set eth0 down(在一端,谨慎) 该端进 FAULT,对端接管 无 IP 冲突告警
配置回滚 故意把权重配错,再回滚 演练能发现问题并成功回滚 回滚耗时 ≤ 5 分钟
# 演练脚本的一部分:记录切换窗口的精确时间线
# 目的:把"切换耗时"变成可比较的数据,而不是"感觉挺快"
START=$(date +%s.%N)
sudo systemctl stop haproxy
# 等待 VIP 离开本机
while ip -o -4 addr show | grep -qw 10.x.x.100; do
    sleep 0.2
    # 超时保护,避免死循环
    if (( $(echo "$(date +%s.%N) - $START > 30" | bc -l) )); then
        echo "TIMEOUT: VIP 未在 30 秒内漂移" >&2
        exit 1
    fi
done
END=$(date +%s.%N)
printf 'VIP 漂移耗时: %.2f 秒\n' "$(echo "$END - $START" | bc -l)"
# 预期:≤ 8 秒(advert_int 1 + fall 2 + timeout 3 的累加)
# 把这个数值记入基线,后续每次演练对比

sudo systemctl start haproxy

风险提示:演练会在生产环境制造真实的服务切换。必须满足三个条件才能做:知情(业务方知晓)、窗口(低峰期)、可回退(对端健康且容量充足)。不要在没有任何准备的情况下在生产演练。

十三、总结

把全文压成可执行的判断规则:

  1. 排障顺序不能颠倒:状态机 → VRRP 报文 → 健康检查 → 上层网络。先抓包再调脚本,否则大概率在错误的层浪费时间。
  2. VRRP 没有端口,只有协议号 112(IPv4)。 所有按端口写的加固清单都会漏掉它。这是"VIP 不漂移"的头号原因,而且症状是完全静默的。
  3. VRRP 需要同二层、TTL 255、组播可达。 跨三层或组播被丢的环境,必须改用 unicast_peer,而不是想办法"绕过"这些限制。
  4. priority 和 weight 的方向必须一起验算。 weight -30 配在 150 vs 100 的节点上不会触发切换,这是配置层最常见的隐蔽错误。用 weight 0(失败即 FAULT)能彻底避开这类算术问题。
  5. vrrp_script 和 real_server 检查是两件完全不同的事。 前者决定 VIP 漂移,后者只摘除后端。用错会导致"后端故障引发 VIP 抖动"或"本机进程死了也不漂移"。
  6. state 只是初始状态,nopreempt 只在 state BACKUP 下有效。 要靠实际版本验证,不要照抄。
  7. "reload 能应用所有配置"是错误认知。 涉及 vrrp_script 定义、virtual_router_id、interface、track_* 的变更必须 restart,而 restart 会触发真实漂移。
  8. virtual_router_id 必须有唯一性清单。 幽灵节点、跨业务 VRID 冲突会产生最难排查的"莫名其妙切换"。
  9. 漂移成功 ≠ 流量切换成功。 中间隔着 GARP 和上游设备的 ARP 更新。garp_master_refresh 要设非零值,验证必须从跨网段客户端做。
  10. 健康检查自身会成为故障源。 检查越重、超时越紧,抖动风险越高。检查脚本的耗时必须被监控,timeout 要基于实测 P99 而不是感觉。
  11. 双主要按 100% 容量准备,并且必须有 vrrp_sync_group。
  12. 每次变更前备份配置 + 校验语法 + 准备回滚,变更后做一次真实演练。 案例三正是因为做了演练才在造成事故前发现问题——演练不是额外成本,它是唯一能验证配置真的生效的手段。

最后一条经验:Keepalived 的配置很短,短到让人松懈。但它的每个参数都直接决定入口是否可用,而且大部分失效模式是静默的——没有报错、没有告警、进程还活着,只是不再工作了。对抗这种静默失效的唯一手段不是更仔细地读文档,而是把状态变成指标、把切换变成定期演练、把配置变更变成带校验和回滚的流程。这也是云栈社区上很多一线运维同行反复讨论过的共识。




上一篇:REA v5.1.0 逆向框架:一条命令分析任意二进制,输出安全报告
下一篇:144K Star 国产开源 CC Switch:一键切换 Claude Code 供应商,告别手改配置
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-16 07:51 , Processed in 0.224378 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表