foms/foms/failover.py
yumoqing fa257f36b1 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);注册链完整。
2026-08-26 18:33:42 +08:00

505 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FOMS 切换编排器 — 六步状态机 + 逆向回滚 + 防脑裂不变量。
设计2026-08-26 定调1A+2B+3A
· 流量切换VIP 浮动keepalived 兜底)
· 触发模式:半自动(健康判定器告警 + 人工确认后切)
· 防脑裂fence隔离原主先于 promote立新主fence 失败硬中止
切换六步failover A→B
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