feat(failover): 补全主备切换闭环 — 六步状态机 + 逆向回滚 + 健康判定器

依据现有实现补全「自动识别故障 → 人工确认切换 → 切回」运维核心闭环。
触发模式 1A+2B+3A:VIP 浮动 + 半自动 + 切前隔离原主(防脑裂)。

【表结构】
  clusters 加 vip/failover_mode;node_commands 加 command_type
  新增 switchover_steps(切换步骤级审计)

【Agent 六动作(脚本化、幂等、结构化返回)】
  fence(停MySQL+摘VIP)/unfence(恢复)/promote_master(STOP+RESET SLAVE)
  revert_promote(降回从库)/flip_sync(反转同步)/verify(可写+VIP校验)

【切换编排器 foms/failover.py】
  六步状态机:precheck→fence→promote→flip_sync→verify→converge
  失败 → 已完成步骤逆向回滚 → switchover_logs=failed
  回滚也失败 → rollback_failed + 最高级告警冒泡人工
  防脑裂不变量:fence 先于 promote,fence 失败硬中止绝不 promote

【健康判定器】
  后台循环(默认5s)扫 active 集群,心跳超时/复制异常判定
  防抖:连续 failover_threshold 次命中才判故障(防瞬时抖动误判)
  半自动:只告警(写 switchover_logs + hostwatch_events)不切,等人确认

【切回 + 回滚语义】
  trigger_switchback 反向六步;回滚用 unfence/revert_promote/反转/换回

【部署文档】
  docs/deploy-operations.md:架构/三机部署/keepalived/Agent 环境变量/
  日常运维/失败语义/防脑裂/已知限制

验证:语法全过;编排器↔Agent 六命令类型对齐;六步顺序正确;
防脑裂不变量成立(fence idx=1 < promote idx=2);注册链完整。
This commit is contained in:
yumoqing 2026-08-26 18:33:42 +08:00
parent e1be45e940
commit fa257f36b1
7 changed files with 907 additions and 64 deletions

173
docs/deploy-operations.md Normal file
View File

@ -0,0 +1,173 @@
# FOMS 部署与运维手册
> 版本2026-08-26补全主备切换闭环后
> 适用MySQL 主从 + rsync 文件同步的双机主备VIP 浮动1A+2B+3A
## 一、架构
```
管理中心 Cfoms 应用 + 管理库 foms
▲ HTTPBearer token
┌────────────┴────────────┐
主机 Amaster 主机 Bstandby
· MySQL 主库 · MySQL 从库binlog 复制)
· 业务 + keepalived · 业务 + keepalived
· foms-agent · foms-agent
└──── VIP浮动业务连 VIP────┘
```
三个角色:
- **管理中心 C**:跑 foms核心 + dbbackup/filesync/hostwatch 模块),存配置与切换记录。
- **主机 A / 备机 B**:跑业务 + MySQL + keepalived + foms-agent守护进程
- **VIP**:业务流量入口,平时在 A故障时漂到 Bkeepalived 兜底 + FOMS 主动操作)。
## 二、管理中心 C 部署
```bash
cd foms
./build.sh # 建 venv、装依赖含三模块、生成 DDL/CRUD
```
**建库**
```bash
mysql -uroot -p < models/mysql.ddl.sql # 核心表
mysql -uroot -p < pkgs/dbbackup/models/mysql.ddl.sql # dbbackup
mysql -uroot -p < pkgs/filesync/models/mysql.ddl.sql # filesync
mysql -uroot -p < pkgs/hostwatch/models/mysql.ddl.sql # hostwatch
```
**配置数据库连接**:编辑 `conf/config.json``databases.foms`host/user/password/db
**启动**
```bash
py3/bin/python app/foms.py # 监听 conf 里 website.port默认 9080
```
启动时 `load_foms()` 会自动注册切换编排器并拉起健康判定器后台任务。
## 三、管理中心登记拓扑(部署后必做)
在 FOMS 界面(或 API依次创建
1. **节点**nodesA、B 各一条,填 host、ip、ssh_port、ssh_user、rolemaster/standby
2. **集群**clustersmaster_node_id=A、standby_node_id=B、`vip`(如 `192.168.1.100/24`)、
`check_interval`(检测间隔秒)、`failover_threshold`(连续故障次数)、`failover_mode=semi`
3. **复制配置**dbbackup_replications主库/从库地址、复制账号。
4. **文件同步**filesync_directories要 rsync 的目录source→dest
## 四、被管主机 A / B 部署
### 4.1 安装依赖
```bash
apt install -y mysql-server rsync keepalived ipvsadm python3
pip3 install psutil # Agent 采集系统指标需要
```
### 4.2 配置 keepalivedVIP 浮动)
A`/etc/keepalived/keepalived.conf`MASTER
```
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 100 # A 高优先级
advert_int 1
virtual_ipaddress { 192.168.1.100/24 }
}
```
B 相同,但 `state BACKUP``priority 90`
keepalived 是**兜底**:机器全宕机时 VIP 自动漂移;"机器活、服务挂"的场景由 FOMS 主动 fence/promote 处理。
### 4.3 配置并启动 Agent
环境变量A、B 各自的值不同):
```bash
export FOMS_MANAGEMENT_URL=http://<C的IP>:9080 # 管理中心地址
export FOMS_NODE_ID=<本节点在nodes表的id> # A/B 各自
export FOMS_AGENT_TOKEN=<鉴权token> # 与管理中心约定
export FOMS_HEARTBEAT_INTERVAL=10
# 切换动作专用
export FOMS_MYSQL_SERVICE=mysql # MySQL systemd 服务名
export FOMS_VIP=192.168.1.100/24 # VIPCIDR
export FOMS_VIP_IFACE=eth0 # VIP 所在网卡
export MYSQL_ROOT_PASSWORD=<root密码> # promote/verify 用
# 回滚降从库用(指向"对端",即本机的复制上游)
export FOMS_MASTER_HOST=<对端IP> # B 上填 A 的 IPA 上填 B 的 IP
export FOMS_MASTER_PORT=3306
export FOMS_REPL_USER=repl
export FOMS_REPL_PASSWORD=<复制密码>
```
启动:
```bash
python3 foms-agent.py # 建议用 systemd 托管(见下)
```
**systemd 单元**`/etc/systemd/system/foms-agent.service`
```
[Unit]
Description=FOMS Agent
After=network.target
[Service]
EnvironmentFile=/etc/foms/agent.env # 上面的环境变量写这里
ExecStart=/usr/bin/python3 /opt/foms/foms-agent.py
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
systemctl enable --now foms-agent
```
### 4.4 Agent 需要的权限
Agent 以能执行 `systemctl`/`ip`/`mysql` 的用户运行(通常 root。**生产环境应收窄**
用 sudoers 白名单只允许这几条命令,而非完整 root。
## 五、日常运维
### 5.1 故障识别(自动)
健康判定器后台循环(默认 5s 扫一次):
- 判定条件master 心跳超时(> `failover_threshold × check_interval` 秒)**或** 复制状态 error/failed。
- **防抖**:连续 `failover_threshold` 次命中才判故障(避免瞬时抖动误判)。
- 判故障 → 写 `switchover_logs(status=detected, trigger=auto_detect)` + 发 `hostwatch_events` 告警。
- **半自动**:只告警不切,等人确认。
### 5.2 故障切换(人工确认)
看到告警后,人工确认 → 触发切换:
- 界面:集群列表 → 切换按钮(`trigger_switchover`
- API`POST /api/trigger_switchover.dspy {cluster_id}`
编排器异步执行六步(`switchover_steps` 表可查每步状态):
```
1. precheck 目标节点在线+心跳新鲜+复制延迟≤5s防丢数据
2. fence 隔离原主:停 MySQL + 摘 VIP ← 防脑裂,失败必中止
3. promote 提升新主STOP SLAVE + RESET SLAVE ALL原子
4. flip_sync 反转文件同步方向
5. verify 新主可写测试 + VIP 归属检查(业务层请人工确认)
6. converge 集群主备互换 + 节点角色互换 + 状态收敛
```
### 5.3 故障维修与切回
1. 修好原主 A硬件/系统/MySQL
2. 把 A 重新挂为 B 的从库(`CHANGE MASTER TO` 指向 B`START SLAVE`),等复制追平。
3. 确认 A 健康后,触发切回:`trigger_switchback`(反向六步)。
### 5.4 失败与回滚语义
- 任何一步失败 → 已完成步骤**逆向回滚**`switchover_logs=failed`
- 回滚也失败 → `switchover_logs=rollback_failed` + 最高级告警,**人工介入**。
- 回滚动作unfence恢复原主、revert_promote降回从库、flip_sync 反转、converge 换回。
## 六、防脑裂三重保险
1. **fence 先于 promote**:先停旧主 MySQL + 摘 VIP再立新主。
2. **fence 失败硬中止**:绝不 promote否则双主
3. **keepalived VRRP 兜底**:主机器全宕机时 VIP 自动漂移。
## 七、已知限制(诚实声明)
- **切换时 Agent 延迟**Agent 轮询命令,切换六步串行走完约需数十秒(运维切换通常可接受)。
- **verify 只到数据层**:验证"MySQL 可写 + VIP 可达",业务层是否正常需人工确认。
- **数据丢失窗口**precheck 要求复制延迟 ≤5sfence 到 promote 之间若有未同步 binlog 会丢失(已尽量收窄)。
- **远程命令安全**命令下发目前无白名单鉴权P1 待办),生产应收窄。
## 八、模块清单
| 模块 | 仓库 | 职责 |
|---|---|---|
| foms核心 | yumoqing/foms | 集群/节点/切换编排/心跳/远程命令 |
| dbbackup | yumoqing/dbbackup | 数据库备份 + 日志同步binlog 复制) |
| filesync | yumoqing/filesync | 运行文件同步rsync |
| hostwatch | yumoqing/hostwatch | 日志监控 + 日志分析 + 主机指标 |

