外观
Flap 检测
本文按一条主线讲清 fleet 的 BGP flap 检测:先定义什么是抖动,再讲怎么观测、怎么算分与定位根因,最后讲怎么验证算法准确、以及检测结果如何变成人工处置。 会话级与前缀级是同一现象的两个粒度,不作分层叙述,而是贯穿在每个主题里。健康推导(五态)见 control-server.md;排错入口见 ../guides/monitoring-and-troubleshooting.md;踩过的坑归档在 postmortems.md。
一、什么是 flap(抖动的定义)
BGP flap 指路由状态在短时间内反复变化。它有两个粒度,现象与可观测面完全不同:
- 会话级抖动:一条 BGP 会话反复断建(
Established ↔ 非 Established)。会话每断一次,它承载的全部路由都随之撤销/重宣,影响面大而粗。 - 前缀级抖动:会话本身稳如常,但对端反复宣告/撤回某几条前缀,或反复改这些前缀的
AS_PATH/ next-hop。会话层完全看不见,必须贴着每一条 UPDATE 才能抓到,而且要能归因——抖动是从哪家对端进来的、前缀在哪里起源。
抖动检测的产物有三种形态,从"当下强度"到"历史事实"再到"根因":
| 形态 | 粒度 | 数据源 | agent 改动 | 节奏 |
|---|---|---|---|---|
| 衰减分(会话) | BGP 会话 | runtime snapshot 的 bgp_protocols | 零 | ~5 min |
| 衰减分(前缀) | 前缀 × 归因对端 | 旁路 flapfeed 采集会话(agent 进程内) | 零(exabgp 编解码库随 wheel 依赖装) | ~60 s 上报,秒级感知 |
| 告警事件 | 会话 / 前缀 | 上两者衰减分的边沿 | 零 | 60 s 评估循环 |
| 根因定位 | 前缀 | 前缀级路径证据的图分析 | 零 | 读时算 |
| 时序存档 | fleet(速率按节点) | 累计计数差分 + 评估循环落点 | 零 | 60 s 桶,留 60 天 |
代码里这几种形态沿用
P0(会话)/P1(前缀)/P2(告警)作简写标签,端点名也带这层含义(bgp-flaps/prefix-flaps/flap-alerts)。本文不以此分层组织,只在需要时用作定位标签。
三条设计原则
贯穿全部检测逻辑,读代码前先记住:
- 只观测、只告警,不自动抑制。 检测层绝不参与对账 / apply,也绝不自动改路由。发现抖动只在榜单和告警流里透出;任何抑制动作(拒收前缀、断开对端)都是操作员显式触发的处置,声明式下发、可撤销。一次误判不会自己放大成事故。
- 指数衰减打分,而非固定窗口滞回。 每次 flap 事件给对应主体
+1,分数随时间按半衰期连续衰减。好处:有记忆(短时间多次抖动累加)、单参可调(只有半衰期一个旋钮)、消警自然(不再抖,几个半衰期后自己滑落)。分数锚定在最近一次事件时刻,读取时从锚点续衰到now——不需要后台定时器"降温",榜单读一次就是当下值。 - 宁可漏检,不可虚报。 采集失败、快照乱序、时间戳分辨率不足、会话重建的全表重灌——凡是分不清"真抖动"还是"观测假象"的场合,一律不计分。得到的分数是抖动强度的下界。
二、检测方法(观测与数据源)
两个粒度各有一条独立的观测链路,都不触碰收敛路径。
会话级:纯控制面从 runtime snapshot 推导
零 agent 变更。 数据源是 agent 本就在上报的 runtime snapshot 里的 bgp_protocols(BIRD show protocols 结果,约每 5 min 一份)。每份快照入库时,FlapStore.record_snapshot(services/flaps.py)把每条会话的 state / since 与上一份观测(存在 bgp_flap_state,每 (节点, bird协议名) 一行)逐一比对。入库钩子在 agent_http.py 的快照接收里。
观测保真的两道闸:
- 只有
bgp_observation == OBSERVED的快照参与比对。UNAVAILABLE(采集失败)、NOT_OBSERVED(无观察器)整份跳过——采集失败绝不能被当成"所有会话都消失了"。 - 乱序 / 重复快照(
captured_at不晚于该节点现存基准的最新时刻)整份跳过:表级操作("已删除"判定、首见建行)不能被迟到的重放快照驱动,否则它没见过的新会话会被连同累积分数一起误删(主危害:分数清零 → 漏检),已删行会被过期观测复活。
OBSERVED 的快照被当作权威:比对基准里有、但这份快照里没有的会话,视为已从配置删除,bgp_flap_state 行同步删除(转移历史仍保留)。
前缀级:节点侧 flapfeed 旁路采集
前缀级抖动的分辨率必须做在节点上、贴着 BIRD 实时看每一条 UPDATE。整条旁路管线独立于 reconcile:
BIRD (全表全路径, add paths tx, import none)
│ passive eBGP, 假 AS 65001
▼
flapfeed_bgp.FlapfeedSession (agent 内线程, 主动拨入 router-netns 的 underlay IPv6 :179,
│ AddPath receive; UPDATE 用 exabgp 5.0.9 库解码)
│ 解出的 NLRI/AS_PATH 进程内直喂计分器
▼
flapfeed_scoring.PrefixFlapScorer (纯计分, 带锁, agent 上报循环直接读快照)
│ POST 控制面
▼
控制面 PrefixFlapStore (热态整份 SET 进 Redis/KV blob; SQL 只落 meta + 速率存档)代码: 节点侧 flapfeed.py(管线监管)+ flapfeed_bgp.py(进程内 BGP 采集会话)+ flapfeed_scoring.py(计分器);控制面侧 services/prefix_flaps.py + db/models/prefix_flap.py。
各段的关键约束:
- BIRD 侧是一条 passive eBGP 协议(假 AS
65001,对端 = 宿主 underlay IPv6 网关),add paths tx; import none; export all——把全表全部路径喂出来,import none保证对 BIRD 自己的选路零影响。写法是往 rendered bird 目录丢一个local_flapfeed.conf,借bird.conf末尾的include "/etc/bird/local*.conf"钩子加载,不改任何现有渲染文件。必须是 eBGP:iBGP 不会把 iBGP 学来的路由反射出去,看不到全表。 - 采集会话是 agent 进程内的一个线程(
flapfeed_bgp.py)。它主动连 router-netns 容器的 underlay IPv6:179(实测宿主→netns 的 v4:179 被挡、v6 OPEN 可通),源地址绑 underlay 网关(BIRD 侧neighbor只认它)。会话层自写的只有 TCP 分帧 + OPEN/KEEPALIVE 定时(约 200 行);能力协商与 UPDATE 解码全部调exabgp==5.0.9的报文解析层(Capabilities().new/Negotiated/Update.unpack_message),与旧 ExaBGP 子进程时代同一套解析代码,语义零漂移。exabgp 是 agent wheel 的钉版依赖(纯 Python 零依赖,随 manifest 离线分发),不再需要宿主独立 venv。 - 优雅降级:exabgp 库不可导入 / underlay 没启 IPv6——任一前置条件不满足,
FlapfeedManager整体旁路,并声明式清掉 BIRD 侧的 flapfeed 协议,保持"未启用"的干净状态。 - 历史形态(≤1.0.190):宿主独立 venv 的 ExaBGP 子进程 + JSON 管道 + sink api 子进程 + 快照文件手递手。升级时新 agent 会按旧 pidfile 收割遗留的 ExaBGP 孤儿(它占着 BIRD 会话,不杀掉新会话建不起来)。
控制面 PrefixFlapStore.record_report 把打分条目 + 路径证据当作当下热态整份写入 Redis 单键 blob pflap:<node>(每报一次 SET,TTL 7 天,app/services/prefix_flaps.py:326-466);SQL 只 upsert node_prefix_flap_meta(喂送会话状态、累计变化数、生成时刻——区分"没有抖动"和"喂送断了看不见")并落 60 s 速率存档。主控制面(TS 面)存 KV 同名键,blob 结构逐字段一致(apps/control-server-ts/src/services/prefixFlaps.ts)。Redis/KV 未配置或热路径故障时,写侧自动回退旧 SQL 整表替换路径(node_prefix_flaps,delete + bulk insert),读侧按"blob 缺失即回退该节点旧 SQL 行"装载(load_current_flap_state,prefix_flaps.py:107-155)。迟到 / 重复报告(captured_at 不晚于基准——热路径看 blob、旧路径看 meta)整份跳过——整份替换语义下,乱序回退会复活旧打分、回拨新鲜度。⚠️ ASN 列必须 BigInteger(32-bit ASN 溢出 int32)。会话状态(feed_state)由会话线程实时翻转,established / down 直接透出。
⚠️ 会话重建必须清空路径表(2026-07-06 实测大坑)。flapfeed 会话一旦离开 established,
_paths.clear()立即执行。原因:AddPath 的path_id是会话作用域的,会话重建后全表重灌时 path-id 可能整体洗牌;若残留旧映射,重灌会被逐条误判成"路径实变",把本网全部内部前缀瞬间刷上抖动榜。清空后,重建的全表重灌按首灌处理(不计分),而真实抖动的分数仍在_scores里按半衰期正常消退,不受影响。
存储分层:热态 blob 与持久层
前缀级检测产物按变化节奏分两层存储(动机注释见 app/services/prefix_flaps.py:68-74):
| 层 | 内容 | 介质 | 节奏 |
|---|---|---|---|
| 热态 | 打分条目 + 路径证据(读时续衰的当下视图) | Python 面 Redis / TS 面 KV,单键 blob pflap:<node>,TTL 7 天(_PFLAP_TTL_S) | 每报一次整份 SET(~60 s) |
| 持久 | node_prefix_flap_meta(喂送健康度)+ prefix_flap_rate_rollup(60 s 速率存档) | SQL | 写率低,需持久时序 |
拆层依据:打分条目每 ~60 s 整份替换,放 SQL 每月产生 1.5 亿+ 行写——对 PG 是无谓 churn,对按行计费的目标形态不可负担;而热态本质是丢了等下一份报告即重建的当下快照,天然适合缓存介质。两面 blob 结构逐字段一致(Python _record_report_hot / TS src/services/prefixFlaps.ts),差分与乱序守卫语义同参。
回退语义:Redis/KV 未配置或热路径出错时,写侧落回旧 SQL 整表替换路径(node_prefix_flaps),报告不丢;读侧逐节点装载 blob,缺失的节点(热路径未启用 / 尚未再次上报)回退该节点的旧 SQL 行(load_current_flap_state,prefix_flaps.py:107-155)。测试与降级共用旧路径,行为不变。
三、算法
三部分:所有粒度共用的衰减打分核、判定"什么算一次抖动"的计分规则、以及在前缀路径证据上做的根因定位。加分与消警的边沿被固化成持久告警。
衰减打分的数学
所有粒度共用同一个衰减核。设某主体在时刻 $t_0$ 的分数为 $s_0$,半衰期为 $T$,则任意 $t \ge t_0$ 的当前分为:
$$s(t) = s_0 \cdot 2^{-(t - t_0)/T}$$
代码里就是这一行(各粒度各有一份,参数不同):
python
def _decayed(score, anchor_iso, now_epoch):
anchor = parse_iso_utc(anchor_iso)
if anchor is None or now_epoch <= anchor.timestamp():
return score # 锚点缺失 / 超前:不衰减
elapsed = now_epoch - anchor.timestamp()
return score * 0.5 ** (elapsed / _HALF_LIFE_S)每来一次 flap 事件,先把旧分衰减到事件时刻,再 +1、把锚点前移到当下:
$$s_{\text{new}} = s_{\text{old}} \cdot 2^{-\Delta/T} + 1$$
稳态分。 若某主体以固定间隔 $\tau$ 持续抖动(速率 $r = 1/\tau$),分数不会无限涨,而是收敛到稳态值。把每次事件当作一次 +1 冲激、其后按半衰期衰减,稳态是等比级数求和:
$$s_\infty = \frac{1}{1 - 2^{-\tau/T}} \approx \frac{r \cdot T}{\ln 2} = \frac{T}{\tau \ln 2}$$
这个闭式是阈值定标的依据——想让"每 15 s 抖一次"刚好压过报警线,就反解出半衰期与阈值。判定"抖动中"就是拿当前分和阈值比:$s(t) \ge \text{THRESHOLD}$。
两组参数:
- 会话级:半衰期
1800 s(30 min),阈值3.0(需连续 4 份抖动快照 ~20 min 过线,3 次断连任意间隔下都不过线)。 - 前缀级:半衰期
600 s(10 min),阈值20.0。前缀抖动节奏远快(单前缀可秒级抖),故半衰期取小;阈值 20 ≈"每 44 s 抖一次并持续"的稳态,"每 20 s 一次"的稳态 $\approx 600/(20\ln2) \approx 43$、约 9 min 过线,阈值放其一半留余量。前缀级半衰期在 agent 计分器与控制面两侧各有一份,必须同参,否则打分口径分叉(阈值只在控制面读侧判定,agent 不感知)。
上述阈值与会话级半衰期可经配置文件 [flap] 段调整(默认即上述数值,三个评估方装配时自动同参;见 配置参考 §1.1);前缀级半衰期因 agent 耦合刻意不可调。
为什么锚定 + 续衰,而不是定时降温? 分数只在两个时刻被触碰:事件到来时(加分并重锚)、读取时(从锚点续衰到
now)。中间不需要后台任务周期性下调。"不再抖的会话自动滑出榜单"是读取时续衰的自然结果,零维护成本。
什么算一次「变化」
计分只认真实抖动,存疑一律不计——这是"宁可漏检"原则的落地。
会话级两类事件各计一次 +1:
- 离开 Established:
state实变且原 state 是 Established——会话断了。 - 隐性重连:前后两份快照 state 都是 Established,但
since变了——~5 min 间隔内会话至少断建了一整轮,快照节奏抓不到中间态,since(本次建立时刻)是唯一痕迹。
会话级不计分的情况:
- 非 Established 状态间换挡(
Active → Connect → OpenSent…)是重试的机械噪声——计入transitions_total但既不计分也不写历史行:一条长期 down 的会话每份快照换一挡、一天 ~288 条,不到一天就把 200 条限额内的真实 flap 历史挤光。 - 恢复(
→ Established)本身不计分,否则一次断连会被计两次(断 + 恢复)。规则:只有原 state 是 Established(或判定为隐性重连)才scored。 - 隐性重连只在两侧
since都是带时分秒的完整时间戳时才判定(":" in since)。旧 agent / 旧 timeformat 可能只给日期,分辨率不足;"日期↔完整时间戳"形态切换还会造成字符串假变化——宁可漏检。 - 某会话首次出现时只立比对基准,不计转移(没有"之前"可比)。
转移历史 bgp_flap_transitions append-only,kind ∈ {state, restart},只落涉及 Established 的转移,每节点裁剪到最近 200 条。captured_at 记的是观测到转移的快照时刻,非转移实际发生时刻——分辨率就是快照节奏。
前缀级对某 (prefix, 归因peer) 执行 _bump → +1(这就是榜单 changes / total_changes「路由变化次数」的口径):
- withdraw:某条路径被撤回(
_paths里有记录,pop 出来,按它记下的归因对端计分)。 - 路径实变:同一
(family, prefix, path_id)重新宣告,但路径指纹 = hash((AS_PATH, next-hop))变了。
前缀级不计分的情况:
- 同路径的重复宣告(指纹没变)——刷新 / 属性噪声。
- 首见(
_paths里没有该 key):采集会话建连时的全表首灌天然没有 withdraw、也没有"之前的指纹"可比,首灌一条都不计。分数从零开始,只随真实抖动增长。
低于 PRUNE_SCORE = 0.5 的条目直接从计分表剪掉(防内存无界),落盘取 Top 200。
changes是里程表、score是时速表。changes(=total_changes,「路由变化次数」)是不衰减的累计计数,反映该路由历史上抖过多少次;score是指数衰减的当下强度,不抖了会自己滑落。一条前缀可能changes=19828但此刻score已很低。
归因:第一跳=对端,最后一跳=起源
AddPath 让计分器看到 BIRD 的每一条路径。每条按 AS_PATH 剥掉本节点自身 AS 的前缀后——第一跳归因到直接对端(_attribute_peer,"抖动从哪家入向进来"),最后一跳归因为起源(_attribute_origin,"前缀在哪里起源");自起源 / 空路径归 0(未归因)。withdraw 消息不带 AS_PATH,所以宣告时就把 path_id → (归因, 起源, AS_PATH) 记进 _paths,撤回时回查。
计分器为此维护四张表(后两张服务根因定位,只对上榜抖动前缀攒、随分数剪枝):
_paths: (family, prefix, path_id) → (归因 peer_asn, 起源 origin_asn, 路径指纹, AS_PATH)
_scores: (prefix, peer_asn) → [score, anchor_ts, changes, last_change_ts, origin_asn]
_path_churn: (prefix, AS_PATH) → [churn, anchor] # 每条 distinct 路径的抖动权重
_prefix_events: prefix → [changes, withdrawn, anchor] # 前缀级总变化 / 彻底消失计数每次"变化"除了给 (prefix, peer) 打分,还给卷入的路径记 churn(新旧路径各 +1、同半衰期衰减)、给前缀记事件(总变化 +1,若这次导致该前缀在本节点再无任何在宣告路径则 withdrawn +1)。dump 时对每条上榜前缀导出 ≤12 条 distinct AS_PATH(优先在宣告的幸存路径,再按 churn 补——定位靠稳定幸存路径给起源免责,不能被高 churn 挤掉)+ 前缀级 changes/withdrawn,作为根因定位的证据随上报送控制面。老 agent 不发这些字段,控制面忽略,纯向后兼容。
跨节点归组 = fleet 级关联
同一条路由抖动通常被多个节点同时看见(iBGP 传播)。读取时按 (prefix, peer_asn) 跨节点归组,是 fleet 级关联的第一步:
fleet_view(min_score, limit):前缀 flap 榜。每组取跨节点max_score排序,nodes列出哪些节点看到、各自分数;另附feeds(各节点喂送健康度 + 上报新鲜度,超 300 s 标stale)。peer_aggregate(min_score):"哪家对端最抖"榜的数据层。先按(前缀, 对端)归组取跨节点 max(与 fleet 榜同口径),再按对端 AS 收拢,返回每家的prefix_count / prefixes_flapping / score_max / nodes / last_change_at。未归因(peer_asn = 0)不参与。
根因定位(判别性图元差集)
榜单只能说明"这条前缀在抖",但抖的根因在哪、能不能动手是另一回事:源头 AS 在反复宣告/撤回(抖动传播到本网)、中段某条传输链路在翻、还是本侧入向对端在抖?三者处置策略完全不同——源头拒收无用只能告警,近端能断,中段能精确点名链路。定位把它当作 AS 图上的根因定位。
代码: services/flap_localize.py(纯函数定位器)+ prefix_flaps.py 的 localize_flapping(跨节点聚合路径证据)+ db/models/prefix_flap.py 的 node_prefix_flap_paths(每 (节点, 前缀) 一行 JSON 存路径证据,按节点整表替换,迁移 f0a1b2c3d4e5)。
核心思路——判别性图元差集。 把一条前缀的 fleet 路径观测(跨节点求并:同 AS_PATH 的 churn 相加、记录哪些节点看到、withdrawn/changes 求和)分成抖的路径集与稳的路径集(churn 是否 >0),找那个同时满足"载多数 churn"且"出现在多数抖动路径"、又在稳路径里少出现的图元(AS 邻接边 / 入向对端 / 起源)——它就是嫌疑点。参 Feldmann et al., Locating Internet Routing Instabilities, SIGCOMM'04。
判别流程(localize):
- 折叠 prepend:AS_PATH 里连续重复的 AS(
A B B B C → A B C)是选路手段、不是拓扑跳。不折叠会冒出(X, X)自环边,只在抖动路径出现故判别分最高、被误当中段链路(实测某 hub prepend 5 次 →culprit_edge=(X,X))。 - MOAS:多个不同起源同时出现 = 路由异常/疑似劫持,绝不自动动手。内嵌进榜行时以行的 origins 为权威(
zone==moas ⟺ 行 moas,同一 origin 集算出、结构自洽)。 - 源头强信号:前缀彻底消失(无幸存路径)且窗口内反复 withdraw → 唯一公共点是起源 →
origin。 - 判别性图元:判别分 =
min(churn质量占比, 抖动路径条数占比) − 稳定占比。两者取小是关键:高介数 hub 在海量路径上当中转,churn"经过它"是拓扑必然(churn占比高)但只在少数路径(路径占比低)→ 被压低,不背锅。 - 散布 → 源头:没有图元能同时解释多数 churn 和多数抖动路径 → churn 散布、共同点是起源 →
origin(即便有一条备份幸存路径把起源"免责"——多入向齐抖到同一源,病在源侧)。 - 起源免责:有稳定路径还到得了起源 → 起源被证明在线,把它及贴着它的边排除出候选,聚焦近端/中段。
四个区与可操作性:
| zone | 判据 | 处置 |
|---|---|---|
origin | 全撤回 / 多入向散布汇一源 | 源头侧,拒收无用——只告警 |
near | 抖动集中在同一入向对端 | 可断该 peer(须 P0 门控,见下) |
path | 中段某条 AS 邻接在翻 | culprit_edge=[A,B] 精确点名 |
moas | 多源 | 疑似劫持,绝不自动动手 |
near 用会话级信号门控(在端点做,_p0_session_score_by_asn):真"近端"应是本节点到该对端的会话也在抖(会话级 flap,来自 runtime 快照、与 flapfeed 完全独立)。若会话不抖,则那条"churn 集中经某对端"多半是该对端的下游/onward 在抖、不是本侧会话——signals.session_flapping=false,置信 ×0.6,不可当断 peer。
输出:{zone, confidence, culprit_asn/culprit_edge, representative_path, signals}。representative_path 是该前缀 churn 最高的一条 AS_PATH(已折叠 prepend),供前端画路径条、高亮嫌疑段。confidence 混合判别分 + 节点多样性 + 路径多样性——单节点观测的 path 天然低置信(一个 vantage 看不全路径多样性,中段定位多是猜)。
内嵌 = 单一数据源。 定位结果内嵌进 GET /ui/fleet/prefix-flaps?group=prefix 每行的可选 localization 对象,与该行同一次查询、同一快照算出——前端不再拼两个接口(历史上 join 两接口按 prefix 对齐,产生成员集/快照时序/moas 口径三重不一致)。localization=null = 该行未能定位。顶层 localization_summary 给全量 fleet(含被 limit 截断的部分)区计数。独立 GET /ui/fleet/prefix-flaps/localize 端点保留他用。纯只读、只标注不动手——是未来自动抑制决策引擎的输入(届时才会:near+session_flapping 允许断 peer、origin 只告警、path 精确点名、moas 永不动手)。
把边沿固化成持久告警
衰减分是"当下"视图:抖动停了、分数消退,历史就不可见了。告警事件流把"进入 / 退出抖动"固化成持久边沿事件,即便现在不抖,也能回看"昨晚 3 点这条会话抖过 20 分钟"。
代码: services/flap_alerts.py + FlapAlert 模型。一个背景循环(run_loop,60 s 一轮)对会话级和前缀级各算一遍当前衰减分(复用两处导出的同一套半衰期常量,避免口径分叉),做边沿判定:
- 上穿告警线且当前无在开告警 → 开一条(记
started_at、peak_score、波及节点)。 - 下穿消警线(= 告警线的一半,迟滞设计)→ 关该条(记
resolved_at)。消警线取一半,让告警线附近来回波动的分数不开关乒乓。 - 在开期间持续刷新
last_score/peak_score/ 波及节点集合(前缀级取持续期间看到该抖动的节点并集)。
告警线与榜单线分层。 会话级两线同参(3.0,会话断建少而重);前缀级榜单「flapping」线 20 只作可观测标记,开单线单独抬到 100(PREFIX_ALERT_THRESHOLD ≈ 持续每分钟 ~7 次计分变化;两线均可经 [flap] 配置段调整)。依据是生产实测:DN42 低烈度背景抖动是常态,按榜单线开单积出 283 条常开告警(峰值中位数才 54、最老 3 天不消),告警失去事件含义;≥100 的仅 ~26 条,才是人能逐条处置的量级。对照竞品 FlapAlerted 的默认告警参数(600 次/分钟且连续 10 分钟),分层后的 100 仍然灵敏得多——它的默认调法连持续 290 次/分的风暴都不触发。
告警身份:kind=session 定位 (node_id, 会话名);kind=prefix 定位 (前缀, 归因对端 AS),按跨节点归组后的 max 分数判定,与 fleet 榜同口径。node_id 不设外键——告警是历史事实,不随节点删除而级联蒸发。已消警历史按条数截断保留最近 500 条。evaluate_once 返回 {opened, resolved, active, tracked_prefixes, flapping_prefixes, flapping_sessions} 供日志 / 测试;单轮评估失败只记日志,不拖垮控制面。
速率与时序(可视化数据层)
衰减分回答「现在抖多凶」,告警回答「什么时候抖过」,但都画不出「变化速率随时间」的曲线——打分表是整表替换的当下快照,历史桶必须单独存。这一层完全构建在累计计数差分上,零 agent 变更:
- 速率存档(
prefix_flap_rate_rollup,每 (节点, 60s 桶) 一行,保留 60 天)。收报时对上一份报告差分两条线:喂送级total_changes(该节点看到的全部计分事件,含未上榜前缀)与榜内同键条目的changes之和(只算两份报告都在榜的键——新上榜键无基准不计)。对应竞品 FlapAlerted 的 Route Changes 总量线 / listed 线。 - 榜行速率与时长。同一次差分把
recent_changes/rate_per_s落在条目行上(榜单 Rate 列,读时跨节点求和、与total_changes同口径);「抖了多久」(Duration 列)不另存——fleet_view就地 join 同键在开告警的started_at(上穿时刻即抖动起点),得flapping_since/flap_duration_s。告警线(100)高于榜单线(20),故中间带的行flapping=true但 duration 为 null——duration 只对告警级强度有值。 - 活跃度时间线(
flap_activity_rollup,fleet 级每 60s 桶一行,保留 60 天)。告警评估循环本来就把两层分数全算一遍,顺手数出在跟踪 / 过阈前缀数、过阈会话数、在开告警数落一个点——「活跃前缀数」曲线的数据源。 - gauge(瞬时速率仪表)。读时对窗口(默认 900s)内每个 60s 桶求全节点变化数之和(缺桶按 0,固定分母),升序去掉最高 10% 后取平均、折算次/秒——90 分位截尾均值,对突发尖刺鲁棒(与 FlapAlerted 同法)。
差分的保真规则与计分一脉相承(宁可漏检):计数回卷(agent 重启 / 条目剪枝后重建)取新值当下界——回卷必发生在报告间隔内,新值即区间下界;报告间隔超限(>600s,掉线恢复)整份不计速率只重建基准,否则积压变化会灌成一个假尖刺。空桶留 null,「无上报」与「确实没变化」在读侧可区分。
分辨率是 60s(受 agent 上报节奏限制,竞品单机内存态可做 5s;要更细就得 agent 加实时推送通道,收益配不上复杂度)。「路由表规模随时间」不在这层——路由采集侧的 node_routing_events 已有该时序(/ui/routing/fleet-overview 的 trend)。
四、验证(算法准确度)
定位算法给出的 zone / culprit 没有天然的 ground truth。验证的办法是引入与算法本身无关的独立信号,在三重时间尺度上交叉核对——单快照看内部一致性与横向信号、回溯事件日志看持久性、前向复拍看分类稳不稳。方法可复现,结论随 fleet 状态点在时间上,下面的实测数取自 2026-07-09 一次全量核对。
验证用的独立信号
- 会话级 flap(P0):来自 runtime 快照,与 flapfeed 完全不同源——用来验 near(真近端应伴随会话抖动)。
- 嫌疑点聚集度:一条真链路事件(边
A→B翻)会同时命中经过它的一大批前缀;一个真不稳起源会命中它名下一批前缀。若模型的 culprit 聚成少数几个 = 抓到真事件,若散成一堆随机边 = 噪声。这是最强的一条效度信号。 - 抽样重推差集:手工核对具体判断——"经嫌疑边的 churn" vs "绕开它的 churn",差集是否真的显著。
- 独立归因管线:
flap_alerts.peer_asn(告警器自己的对端归因)不经定位器算出,可与定位器的近端集中点位交叉。 - 结构一致性:
zone==moas ⟺ 行 moas,应恒成立(行权威)。
单快照:横向信号
- 聚集度分道扬镳,且方向都对。
path前缀高度聚集(实测 61 前缀只落在 14 条嫌疑边,头一条独占 25 条)——真链路事件签名;origin前缀高度散布(154 前缀散在 128 个源,最高才 6 条)——每个末梢网络独立抖动的正确签名,与 path 相反。若定位在乱判,两者不会呈现这种彼此相反的聚散结构。 - 差集显著:抽样嫌疑边"经它 churn≈688 / 绕它 churn≈14",约 49×——边确实在鉴别。
- 结构一致:271 行
moas判定 0 违例。 - 置信度校准:多节点观测置信显著高于单节点(path 0.99 vs 0.62、origin 0.90 vs 0.80)——跨节点佐证正确抬高置信。
回溯 72h:持久性 + 独立管线收敛
- 抖动是持久事件、非快照噪声。
flap_alerts生命周期显示前缀告警平均寿命 ~125 min、峰值 open 达 22 h。定位分析的是一个持续数小时的稳定态,单快照因此有代表性。 - 两套独立管线收敛同一点位。 未经定位器的
flap_alerts.peer_asn,其 open 告警高度集中在同一个高介数 hub 对端——正是定位器把 near/path 结构集中的那个 AS。 - near 被时序重新定性。 该 hub 对端是本网真实 eBGP 对端,72h 内会话确有数十次抖动,但最近一次早于快照约 13 h。故当前经它到达的 churn 不是会话驱动,而是"穿过它的下游 churn"——定位判
near+ 低置信 + 标不可断 peer,恰好对:历史偏抖的对端,但此刻打进来的是下游。near 既非空穴来风,也没被过度断言。
前向:分类稳定性(t0 → t1,间隔 25 min)
- zone 稳定率 77%,且与置信同序:
path88% /origin80% /near62%。模型越有把握的区,时间上越稳——置信度本身是有效的稳定性预测器。 - 翻转是"低置信 near → 高置信 origin/path"的收敛,不是抖动。 绝大多数翻转形如
near(conf≈0.44) → origin/path(conf≈0.87):near实际是诚实的"我还不确定"过渡态,证据一足就往根因方向落定。若是纯边界抖动,翻转应 conf 中性对倒;系统性的低→高说明是证据驱动。 - 嫌疑边跨时间持续:t0 的 top path 边在同一批前缀上跨 25 min 保持 85–100% 同边——真链路事件签名持续存在。
总评
高置信的 path / origin 判断真实且时间稳定,可直接指导处置(尤其反复出现的中段嫌疑边);near 是被正确标记的低置信过渡区,置信度已在替操作员区分"能信"与"再等等",不应据其贸然断 peer。moas 结构自洽。方法与实测脚本沿用上述独立信号,可随时复核。一个诚实的度量限制:按前缀求和的 Δchanges 非单调(路径 prune/恢复会让和值下降),只有 per-(node,prefix,peer) 粒度的计数单调,聚合量只作方向信号、不作里程表。
五、处置(Mitigation)
检测只告警不抑制;处置是从告警一键发起的人工运维动作。每个动作由操作员显式触发、声明式下发、可撤销,且挂在 /admin 前缀下天然进审计日志。
代码: api/v1/admin/mitigations.py。
- 拒收前缀(
prefix-block/prefix-unblock):把前缀写进节点的base_template.bird.blocked_prefixes(字段级合并,与路由调优同法),materializer 透传进 DesiredState,agent 渲染进 eBGP 导入过滤器精确匹配 reject。缺省 fleet 级(全部未退役节点——退役节点绝不能被重物化,等于变相 recommission),可传nodes指定子集(如金丝雀单节点先试)。幂等。 - 断开 / 恢复对端(
peer-disable/peer-enable):按对端 AS 把它的全部 eBGP 会话enabled置 False(BIRD 摘除协议、停收发路由;WG 隧道保留,恢复即秒级重建)或反向恢复。无该 AS 会话返回 404。幂等。
写路径遵守事务纪律:变更与 materialize 同事务,提交后再 broadcast。GET /fleet/mitigations 汇总当前生效的全部拒收前缀(按前缀归组)+ 全部 disabled 会话,供 UI 渲染"处置中"清单和撤销入口。
API 端点一览
只读观测端点在 /api/v1/ui(需 admin token),处置端点在 /api/v1/admin:
| 端点 | 形态 | 作用 |
|---|---|---|
GET /api/v1/ui/fleet/bgp-flaps | 会话 | 全机群会话 flap 榜 |
GET /api/v1/ui/nodes/{id}/bgp-flaps | 会话 | 单节点会话 flap + 转移历史 |
GET /api/v1/ui/fleet/prefix-flaps | 前缀 | 前缀 flap 榜(行带 rate_per_s / flapping_since / flap_duration_s)+ 各节点喂送健康度;?group=prefix 每行内嵌 localization 根因定位 + 顶层 localization_summary |
GET /api/v1/ui/fleet/prefix-flaps/localize | 前缀 | 各抖动前缀根因区定位(独立端点,他用;榜行已内嵌) |
GET /api/v1/ui/fleet/flap-stats | 时序 | fleet 级活动时序(变化速率两条线 + 活跃前缀数 + 在开告警数)+ 90 分位截尾均值 gauge;?range=1h|6h|24h|7d|30d |
GET /api/v1/ui/fleet/flap-peers | 前缀+会话 | "哪家对端最抖"榜 |
GET /api/v1/ui/fleet/flap-peers/{asn} | 前缀+会话 | 单对端详情:聚合 + 该方向前缀行 + 告警事件(Peer ASN lookup) |
GET /api/v1/ui/fleet/flap-alerts | 告警 | 告警事件流(游标分页) |
GET /api/v1/admin/fleet/mitigations | 处置 | 当前生效处置汇总 |
POST /api/v1/admin/fleet/mitigations/prefix-block | -unblock | 处置 | 拒收 / 撤销前缀 |
POST /api/v1/admin/fleet/mitigations/peer-disable | -enable | 处置 | 断开 / 恢复对端 |
会话列表类端点(如 /nodes/{id}/bgp-sessions/status)会把会话级的 flapping / flap_score 就地并入每行:一条会话此刻可能是 Established,但只要衰减分过线就标 flapping,排序时抖动中的排最前。
参数速查
| 常量 | 值 | 位置 | 含义 |
|---|---|---|---|
| 会话级半衰期 | 1800 s | services/flaps.py | 会话分数半衰期 |
| 会话级阈值 | 3.0 | services/flaps.py | 需连续 4 份抖动快照(~20 min,s₄≈3.39)过线;3 次断连任意间隔下都不过线 |
| 前缀级半衰期 | 600 s | flapfeed_scoring.py / prefix_flaps.py | 前缀分数半衰期(两侧必须同参) |
| 前缀级榜单线 | 20.0 | prefix_flaps.py | 榜单/徽章「flapping」判定:≈每 44 s 抖一次持续的稳态;每 20 s 一次的稳态 ≈ 43.8,约 9 min 过线 |
| 前缀级告警线 | 100.0 | prefix_flaps.py | 告警开单(与榜单线分层):≈持续每分钟 ~7 次计分变化;会话级告警线与榜单线同参(3.0) |
| 消警线 | 告警线 ÷ 2 | flap_alerts.py | 迟滞,防开关乒乓(前缀 50 / 会话 1.5) |
| 剪枝分 | 0.5 | flapfeed_scoring.py | 低于即从计分表剔除 |
| 路径证据条数 | 12 | flapfeed_scoring.py | 每前缀上报的 distinct AS_PATH 上限(优先在宣告的幸存路径) |
| 定位嫌疑图元阈值 | 0.5 | flap_localize.py | min(churn占比,路径占比)−稳定占比 达不到即判 origin(散布) |
| near 门控 | 会话级阈值 3.0 | observability.py | 本侧会话分未过阈的 near 置信 ×0.6、标不可断 |
| 喂送 hold | 720 s | flapfeed_bgp.py / local_flapfeed.conf | 采集会话 hold time,KEEPALIVE 按 1/3 发 |
| 喂送 stale | 300 s | prefix_flaps.py | 控制面标记节点喂送陈旧 |
| 热态 blob TTL | 7 天 | prefix_flaps.py(_PFLAP_TTL_S) | 热态 blob pflap:<node> 的 Redis/KV 过期时间(节点长期不上报后热态自然蒸发) |
| 告警评估 | 60 s | flap_alerts.py | 边沿评估循环周期(兼活跃度落点节奏) |
| 存档桶宽 | 60 s | prefix_flaps.py | 速率 / 活跃度时序的原生分辨率(RATE_BUCKET_S) |
| 存档保留 | 86400 桶(60 天) | prefix_flaps.py / flap_alerts.py | 速率与活跃度存档保留期 |
| 差分间隔上限 | 600 s | prefix_flaps.py | 报告间隔超限不计速率(掉线恢复防假尖刺) |
| gauge 窗口 | 900 s,P90 截尾 | prefix_flaps.py | 瞬时速率仪表:截尾均值窗口与分位 |
前缀级半衰期与阈值在 agent 计分器和控制面两侧各有一份,必须同参,否则打分口径分叉。告警循环直接 import 会话级/前缀级导出的常量,天然同步。