504
foms/failover.py Normal file
View File

@ -0,0 +1,504 @@
"""FOMS 切换编排器 — 六步状态机 + 逆向回滚 + 防脑裂不变量。
设计2026-08-26 定调1A+2B+3A
· 流量切换VIP 浮动keepalived 兜底
· 触发模式半自动健康判定器告警 + 人工确认后切
· 防脑裂fence隔离原主先于 promote立新主fence 失败硬中止
切换六步failover AB
step1 precheck 编排器本地B 在线 + 心跳新鲜 + 复制延迟 < 阈值防丢数据
step2 fence 下发 A MySQL + VIP 防脑裂关键失败必中止
step3 promote 下发 BSTOP SLAVE + RESET SLAVE ALL脚本化原子执行
step4 flip_sync DB 层反转 filesync 方向 + 下发 B 确认
step5 verify 下发 B连库读写 + VIP 归属检查业务层留人工确认
step6 converge 收敛集群主备互换 + 节点角色互换 + 日志终态
任何一步 failed 已完成步骤逆向回滚 switchover_logs=failed
回滚也失败 rollback_failed + 最高级告警冒泡人工
松耦合约束
· precheck/flip_sync dbbackup_replications / filesync_directories同库只读
· 告警写 hostwatch_events hostwatch 模块约定的事件结构
"""
import asyncio
import json
from datetime import datetime
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString
from sqlor.dbpools import get_sor_context
from ahserver.serverenv import ServerEnv
DBNAME = 'foms'
# 幂等标志:防热重载重复启动健康判定器后台任务
_failover_registered = False
# ═══ 常量(防丢数据 / 超时 / 轮询)═══
MAX_LAG_SECONDS = 5 # precheck复制延迟必须 ≤ 该值才允许切换R4
HEARTBEAT_FRESH_SEC = 60 # precheck目标节点心跳须在 60s 内
CMD_TIMEOUT = 90 # 等待 Agent 命令完成的最长秒数
CMD_POLL_INTERVAL = 2 # 轮询命令结果间隔(切换期加密,缓解 R1 延迟)
JUDGE_INTERVAL = 5 # 健康判定器扫描间隔(秒)
def _now():
return curDateString()
def _parse_dt(v):
"""把库里取出的时间值解析成 datetime兼容 str/datetime"""
if v is None:
return None
if isinstance(v, datetime):
return v
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
try:
return datetime.strptime(str(v)[:19], fmt)
except (ValueError, TypeError):
continue
return None
# ═══════════════════════════════════════════
# 步骤记录switchover_steps 审计)
# ═══════════════════════════════════════════
async def _record_step(sor, switchover_id, seq, step_name, status, output='', error=''):
"""写一条步骤审计记录(先查后建,保证幂等)。不写 None 值sqlor 不接受)。"""
existing = await sor.sqlExe(
'SELECT id FROM switchover_steps WHERE switchover_id=${sw}$ AND seq=${seq}$',
{'sw': switchover_id, 'seq': seq})
if existing:
upd = {
'id': getattr(existing[0], 'id', ''),
'status': status,
'output': (output or '')[:2000],
'error': (error or '')[:2000],
}
if status in ('success', 'failed', 'skipped'):
upd['completed_at'] = _now()
await sor.U('switchover_steps', upd)
else:
rec = {
'id': getID(),
'switchover_id': switchover_id,
'seq': seq,
'step_name': step_name,
'status': status,
'output': (output or '')[:2000],
'error': (error or '')[:2000],
}
if status == 'running':
rec['started_at'] = _now()
await sor.C('switchover_steps', rec)
# ═══════════════════════════════════════════
# Agent 命令下发 + 等待(结构化类型)
# ═══════════════════════════════════════════
async def _send_command(sor, node_id, command_type, command):
"""向节点下发结构化命令,返回 cmd_id。"""
cmd_id = getID()
await sor.C('node_commands', {
'id': cmd_id,
'node_id': node_id,
'command_type': command_type,
'command': command or command_type,
'status': 'pending',
'created_at': _now(),
})
return cmd_id
async def _wait_command(sor, cmd_id, timeout=CMD_TIMEOUT):
"""轮询 node_commands 直到终态,返回 (ok, exit_code, output)。"""
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
rows = await sor.sqlExe(
'SELECT status, exit_code, output FROM node_commands WHERE id=${cid}$',
{'cid': cmd_id})
if rows:
r = rows[0]
status = getattr(r, 'status', '')
if status in ('success', 'failed'):
return (status == 'success'), getattr(r, 'exit_code', -1), getattr(r, 'output', '')
await asyncio.sleep(CMD_POLL_INTERVAL)
return False, -1, f'command timeout after {timeout}s'
# ═══════════════════════════════════════════
# 各步骤实现
# ═══════════════════════════════════════════
async def _step_precheck(sor, ctx):
"""step1本地预检 —— 目标节点在线、心跳新鲜、复制延迟达标(防丢数据)。"""
master_id, standby_id = ctx['from_node_id'], ctx['to_node_id']
target = await sor.R('nodes', {'id': standby_id})
if not target:
return False, '目标节点不存在'
t = target[0]
if getattr(t, 'status', '') != 'online':
return False, f"目标节点不在线status={getattr(t, 'status', '')}"
hb = _parse_dt(getattr(t, 'last_heartbeat', None))
if hb is None or (datetime.now() - hb).total_seconds() > HEARTBEAT_FRESH_SEC:
return False, '目标节点心跳不新鲜(超过 %ds' % HEARTBEAT_FRESH_SEC
# 复制延迟检查(松耦合读 dbbackup 表)
repls = await sor.sqlExe(
"SELECT db_name, seconds_behind, sync_status FROM dbbackup_replications "
"WHERE cluster_id=${cid}$ AND enabled='1'", {'cid': ctx['cluster_id']})
for r in (repls or []):
lag = getattr(r, 'seconds_behind', None)
if lag is None:
lag = 999
if int(lag) > MAX_LAG_SECONDS:
return False, f"复制延迟过大:{getattr(r, 'db_name', '')} behind {lag}s阈值 {MAX_LAG_SECONDS}s防丢数据"
return True, 'precheck passed'
async def _step_fence(sor, ctx):
"""step2隔离原主停 MySQL + 摘 VIP。防脑裂关键失败必中止。"""
cmd_id = await _send_command(sor, ctx['from_node_id'], 'fence', 'fence')
ok, code, out = await _wait_command(sor, cmd_id)
return ok, out
async def _step_promote(sor, ctx):
"""step3提升新主STOP SLAVE + RESET SLAVE ALL脚本化原子"""
cmd_id = await _send_command(sor, ctx['to_node_id'], 'promote_master', 'promote_master')
ok, code, out = await _wait_command(sor, cmd_id)
return ok, out
async def _step_flip_sync(sor, ctx):
"""step4DB 层反转 filesync 方向 + 下发目标节点确认。"""
from_id, to_id = ctx['from_node_id'], ctx['to_node_id']
# 取原主/新主的 host用于方向反转
nodes = {}
for nid in (from_id, to_id):
recs = await sor.R('nodes', {'id': nid})
if recs:
nodes[nid] = getattr(recs[0], 'host', '')
from_host, to_host = nodes.get(from_id, ''), nodes.get(to_id, '')
# 反转source_host 从原主指向新主A→B 变 B→A
await sor.sqlExe(
'UPDATE filesync_directories SET source_host=${th}$, dest_host=${fh}$, '
'updated_at=${now}$ WHERE cluster_id=${cid}$ AND source_host=${fh}$',
{'th': to_host, 'fh': from_host, 'now': _now(), 'cid': ctx['cluster_id']})
cmd_id = await _send_command(sor, to_id, 'flip_sync', 'flip_sync')
ok, code, out = await _wait_command(sor, cmd_id)
return ok, f'flip db done; agent: {out}'
async def _step_verify(sor, ctx):
"""step5校验新主可写 + VIP 归属(业务层留人工确认)。"""
cmd_id = await _send_command(sor, ctx['to_node_id'], 'verify', 'verify')
ok, code, out = await _wait_command(sor, cmd_id)
if not ok:
return False, out
return True, out + '(业务层请人工确认)'
async def _step_converge(sor, ctx):
"""step6收敛 —— 集群主备互换 + 节点角色互换 + 集群恢复 running。"""
from_id, to_id = ctx['from_node_id'], ctx['to_node_id']
await sor.U('clusters', {
'id': ctx['cluster_id'],
'master_node_id': to_id,
'standby_node_id': from_id,
'status': 'running',
'updated_at': _now(),
})
await sor.U('nodes', {'id': to_id, 'role': 'master', 'updated_at': _now()})
await sor.U('nodes', {'id': from_id, 'role': 'standby', 'updated_at': _now()})
return True, 'converged: master/standby swapped'
# 步骤表(正序)
STEPS = [
('precheck', _step_precheck),
('fence', _step_fence),
('promote', _step_promote),
('flip_sync', _step_flip_sync),
('verify', _step_verify),
('converge', _step_converge),
]
# ═══════════════════════════════════════════
# 逆向回滚
# ═══════════════════════════════════════════
async def _rollback(sor, ctx, completed_steps):
"""逆向回滚已完成步骤。返回 (ok, detail)。
回滚语义
fence 恢复原主start MySQL + VIP
promote 把新主降回从库CHANGE MASTER 指向原主需原主已恢复
flip_sync 反转回来
verify 无需回滚
converge 主备换回
"""
detail = []
ok_all = True
for step_name in reversed(completed_steps):
if step_name == 'fence':
# 恢复原主start MySQL + 还 VIP用 unfence 类型Agent 幂等执行)
cmd_id = await _send_command(sor, ctx['from_node_id'], 'unfence', 'unfence')
ok, _, out = await _wait_command(sor, cmd_id)
detail.append(f'unfence {ctx["from_node_id"]}: {"OK" if ok else "FAILED"} {out[:200]}')
ok_all = ok_all and ok
elif step_name == 'promote':
# 降回从库revert_promote重新 CHANGE MASTER 指向原主)
cmd_id = await _send_command(sor, ctx['to_node_id'], 'revert_promote', 'revert_promote')
ok, _, out = await _wait_command(sor, cmd_id)
detail.append(f'revert_promote {ctx["to_node_id"]}: {"OK" if ok else "FAILED"} {out[:200]}')
ok_all = ok_all and ok
elif step_name == 'flip_sync':
# 再反转一次即恢复
from_id, to_id = ctx['from_node_id'], ctx['to_node_id']
nodes = {}
for nid in (from_id, to_id):
recs = await sor.R('nodes', {'id': nid})
if recs:
nodes[nid] = getattr(recs[0], 'host', '')
from_host, to_host = nodes.get(from_id, ''), nodes.get(to_id, '')
await sor.sqlExe(
'UPDATE filesync_directories SET source_host=${fh}$, dest_host=${th}$, '
'updated_at=${now}$ WHERE cluster_id=${cid}$ AND source_host=${th}$',
{'fh': from_host, 'th': to_host, 'now': _now(), 'cid': ctx['cluster_id']})
detail.append('flip_sync reverted')
elif step_name == 'converge':
# 主备换回
await sor.U('clusters', {
'id': ctx['cluster_id'],
'master_node_id': ctx['from_node_id'],
'standby_node_id': ctx['to_node_id'],
'status': 'running',
'updated_at': _now(),
})
await sor.U('nodes', {'id': ctx['from_node_id'], 'role': 'master', 'updated_at': _now()})
await sor.U('nodes', {'id': ctx['to_node_id'], 'role': 'standby', 'updated_at': _now()})
detail.append('converge reverted')
# verify / precheck 无需回滚
return ok_all, '; '.join(detail)
# ═══════════════════════════════════════════
# 编排器主流程(六步状态机)
# ═══════════════════════════════════════════
async def run_switchover(switchover_id):
"""执行一次切换(在独立 asyncio task 中运行)。"""
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
logs = await sor.R('switchover_logs', {'id': switchover_id})
if not logs:
return
logrec = logs[0]
ctx = {
'switchover_id': switchover_id,
'cluster_id': getattr(logrec, 'cluster_id', ''),
'from_node_id': getattr(logrec, 'from_node_id', ''),
'to_node_id': getattr(logrec, 'to_node_id', ''),
}
completed = []
failed_at = None
fail_detail = ''
for seq, (step_name, fn) in enumerate(STEPS, start=1):
await _record_step(sor, switchover_id, seq, step_name, 'running')
try:
ok, detail = await fn(sor, ctx)
except Exception as e:
ok, detail = False, f'exception: {e}'
if ok:
await _record_step(sor, switchover_id, seq, step_name, 'success', output=detail)
completed.append(step_name)
else:
await _record_step(sor, switchover_id, seq, step_name, 'failed', error=detail)
failed_at = step_name
fail_detail = detail
break
if failed_at is None:
# 全部成功
await sor.U('switchover_logs', {
'id': switchover_id, 'status': 'completed',
'details': 'all steps success', 'completed_at': _now(),
})
return
# 有失败 → 逆向回滚
rb_ok, rb_detail = await _rollback(sor, ctx, completed)
final_status = 'completed' if rb_ok else 'rollback_failed'
# 回滚成功也意味着切换未达成 → 记 failed原切换失败但状态收敛
await sor.U('switchover_logs', {
'id': switchover_id,
'status': 'failed' if rb_ok else 'rollback_failed',
'details': f'failed at {failed_at}: {fail_detail} | rollback: {rb_detail}',
'completed_at': _now(),
})
# 集群恢复(回滚后主备未变)
await sor.U('clusters', {
'id': ctx['cluster_id'], 'status': 'running', 'updated_at': _now(),
})
# 回滚失败 → 最高级告警冒泡人工
if not rb_ok:
await _emit_alert(sor, ctx['cluster_id'], 'critical',
f'切换回滚失败,需人工介入:{rb_detail}')
async def _emit_alert(sor, cluster_id, severity, message):
"""写一条最高级告警到 hostwatch_events松耦合约定结构"""
try:
await sor.C('hostwatch_events', {
'id': getID(),
'rule_id': '',
'node_id': '',
'severity': severity,
'match_count': 1,
'sample_message': f'[FOMS cluster={cluster_id}] {message}'[:2000],
'status': 'open',
'notified': '0',
'first_seen_at': _now(),
'last_seen_at': _now(),
})
except Exception:
pass # 告警失败不阻断主流程
# ═══════════════════════════════════════════
# 对外入口:确认切换 / 健康判定器
# ═══════════════════════════════════════════
async def confirm_switchover(request, params_kw):
"""人工确认后启动切换(半自动模式的执行入口)。
入参cluster_id必填directionfailover/failback默认 failover
行为创建 switchover_logs(status=in_progress, trigger=manual_confirm)
+ 置集群 switching + 起异步任务执行六步
"""
env = ServerEnv()
cluster_id = params_kw.get('cluster_id', '') if hasattr(params_kw, 'get') else ''
direction = params_kw.get('direction', 'failover') if hasattr(params_kw, 'get') else 'failover'
if not cluster_id:
return {'status': 'error', 'message': 'cluster_id required'}
log_id = getID()
now = _now()
async with get_sor_context(env, DBNAME) as sor:
clusters = await sor.R('clusters', {'id': cluster_id})
if not clusters:
return {'status': 'error', 'message': '集群不存在'}
cluster = clusters[0]
master_id = getattr(cluster, 'master_node_id', '')
standby_id = getattr(cluster, 'standby_node_id', '')
if direction == 'failback':
from_id, to_id = standby_id, master_id
else:
from_id, to_id = master_id, standby_id
await sor.C('switchover_logs', {
'id': log_id,
'cluster_id': cluster_id,
'event_type': direction,
'from_node_id': from_id,
'to_node_id': to_id,
'trigger': 'manual_confirm',
'reason': params_kw.get('reason', '人工确认切换') if hasattr(params_kw, 'get') else '人工确认切换',
'status': 'in_progress',
'started_at': now,
'created_at': now,
})
await sor.U('clusters', {'id': cluster_id, 'status': 'switching', 'updated_at': now})
# 起异步任务执行(不阻塞 HTTP 响应)
asyncio.get_event_loop().create_task(run_switchover(log_id))
return {'status': 'ok', 'message': '切换已启动(异步执行)', 'log_id': log_id}
# ═══════════════════════════════════════════
# 健康判定器(后台循环 + 防抖 + 半自动告警)
# ═══════════════════════════════════════════
# 内存防抖计数:{cluster_id: 连续故障次数}
_fault_counters = {}
async def health_judge_loop():
"""后台健康判定:周期扫 active 集群,连续 N 次命中才判故障(防抖)。
判定条件任一
· master 心跳超时> failover_threshold * check_interval
· 复制状态 error/failed
判故障 switchover_logs(status=detected, trigger=auto_detect) + 告警
半自动只告警不切等人 confirm_switchover
"""
env = ServerEnv()
while True:
try:
async with get_sor_context(env, DBNAME) as sor:
clusters = await sor.sqlExe(
"SELECT id, master_node_id, check_interval, failover_threshold, status "
"FROM clusters WHERE status IN ('running','switching')", {})
for c in (clusters or []):
cid = getattr(c, 'id', '')
master_id = getattr(c, 'master_node_id', '')
interval = int(getattr(c, 'check_interval', 10) or 10)
threshold = int(getattr(c, 'failover_threshold', 3) or 3)
if not master_id:
continue
# 心跳超时判定
nodes = await sor.R('nodes', {'id': master_id})
heartbeat_lost = False
if nodes:
hb = _parse_dt(getattr(nodes[0], 'last_heartbeat', None))
if hb is None or (datetime.now() - hb).total_seconds() > threshold * interval:
heartbeat_lost = True
# 复制异常判定(松耦合读 dbbackup 表)
repl_bad = False
repls = await sor.sqlExe(
"SELECT id FROM dbbackup_replications WHERE cluster_id=${cid}$ "
"AND enabled='1' AND sync_status IN ('error','failed') LIMIT 1",
{'cid': cid})
if repls:
repl_bad = True
faulty = heartbeat_lost or repl_bad
if faulty:
_fault_counters[cid] = _fault_counters.get(cid, 0) + 1
if _fault_counters[cid] >= threshold:
# 达到阈值 → 判故障(防抖通过)
reason = ('心跳超时' if heartbeat_lost else '') + \
('+' if heartbeat_lost and repl_bad else '') + \
('复制异常' if repl_bad else '')
await sor.C('switchover_logs', {
'id': getID(),
'cluster_id': cid,
'event_type': 'fault_detected',
'from_node_id': master_id,
'to_node_id': '',
'trigger': 'auto_detect',
'reason': reason,
'status': 'detected',
'created_at': _now(),
})
await _emit_alert(sor, cid, 'critical',
f'主节点故障判定:{reason}(已连续{threshold}次),请人工确认切换')
# 重置计数,避免重复告警刷屏(下次再连续 threshold 次才再报)
_fault_counters[cid] = 0
else:
_fault_counters[cid] = 0
except Exception:
pass # 判定器自身异常不退出循环
await asyncio.sleep(JUDGE_INTERVAL)
def register_failover():
"""注册切换编排器到 ServerEnv + 启动健康判定器后台任务。幂等(防热重载重复起任务)。"""
global _failover_registered
env = ServerEnv()
env.confirm_switchover = confirm_switchover
if _failover_registered:
return True
_failover_registered = True
# 健康判定器后台任务(由 webapp 的 startup 机制拉起)
from ahserver.configuredServer import add_startup
add_startup(health_judge_loop())
return True

View File

@ -126,60 +126,31 @@ async def agent_heartbeat(request, params_kw):
# ═══════════════════════════════════════════
# 故障切换编排
# ═══════════════════════════════════════════
# 2026-08-26 起:切换不再是"只写日志",委托给 failover 编排器执行六步状态机
# precheck → fence → promote → flip_sync → verify → converge失败逆向回滚
# 触发模式为半自动1A+2B+3A健康判定器告警 → 人工确认 → 本入口执行。
async def trigger_switchover(request, params_kw):
"""触发故障切换 A→B"""
env = ServerEnv()
cluster_id = _get(params_kw, 'cluster_id')
reason = _get(params_kw, 'reason', '手动触发')
log_id = getID()
now = curDateString()
async with get_sor_context(env, DBNAME) as sor:
clusters = await sor.R('clusters', {'id': cluster_id})
if not clusters:
return {'status': 'error', 'message': '集群不存在'}
cluster = clusters[0]
await sor.C('switchover_logs', {
'id': log_id,
'cluster_id': cluster_id,
'event_type': 'failover',
'from_node_id': getattr(cluster, 'master_node_id', ''),
'to_node_id': getattr(cluster, 'standby_node_id', ''),
'trigger': 'manual',
'reason': reason,
'status': 'in_progress',
'started_at': now,
'created_at': now,
})
await sor.U('clusters', {'id': cluster_id, 'status': 'switching', 'updated_at': now})
return {'status': 'ok', 'message': '切换流程已触发', 'log_id': log_id}
"""触发故障切换 A→B委托编排器执行六步状态机"""
from foms.failover import confirm_switchover
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
ns['direction'] = 'failover'
result = await confirm_switchover(request, ns)
if result.get('status') == 'error':
return {'widgettype': 'Message', 'options': {'title': '失败', 'message': result.get('message', ''), 'type': 'error'}}
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': f"切换已启动异步执行事件ID{result.get('log_id', '')}", 'type': 'success'}}
async def trigger_switchback(request, params_kw):
"""触发切回 B→A"""
env = ServerEnv()
cluster_id = _get(params_kw, 'cluster_id')
log_id = getID()
now = curDateString()
async with get_sor_context(env, DBNAME) as sor:
clusters = await sor.R('clusters', {'id': cluster_id})
if not clusters:
return {'status': 'error', 'message': '集群不存在'}
cluster = clusters[0]
await sor.C('switchover_logs', {
'id': log_id,
'cluster_id': cluster_id,
'event_type': 'failback',
'from_node_id': getattr(cluster, 'standby_node_id', ''),
'to_node_id': getattr(cluster, 'master_node_id', ''),
'trigger': 'manual',
'reason': '手动切回',
'status': 'in_progress',
'started_at': now,
'created_at': now,
})
await sor.U('clusters', {'id': cluster_id, 'status': 'switching', 'updated_at': now})
return {'status': 'ok', 'message': '切回流程已触发', 'log_id': log_id}
"""触发切回 B→A委托编排器反向六步"""
from foms.failover import confirm_switchover
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
ns['direction'] = 'failback'
ns.setdefault('reason', '手动切回')
result = await confirm_switchover(request, ns)
if result.get('status') == 'error':
return {'widgettype': 'Message', 'options': {'title': '失败', 'message': result.get('message', ''), 'type': 'error'}}
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': f"切回已启动异步执行事件ID{result.get('log_id', '')}", 'type': 'success'}}
# ═══════════════════════════════════════════
@ -245,18 +216,20 @@ async def exec_remote_command(request, params_kw):
async def agent_poll_commands(request, params_kw):
"""Agent 轮询获取待执行命令"""
"""Agent 轮询获取待执行命令(含 command_type供 Agent 按类型路由)"""
env = ServerEnv()
node_id = _get(params_kw, 'node_id')
async with get_sor_context(env, DBNAME) as sor:
rows = await sor.sqlExe(
"SELECT id, command FROM node_commands WHERE node_id=${nid}$ AND status='pending' "
"SELECT id, command_type, command FROM node_commands WHERE node_id=${nid}$ AND status='pending' "
"ORDER BY created_at ASC LIMIT 5",
{'nid': node_id})
# 取到的命令标记为 running防止重复下发
for r in (rows or []):
await sor.U('node_commands', {'id': getattr(r, 'id', ''), 'status': 'running'})
return {'commands': [{'id': getattr(r, 'id', ''), 'command': getattr(r, 'command', '')}
return {'commands': [{'id': getattr(r, 'id', ''),
'command_type': getattr(r, 'command_type', 'shell') or 'shell',
'command': getattr(r, 'command', '')}
for r in (rows or [])]}
@ -306,4 +279,7 @@ def load_foms():
env.exec_remote_command = exec_remote_command
env.agent_poll_commands = agent_poll_commands
env.agent_report_command_result = agent_report_command_result
# 切换编排器 + 健康判定器(后台任务)
from foms.failover import register_failover
register_failover()
return True

View File

@ -15,6 +15,8 @@
{"name": "standby_node_id", "title": "备机节点", "type": "str", "length": 32},
{"name": "check_interval", "title": "检测间隔(秒)", "type": "int", "nullable": "no", "default": "10"},
{"name": "failover_threshold", "title": "故障判定阈值(次)", "type": "int", "nullable": "no", "default": "3"},
{"name": "vip", "title": "VIP地址(CIDR)", "type": "str", "length": 64},
{"name": "failover_mode", "title": "触发模式", "type": "str", "length": 16, "nullable": "no", "default": "semi"},
{"name": "status", "title": "集群状态", "type": "str", "length": 32, "nullable": "no", "default": "stopped"},
{"name": "created_at", "title": "创建时间", "type": "datetime"},
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
@ -24,6 +26,7 @@
],
"codes": [
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='cluster_status'"},
{"field": "failover_mode", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='failover_mode'"},
{"field": "master_node_id", "table": "nodes", "valuefield": "id", "textfield": "host"},
{"field": "standby_node_id", "table": "nodes", "valuefield": "id", "textfield": "host"}
]

View File

@ -10,6 +10,7 @@
"fields": [
{"name": "id", "title": "命令ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "node_id", "title": "节点ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "command_type", "title": "命令类型", "type": "str", "length": 32, "nullable": "no", "default": "shell"},
{"name": "command", "title": "命令内容", "type": "text", "nullable": "no"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"},
{"name": "exit_code", "title": "退出码", "type": "int"},
@ -22,6 +23,7 @@
{"name": "idx_ncmd_status", "idxtype": "index", "idxfields": ["status"]}
],
"codes": [
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='cmd_status'"}
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='cmd_status'"},
{"field": "command_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='cmd_type'"}
]
}

View File

@ -0,0 +1,29 @@
{
"summary": [
{
"name": "switchover_steps",
"title": "切换步骤明细",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "步骤ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "switchover_id", "title": "切换事件", "type": "str", "length": 32, "nullable": "no"},
{"name": "seq", "title": "步骤序号", "type": "int", "nullable": "no"},
{"name": "step_name", "title": "步骤名称", "type": "str", "length": 32, "nullable": "no"},
{"name": "status", "title": "步骤状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"},
{"name": "output", "title": "执行输出", "type": "text"},
{"name": "error", "title": "错误信息", "type": "text"},
{"name": "started_at", "title": "开始时间", "type": "datetime"},
{"name": "completed_at", "title": "完成时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_ss_switchover", "idxtype": "index", "idxfields": ["switchover_id"]},
{"name": "idx_ss_seq", "idxtype": "index", "idxfields": ["switchover_id", "seq"]}
],
"codes": [
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='step_status'"},
{"field": "switchover_id", "table": "switchover_logs", "valuefield": "id", "textfield": "event_type"}
]
}

View File

@ -34,6 +34,12 @@ NODE_ID = os.environ.get('FOMS_NODE_ID', '')
AGENT_TOKEN = os.environ.get('FOMS_AGENT_TOKEN', '')
HEARTBEAT_INTERVAL = int(os.environ.get('FOMS_HEARTBEAT_INTERVAL', '10'))
# ═══ Failover configuration切换动作专用═══
MYSQL_SERVICE = os.environ.get('FOMS_MYSQL_SERVICE', 'mysql')
VIP_ADDR = os.environ.get('FOMS_VIP', '') # 如 192.168.1.100/24
VIP_IFACE = os.environ.get('FOMS_VIP_IFACE', 'eth0')
MYSQL_ROOT_PW = os.environ.get('MYSQL_ROOT_PASSWORD', '')
# ═══ Logging ═══
logging.basicConfig(
level=logging.INFO,
@ -321,26 +327,176 @@ class FomsAgent:
else:
log.warning(f"Metrics report failed: {result}")
# ═══ Failover Actions切换动作脚本化 + 幂等)═══
def _run(self, argv, timeout=30):
"""执行单条命令,返回 (exit_code, output)。argv 为列表,不走 shell。"""
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
output = (proc.stdout + proc.stderr).strip()[:2000]
return proc.returncode, output
except subprocess.TimeoutExpired:
return -1, f'TIMEOUT ({timeout}s): {argv[0]}'
except Exception as e:
return -1, str(e)
def action_fence(self):
"""隔离本机(防脑裂):停 MySQL + 摘 VIP。幂等——已停/已摘时不报错。"""
results = []
# 1) 停 MySQL 服务systemd。已停止时 systemctl stop 幂等返回 0。
code, out = self._run(['systemctl', 'stop', MYSQL_SERVICE], timeout=60)
results.append({'step': 'stop_mysql', 'exit_code': code, 'output': out})
# 2) 摘 VIPip addr del。已不存在时返回非 0视为已摘除幂等成功
if VIP_ADDR:
code, out = self._run(['ip', 'addr', 'del', VIP_ADDR, 'dev', VIP_IFACE])
if code != 0 and 'Cannot find address' in out:
code, out = 0, 'VIP already removed (idempotent)'
results.append({'step': 'remove_vip', 'exit_code': code, 'output': out})
ok = all(r['exit_code'] == 0 for r in results)
log.info(f"fence: {'OK' if ok else 'FAILED'} {results}")
return {'ok': ok, 'actions': results}
def action_promote_master(self):
"""提升本机为新主STOP SLAVE + RESET SLAVE ALL。幂等——已是主库视为成功。"""
env_sql = ['mysql', '-uroot']
if MYSQL_ROOT_PW:
env_sql.append(f'-p{MYSQL_ROOT_PW}')
# 检查当前是否仍是 slave已是主库无 SLAVE STATUS则直接成功幂等
code, out = self._run(env_sql + ['-e', 'SHOW SLAVE STATUS\\G'])
if code == 0 and 'Slave_IO_State' not in out:
log.info("promote_master: already master (no slave status), idempotent OK")
return {'ok': True, 'actions': [{'step': 'already_master', 'exit_code': 0, 'output': out[:300]}]}
# 原子执行STOP SLAVE; RESET SLAVE ALL;(单条 -e 保证原子)
promote_sql = 'STOP SLAVE; RESET SLAVE ALL;'
code, out = self._run(env_sql + ['-e', promote_sql], timeout=60)
results = [{'step': 'stop_reset_slave', 'exit_code': code, 'output': out}]
# 校验:确认不再有 slave 状态
vcode, vout = self._run(env_sql + ['-e', 'SHOW SLAVE STATUS\\G'])
promoted = (vcode == 0 and 'Slave_IO_State' not in vout)
results.append({'step': 'verify_no_slave', 'exit_code': 0 if promoted else 1,
'output': ('promoted' if promoted else vout[:300])})
ok = (code == 0 and promoted)
log.info(f"promote_master: {'OK' if ok else 'FAILED'} {results}")
return {'ok': ok, 'actions': results}
def action_flip_sync(self):
"""反转/暂停文件同步方向。通过通知管理中心翻转 filesync 配置方向实现。
本动作只做本机侧的确认与暂停返回当前同步配置状态由编排器在 DB 层反转"""
results = [{'step': 'flip_sync_ack', 'exit_code': 0,
'output': f'node={self.node_id} ready_for_flip'}]
log.info(f"flip_sync: ACK {results}")
return {'ok': True, 'actions': results}
def action_unfence(self):
"""恢复本机(回滚用):启动 MySQL + 还 VIP。幂等——已启动/已挂时不报错。"""
results = []
# 1) 启动 MySQL 服务systemd。已运行时 systemctl start 幂等返回 0。
code, out = self._run(['systemctl', 'start', MYSQL_SERVICE], timeout=60)
results.append({'step': 'start_mysql', 'exit_code': code, 'output': out})
# 2) 挂回 VIPip addr add。已存在时返回非 0视为已挂载幂等成功
if VIP_ADDR:
code, out = self._run(['ip', 'addr', 'add', VIP_ADDR, 'dev', VIP_IFACE])
if code != 0 and 'File exists' in out:
code, out = 0, 'VIP already present (idempotent)'
results.append({'step': 'add_vip', 'exit_code': code, 'output': out})
ok = all(r['exit_code'] == 0 for r in results)
log.info(f"unfence: {'OK' if ok else 'FAILED'} {results}")
return {'ok': ok, 'actions': results}
def action_revert_promote(self):
"""降回从库(回滚用):重新 CHANGE MASTER 指向原主。
需要原主连接信息从本机复制配置读取若原主已恢复
幂等若本机已是 slave 则直接成功"""
env_sql = ['mysql', '-uroot']
if MYSQL_ROOT_PW:
env_sql.append(f'-p{MYSQL_ROOT_PW}')
# 已是 slave → 幂等成功
code, out = self._run(env_sql + ['-e', 'SHOW SLAVE STATUS\\G'])
if code == 0 and 'Slave_IO_State' in out:
log.info("revert_promote: already slave, idempotent OK")
return {'ok': True, 'actions': [{'step': 'already_slave', 'exit_code': 0, 'output': out[:300]}]}
# 读取本机复制配置CHANGE MASTER 需要原主地址)。
# 优先从环境变量 FOMS_MASTER_HOST/PORT 获取(部署时配置),否则无法回滚。
master_host = os.environ.get('FOMS_MASTER_HOST', '')
master_port = os.environ.get('FOMS_MASTER_PORT', '3306')
repl_user = os.environ.get('FOMS_REPL_USER', 'repl')
repl_pass = os.environ.get('FOMS_REPL_PASSWORD', '')
if not master_host:
return {'ok': False, 'actions': [{'step': 'revert_promote', 'exit_code': 1,
'output': 'FOMS_MASTER_HOST not set, cannot revert'}]}
change_sql = (f"CHANGE MASTER TO MASTER_HOST='{master_host}', MASTER_PORT={master_port}, "
f"MASTER_USER='{repl_user}', MASTER_PASSWORD='{repl_pass}', MASTER_AUTO_POSITION=1; "
f"START SLAVE;")
code, out = self._run(env_sql + ['-e', change_sql], timeout=60)
results = [{'step': 'change_master_start_slave', 'exit_code': code, 'output': out}]
ok = (code == 0)
log.info(f"revert_promote: {'OK' if ok else 'FAILED'} {results}")
return {'ok': ok, 'actions': results}
def action_verify(self):
"""校验本机可写 + VIP 归属(切换后验证)。业务层留人工确认。"""
results = []
env_sql = ['mysql', '-uroot']
if MYSQL_ROOT_PW:
env_sql.append(f'-p{MYSQL_ROOT_PW}')
# 1) MySQL 可写测试:建临时表写入再删除
test_sql = ("CREATE TABLE IF NOT EXISTS _foms_verify (id INT); "
"INSERT INTO _foms_verify VALUES (1); "
"DROP TABLE _foms_verify;")
code, out = self._run(env_sql + ['-e', test_sql], timeout=30)
results.append({'step': 'mysql_write_test', 'exit_code': code, 'output': out[:300]})
# 2) VIP 归属检查(若配置了 VIP
if VIP_ADDR:
vip_ip = VIP_ADDR.split('/')[0]
code, out = self._run(['ip', 'addr', 'show', VIP_IFACE])
vip_here = (code == 0 and vip_ip in out)
results.append({'step': 'vip_check', 'exit_code': 0 if vip_here else 1,
'output': f'vip={vip_ip} on {VIP_IFACE}: {"YES" if vip_here else "NO"}'})
ok = all(r['exit_code'] == 0 for r in results)
log.info(f"verify: {'OK' if ok else 'FAILED'} {results}")
return {'ok': ok, 'actions': results}
# ═══ Command dispatcher按 command_type 路由)═══
def dispatch_command(self, cmd_id, command_type, command):
"""根据 command_type 分发执行,上报结构化结果。"""
if command_type == 'fence':
res = self.action_fence()
elif command_type == 'unfence':
res = self.action_unfence()
elif command_type == 'promote_master':
res = self.action_promote_master()
elif command_type == 'revert_promote':
res = self.action_revert_promote()
elif command_type == 'flip_sync':
res = self.action_flip_sync()
elif command_type == 'verify':
res = self.action_verify()
else:
# 普通 shell 命令(保留原有行为)
proc = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
res = {'ok': proc.returncode == 0,
'actions': [{'step': 'shell', 'exit_code': proc.returncode,
'output': (proc.stdout + proc.stderr)[:2000]}]}
exit_code = 0 if res.get('ok') else 1
output = json.dumps(res, ensure_ascii=False)[:2000]
self._api_post('agent_report_command_result.dspy', {
'node_id': self.node_id, 'cmd_id': cmd_id,
'output': output, 'exit_code': exit_code,
})
log.info(f"Command [{cmd_id}] type={command_type} done: exit={exit_code}")
# ═══ Remote Command Execution ═══
def poll_and_execute_commands(self):
"""Poll management for pending commands, execute, report results"""
"""Poll management for pending commands, dispatch by type, report results"""
result = self._api_get('agent_poll_commands.dspy?node_id=' + self.node_id)
if not result or not result.get('commands'):
return
for cmd in result['commands']:
cmd_id = cmd.get('id', '')
command_type = cmd.get('command_type', 'shell')
command = cmd.get('command', '')
log.info(f"Executing command [{cmd_id}]: {command}")
log.info(f"Executing command [{cmd_id}] type={command_type}")
try:
proc = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
output = (proc.stdout + proc.stderr)[:2000]
self._api_post('agent_report_command_result.dspy', {
'node_id': self.node_id,
'cmd_id': cmd_id,
'output': output,
'exit_code': proc.returncode,
})
log.info(f"Command [{cmd_id}] done: exit={proc.returncode}")
self.dispatch_command(cmd_id, command_type, command)
except subprocess.TimeoutExpired:
self._api_post('agent_report_command_result.dspy', {
'node_id': self.node_id, 'cmd_id': cmd_id,