C COMPIN BLOG

COMPIN TECHNICAL NOTE

新手教材(3/13)|第三编:300 节点系统的控制面和数据面

从容器、namespace、veth、eBPF 和 AF_XDP 出发,解释物理场景怎样形成 300 节点动态网络。

本文是《从 IP 到 SRv6 计算编排》新手教材第 3/13 编。 总目录 · 上一篇:第二编:SRv6 与 SR Policy · 下一篇:第四编:统一任务、Color 和策略安装

13. 先认识300个对象和两台宿主机

13.1 两台机器的职责

192.168.0.5(.5)
  源码、Git、Gitea、ClickHouse、MinIO

192.168.0.2(.2)
  Docker、veth、network namespace、eBPF、AF_XDP、控制器、前端

源码只通过Git同步。.5不运行300节点仿真;.2是唯一大型仿真和部署宿主机。

13.2 三类仿真对象

ID数量类型网络角色
1..200200satellite(卫星)网络节点,运行转发、控制代理和按profile部署的算子
201..25050aircraft(飞机)端侧,只选接入卫星并产生/接收普通业务
251..30050ship(船舶)端侧,与飞机使用相同端侧协议

端侧不是核心中继,不运行HEFT、全网FIB或图像算子。飞机和船舶的不同主要属于物理 场景和前端类型,不形成不同的任务协议。

13.3 地址空间

地址含义示例
fd42:1::<node>网络节点普通IPv6节点10为fd42:1::a
fd42:2::<node>物理节点/集成端侧交付SID节点10为fd42:2::a
fd42:3:<node>::<function>具体算子实例SIDgray@10为fd42:3:a::100
fd42:4:<node>::6:<endpoint>独立端侧IPv6交付SIDendpoint300@199为fd42:4:c7::6:12c
fd42:7::<function>逻辑Anycast算子SIDgray为fd42:7::100
fd42:10:<endpoint>::2端点业务IPv6endpoint300为fd42:10:12c::2
fd42:197::/32物理uSID locator16-bit指令container

同一个数字ID在地址的不同位置代表不同命名空间。不能因为尾数相同就把普通节点地址、 SID和端点服务地址混为一谈。

14. Container、Namespace和veth

14.1 Docker Container

**Container(容器)**是由Linux namespace和cgroup等机制隔离的一组进程。Docker是 创建和管理容器的工具,不是一个独立内核。300节点共享.2宿主机内核,但看到隔离 的进程、网络接口和文件系统视图。

**cgroup(control group,控制组)**用于统计和限制进程资源;本项目可以观察容器CPU 和内存,但不能把观察器输出当作协议就绪条件。

14.2 Network Namespace

**Network Namespace(网络命名空间)**提供独立的接口、地址、路由、邻居和网络协议 栈。每个网络节点容器有主namespace;每个实际部署的算子又有自己的子namespace。

这样gray@10不是节点10主进程里的一个普通函数,而是拥有:

  • 自己的IPv6/SRv6状态;
  • 自己的具体SID与End.X;
  • 自己的XDP/AF_XDP算子接口;
  • 自己的常驻用户态算子进程。

14.3 veth

**veth(virtual Ethernet pair,虚拟以太网对)**像一根虚拟网线的两端:从一端写入 的Ethernet帧从另一端出现。

本项目主要用veth连接:

  • 宿主机与节点容器物理接口;
  • 节点主namespace与算子namespace;
  • 算子内核侧接口与AF_XDP侧接口;
  • 网络节点与集成端侧namespace;
  • 节点与控制器的独立控制附件。

链路质量不由veth本身模拟,而由宿主机AF_XDP fabric实施。

15. XDP、eBPF和AF_XDP

15.1 eBPF

**eBPF(extended Berkeley Packet Filter,扩展伯克利包过滤器)**是一种在Linux 内核受验证执行环境中运行小程序的技术。程序加载前经过verifier(验证器),不能像 普通用户程序一样任意访问内存。

eBPF map(映射)是内核和用户态共享的键值数据结构。例如,宿主XDP入口用map保存:

key   = Linux interface index
value = source node ID

另一类map可保存AF_XDP socket。任务分类map属于第四编,在task和Color定义以后再讲。

15.2 XDP

**XDP(eXpress Data Path,快速数据路径)**是Linux网络栈很早的包处理hook(挂载点)。 它可以快速执行pass、drop、redirect等动作。

宿主fabric的XDP程序只做L2 redirect:根据目标MAC把帧送到代表源节点端口的AF_XDP socket。它不计算路由,不解释应用层数据,也不执行图片算子。

15.3 AF_XDP

**AF_XDP(Address Family XDP,XDP地址族套接字)**让用户态程序通过共享内存ring 高效收发XDP重定向的帧。

关键概念:

  • UMEM(User Memory,用户内存区):内核与用户态共享的packet frame池;
  • Ring(环形队列):生产者和消费者交换descriptor(描述符)的有界队列;
  • RX ring:用户态接收包;
  • TX ring:用户态发送包;
  • fill ring:把可用frame交给内核;
  • completion ring:内核归还已发送frame。

有界ring是系统真实容量,不应通过无限缓存掩盖拥塞。所有权错误会导致frame重复使用、 数据破坏或EINVAL,所以代码显式统计UMEM ownership errors。

**Fabric(交换/链路编织层)**在本项目中是宿主机AF_XDP用户态进程。它为每条有向边 维护:

  • connected:是否连通;
  • rate_bps:当前bit/s速率;
  • design_rate_bps:设计基线速率;
  • burst_bytes:令牌桶可突发字节;
  • queue_bytes:有限出口队列;
  • delay_us:传播时延。

**Token Bucket(令牌桶)**按速率产生token(令牌),发送字节消耗token;允许有限 burst但长期平均速率受限。**Propagation Queue(传播队列)**保存已经发送、尚未到达 的帧直到delay_us到期。

15.5 三类丢包必须分开

计数含义
connectivity_drops链路当前断开,包无法进入该边
egress_congestion_drops有限出口队列满,真实拥塞丢包
propagation_drops已进入传播阶段后丢失;当前合同应恒为0

不能把三类都写成“网络丢包”。它们对应完全不同的组件边界。

15.6 为什么不用bridge、qdisc模拟链路

**Bridge(网桥)**会在二层学习和转发,qdisc(queueing discipline,排队规则) 由Linux流量控制框架实施排队/限速。本项目不使用Linux bridge、TC qdisc或网络命名 空间中的特殊链路模拟,因为这会把物理模型分散在多个内核机制里,难以形成统一、 可审计的有向链路统计。

其他eBPF classifier可以设置本地mark,但它们不模拟链路速率、时延或丢包。

15.7 项目里有两类eBPF,不能混为一谈

本项目至少有两个使用eBPF的边界:

  1. **任务classifier(分类器)**在节点入口读取已经验证的业务身份,查任务map并设置 skb->mark。它回答“这个任务应选择哪张本地策略路由表”,具体格式留到第四、七编;
  2. 宿主fabric XDP程序在300个宿主veth入口检查Ethernet头,把项目帧redirect到 AF_XDP socket。它回答“这是谁发出的二层帧,应交给哪个用户态入口队列”。

前者参与SR Policy选择,后者只把帧引入物理链路模拟器。宿主XDP程序不读取IPv6、SRH或 任何编排结果与业务语义。把二者都称为“eBPF转发”会掩盖真正的状态所有者。

15.8 300个物理端口如何接到宿主

每个节点容器有一对专用veth:

节点容器内:lfc001 ... lfc300
宿主机一侧:lfh001 ... lfh300

容器从lfc017发出的Ethernet帧会出现在宿主lfh017。创建接口时就设置确定性MAC, 而不是等udev异步命名或修改;这是为了避免300接口并发创建时出现身份竞态。

项目单播MAC把node ID编码在末尾字节。项目还定义L2 group MAC(组播组MAC)承载 HELLO等一跳发现帧。fabric只接受符合这些项目身份的目的MAC;与项目无关的帧由XDP 返回XDP_PASS,继续进入普通Linux网络栈。

15.9 XDP程序逐条做了什么

宿主程序加载bpf/xdp_link_fabric.bpf.c,并使用两个eBPF map:

lf_ingress_nodes : host ifindex -> source node ID
lf_node_xsks     : source node ID -> AF_XDP socket FD

**ifindex(interface index,接口索引)**是Linux内核给网络接口分配的整数身份。 XSKMAP是专门保存AF_XDP socket引用、供bpf_redirect_map()重定向的map类型。

可以把XDP逻辑近似读成:

if Ethernet头不完整:
    XDP_PASS
if 目的MAC既不是项目节点MAC,也不是项目组MAC:
    XDP_PASS
source = lf_ingress_nodes[收到包的ifindex]
if source不存在:
    XDP_PASS
if 单播目的MAC不能解出合法destination,或destination == source:
    XDP_PASS
return redirect(lf_node_xsks[source])

最容易误解的一点是:XSKMAP按**source(源节点)**索引,不是按destination索引。XDP 只知道“帧从哪一个宿主veth进入fabric”,于是先把它交给该源端口的RX ring。源到目的 是否连通、传播多久、能否排队,都必须由用户态fabric执行。若XDP直接按目的端口转发, 链路规则就被绕过了。

15.10 为什么链路逻辑不全写进XDP

eBPF verifier要求程序有可证明的有界执行和安全内存访问。动态的300×300有向矩阵、每边 有限队列、令牌桶时间推进、传播队列、配置代次切换和完整统计都不适合塞进一次XDP调用。

所以分工是:

XDP内核快路径:最小身份检查 + redirect
AF_XDP用户态:链路通断 + 限速 + 排队 + 传播 + 复制 + 统计
Linux节点协议栈:IPv6 + SRv6 + 邻居发现 + 本地FIB

这不是“为了快把所有网络都搬到用户态”,而是把最早的收包入口与需要时间和状态的物理 链路模型放到各自适合的执行环境。

15.11 一个AF_XDP端口的UMEM与四个ring

当前fabric把XDP program以XDP_SKB模式挂到veth,并把AF_XDP socket以XDP_COPY | XDP_USE_NEED_WAKEUP绑定。这里:

  • XDP_SKB表示使用内核通用SKB路径,兼容veth;
  • XDP_COPY表示包在内核与UMEM之间复制,不假装veth支持网卡零拷贝;
  • NEED_WAKEUP表示用户态在内核需要时显式唤醒TX处理。

每个端口的当前常量是:

项目数量/大小
单个UMEM frame2048 byte
UMEM frame总数4096
初始RX/fill所有权2048 frames
初始TX free list2048 frames
RX/TX/fill/completion ring容量2048 descriptors
单次RX处理上限64 frames

**Descriptor(描述符)**不是包本身,而是指出UMEM中“地址、长度”等信息的小记录。

四个ring的所有权循环是:

RX方向:用户态把空地址放进fill
       -> 内核在这些地址写入收到的帧
       -> 内核把描述符放进RX
       -> 用户态读取并归还地址到fill

TX方向:用户态从TX free list取得空地址并写帧
       -> 把描述符放进TX
       -> 内核发送完成后把地址放进completion
       -> 用户态回收地址到TX free list

一个端口的UMEM约为4096 × 2048 = 8 MiB;300端口仅UMEM就约 2.34 GiB,尚未计算ring、链路矩阵、包副本和进程本身。这说明300节点不是把24节点 脚本复制几遍,而是真实的大规模单机资源工作负载。

15.12 fabric如何启动300个AF_XDP端口

scripts/setup_issue_76_physical_fabric.sh按同一节点规模创建veth并启动一个宿主fabric 进程。src/host_link_fabric.c依次:

  1. 加载并验证BPF object(eBPF目标文件);
  2. 找到XDP program与两个map;
  3. 对每个lfhNNN核对确定性MAC;
  4. 把XDP以SKB模式挂到该接口;
  5. 为节点创建UMEM、ring和AF_XDP socket;
  6. 写入ifindex -> node和node -> socket两张map;
  7. 启动入口线程、链路worker、配置重载和统计循环。

任何端口身份或map绑定错误都不能靠“稍后收包再猜”修复;启动阶段必须失败并报告具体端口。

15.13 RX线程如何取包并归还frame

默认有5个ingress worker(入口工作线程)。节点按 (node_id - 1) % ingress_worker_count分片,每个线程只poll自己负责的AF_XDP socket。

收到事件后,它最多从RX ring peek 64个descriptor。对每帧执行:

  1. 验证长度和Ethernet目的身份;
  2. 把帧内容复制到fabric自己的lf_packet;
  3. 解析单播目的node ID,或识别一跳group帧;
  4. 把内部包交给负责目标端口的链路worker;
  5. release原RX descriptor;
  6. 把原UMEM地址重新放入源端口fill ring。

这里有一次有意的copy。它让源RX frame在入口处理完就能归还,而不必被某条可能等待数十 毫秒的传播队列长期占用。

15.14 单播与group帧的用户态转发

单播帧从MAC解出一个destination,只进入source -> destination这一条有向边。

group帧不能被无条件广播给299个节点。fabric查看当前活动链路矩阵,只向source当前 connected的出邻居各复制一份。于是节点17的HELLO只会到达物理上连通的邻居;没有 当前邻居时记录group-no-neighbor,而不是制造一个全网广播域。

这一步是物理发现能够“自然而然发生”的基础:节点协议只发送普通一跳组帧,不读取场景 数据库;谁真正收到由这一时刻的物理fabric决定。

15.15 为什么还要第二组worker

默认另有5个link worker。destination按 (destination - 1) % link_worker_count分片。每个ingress worker到每个link worker之间 有一条固定的SPSC ring(Single Producer Single Consumer,单生产者单消费者环), 容量8192;eventfd用于从空闲状态唤醒消费者。

按destination分片的实用意义是:同一个目标AF_XDP TX端口只由一个link worker拥有, 避免多个线程同时修改TX ring和TX free list。每条有向边仍有自己的通断、token和队列, 端口所有权与链路状态所有权因此清晰可审计。

15.16 每条有向链路保存什么状态

对每个source -> destination,用户态保存:

配置:connected, delay_ns, rate_bps, design_rate_bps,
      burst_bytes, queue_bytes
运行态:egress queue, propagation queue, queued_bytes,
        tokens, last_token_update_ns, counters

内部实现可以为指针队列扩容,但queued_bytes绝不能超过配置的queue_bytes。换句话说, 内存容器能扩展不等于模型容量无限;接受还是丢弃由明确的字节上限决定。

15.17 Token Bucket的公式与例子

经过Δt纳秒后新增token字节数为:

added_bytes = Δt × rate_bps / (8 × 10^9)
tokens = min(burst_bytes, tokens + added_bytes)

发送长度为L byte的帧需要至少L个token。若缺少M byte,最早还需等待:

wait_ns = M × 8 × 10^9 / rate_bps

例如链路为1 Mbit/s,当前没有token,一个1000-byte帧至少需要:

1000 × 8 / 1,000,000 = 0.008 s = 8 ms

burst_bytes允许已经积攒的token短时间连续发送,但不能改变长期平均速率。速率为0不进入 除法;物理配置把它表达为connected=false。

15.18 从出口队列到传播队列

一个单播包在链路worker中经历:

链路断开
  -> connectivity_drops++,释放内部包

链路连通但egress已达queue_bytes
  -> egress_congestion_drops++,释放内部包

已入egress但token不足
  -> 保留并等待,下次可发送时再检查

token足够
  -> 扣除token
  -> release_ns = now + delay_ns
  -> 移到propagation queue

now >= release_ns
  -> 复制到目的端口TX UMEM
  -> 提交TX descriptor

传播时延和发送速率是两个不同阶段。一个1 Mbit/s、20 ms传播时延的1000-byte帧,在空桶 情况下既要等待约8 ms取得token,又要在进入传播队列后等待20 ms。

15.19 TX提交、唤醒与completion

传播到期后,worker从目的端口TX free list取得一个UMEM frame,把Ethernet帧复制进去, 再向TX ring提交descriptor。若内核提示需要唤醒,程序执行零字节sendto()触发发送。

EAGAIN、EBUSY和ENOBUFS表示当前需要稍后重试,并不等于链路协议错误。成功提交也 不等于frame已经可以复用;只有completion ring归还该地址后,用户态才能放回TX free list。

因此三种对象有严格不同的寿命:

  1. 源RX UMEM frame:内容复制后立刻归还源fill ring;
  2. fabric内部packet副本:归某条有向边的egress/propagation队列所有;
  3. 目的TX UMEM frame:归还completion以前不能复用。

历史上的AF_XDP EINVAL问题,本质就是破坏了这类descriptor/UMEM所有权,而不是“网络 偶尔慢”。

15.20 新拓扑怎样原子切换到worker

活动配置文件不是逐行在线修改。场景消费者先写同目录临时文件,flush + fsync后执行 os.replace(),让读者看到的要么是完整旧文件,要么是完整新文件。

fabric解析新文件到另一份堆上矩阵,校验代次与全部字段后才短暂锁住link workers并发布 整份矩阵。使用heap(堆)而不是thread stack(线程栈),是因为300×300配置约占数MiB, 不适合放进默认线程栈。

发布时:

  • 新关闭的边清除egress与propagation包,并统一计入connectivity_drops;
  • rate/burst变化时重置或钳制token,不能继承超出新上限的信用;
  • fabric generation递增,并记录对应physical scenario generation;
  • SIGHUP只用于加速同一套文件检查,不提供另一条旁路配置机制;
  • state文件写出准确已应用代次和重载耗时,供上游等待ACK。

已经进入传播阶段的包正常不会随机丢失,所以合同中的propagation_drops恒为0;若传播中 链路被新代关闭,丢失原因仍是connectivity change(连通性变化),不能换个名字隐藏。

15.21 一个SRv6帧穿过AF_XDP fabric的完整轨迹

以节点17向节点18发送已经带SRH的IPv6帧为例:

节点17 Linux FIB/邻居表选出节点18 MAC
  -> lfc017发送Ethernet帧
  -> 帧到达宿主lfh017
  -> XDP从ifindex认出source=17
  -> XDP校验目的MAC并redirect到XSK[17]
  -> ingress worker复制帧、归还17的RX frame
  -> 解析destination=18
  -> 交给拥有目的18端口的link worker
  -> 查询17->18的connected/rate/queue/delay
  -> 经过令牌桶和传播队列
  -> 复制到18的TX UMEM并提交
  -> 帧从宿主lfh018进入容器lfc018
  -> 节点18 Linux IPv6/SRv6继续处理active segment
  -> completion归还18的TX frame

fabric只看二层目的身份和有向边状态。帧里究竟是普通IPv6、SRH、HELLO还是应用数据,不改变 上述物理过程;只有书中第八编所述三级降档是用户态fabric的一个明确、受限业务例外。

15.22 规模、限制与排错顺序

实现为300×300有向矩阵分配索引,便于O(1)查询,但真正配置的连通边是稀疏的。不能看到 矩阵容量就误认为每颗卫星与299个对象直连。

遇到“节点A收不到节点B”时按边界检查:

  1. A的Linux是否真的发出了目的MAC正确的帧;
  2. A对应XDP是否挂载,两个map是否包含A;
  3. A的RX ring是否收到,是否有invalid descriptor/fill empty;
  4. A -> B当前是否connected;
  5. 是connectivity drop、egress congestion还是token wait;
  6. 包是否到达propagation release时间;
  7. B的TX ring是否busy,completion是否正常回收;
  8. B的Linux协议栈是否接收并按IPv6/SRv6处理。

只有先找到第一处事实中断,才能决定修改XDP、UMEM所有权、链路模型还是节点协议。

16. 物理场景怎样变成真实链路

16.1 先区分运动模型、物理图和协议图

这一章涉及三层对象:

运动模型:在时间t计算300个对象的位置
物理链路图:按几何与环境规则决定哪些有向边真的能传帧
协议拓扑图:节点用实际HELLO/ACK观察后形成的双向邻接

运动模型不安装路由,物理图不向节点注入邻居,控制器也不修改物理图。三层通过真实帧 传递因果关系,避免“场景脚本知道答案,于是直接把正确答案写进FIB”。

16.2 当前是确定性圆轨道工程模型,不是SGP4

scripts/physical_scene_provider.py实现的是deterministic circular-orbit engineering model(确定性圆轨道工程模型)。它用于生成可重复、连续运动且几何自洽的研究场景, 不是读取真实TLE并用SGP4计算的精密星历。

  • Ephemeris(星历):天体或航天器随时间的位置/速度数据;
  • TLE(Two-Line Element,两行轨道根数):公开卫星常见的轨道参数格式;
  • SGP4(Simplified General Perturbations 4,简化常规摄动模型4):用TLE传播近地轨道 的工程算法;
  • Deterministic(确定性):相同配置、seed和仿真时刻必得相同结果。

教材必须明确这个边界:当前场景能验证动态网络协议与计算编排,不能拿来声称某颗真实卫星 在某个UTC时刻的精密位置。

16.3 300个对象的基础配置

当前configs/issue255-physical-scene-300.json定义:

对象/参数当前值
轨道面总数20
每轨道面卫星10
卫星总数200
极轨轨道面前10面,倾角90°,高度550 km
倾斜轨道面后10面,倾角53°,高度600 km
飞机50,高度约10–12 km,基准速度240 m/s
船舶50,高度1 m,基准速度12 m/s
星间/接入设计速率1 Mbit/s
端侧最低仰角5°
跨轨候选最大距离5500 km

卫星ID由位置确定:

satellite_id = plane × satellites_per_plane + slot + 1

因此plane 0的slot 0..9是节点1..10,plane 19的slot 0..9是191..200。飞机为201..250, 船舶为251..300。

16.4 计算卫星圆轨道速度和周期

设:

  • R为地球半径,当前取6,371,000 m;
  • h为轨道高度;
  • r = R + h为地心到卫星的轨道半径;
  • μ = 3.986004418 × 10^14 m³/s²为地球标准引力参数;
  • ω为轨道角速度,单位rad/s;
  • v为轨道线速度;
  • T为一周周期。

圆轨道模型使用:

ω = sqrt(μ / r³)
v = sqrt(μ / r)
T = 2π / ω

代入当前配置:

高度轨道半径角速度线速度周期
550 km6,921 km0.00109652 rad/s7,588.998 m/s5,730.127 s(95.502 min)
600 km6,971 km0.00108474 rad/s7,561.733 m/s5,792.334 s(96.539 min)

这解释了为什么一个60分钟Run中卫星已经移动很远,却还没有完成一整圈。

16.5 从plane/slot得到轨道相位

每颗卫星的相位由slot、轨道面错相和时间共同决定,可概括为:

phase(t) = 2π × slot / slots
         + 2π × local_plane / (group_planes × slots)
         + inclined_phase_offset
         + ωt

**RAAN(Right Ascension of the Ascending Node,升交点赤经)**描述轨道平面绕地球自转轴 的方位。当前极轨组的RAAN分布在π范围,倾斜组分布在2π范围;相邻面再加小的 phase错开,避免所有卫星在同一经线同步重合。

给定RAAN Ω、相位u、倾角i和半径r,代码先计算惯性坐标:

x = r(cosΩ cosu - sinΩ sinu cosi)
y = r(sinΩ cosu + cosΩ sinu cosi)
z = r(sinu sini)

16.6 ECI、ECEF、经纬度分别是什么

上式得到类似ECI(Earth-Centered Inertial,地心惯性坐标)的位置,它不随地球表面 一起转。前端和地面对象需要ECEF(Earth-Centered Earth-Fixed,地心地固坐标): 坐标轴随地球旋转,相同经纬度的地面点在ECEF中近似固定。

地球自转角速度取7.2921159 × 10^-5 rad/s。在时刻t,代码绕z轴变换:

θ = earth_rotation_rate × t
x_ecef =  cosθ × x_eci + sinθ × y_eci
y_ecef = -sinθ × x_eci + cosθ × y_eci
z_ecef = z_eci

然后从ECEF向量得到latitude(纬度)、longitude(经度)和altitude(高度)。heading (航向)通过比较t与t+0.25 s的位置估算。这样前端看到的是地球自转背景下连续移动 的卫星,而不是轨道平面上的静态点。

16.7 飞机和船舶如何移动

飞机和船舶不是随机游走。provider按对象index确定性生成初始纬度、经度和heading,再用 **great-circle motion(大圆航行)**沿球面推进:路程等于speed × elapsed_time。

当前速度在基准值附近按index做小幅、可重复的变化;飞机高度在10–12 km间分布,船舶 高度为1 m。相同配置和仿真时间永远产生相同位置,所以测试可以复现;运动又只依赖物理 参数,不依赖任务是否成功。

16.8 怎样判断两颗卫星之间没有被地球挡住

仅看两星距离不够。即便相距不远,它们连线也可能穿过地球。代码对两端ECEF向量a、 b计算线段上离地心最近的点:

p(q) = a + q(b-a), 0 <= q <= 1
q* = clamp(-a·(b-a) / |b-a|², 0, 1)
closest = p(q*)

若|closest| < EarthRadius,线段穿过地球,**LOS(Line of Sight,视距)**不成立;否则 才有几何视距。跨轨候选还必须满足距离不超过5500 km。

这条规则来自几何,不知道业务、HEFT或某项验收指标,所以链路通断是场景自然演化的结果。

16.9 同轨链路和跨轨链路怎样选

每颗卫星固定考虑同一轨道面的前后两个slot,轨道首尾相接形成ring(环):

plane p: slot0 -- slot1 -- ... -- slot9 -- slot0

这给每颗健康卫星两个intra-plane(同轨)端口。

跨轨不能把所有满足LOS的候选都连起来,否则一颗卫星会瞬间拥有几十条“天线”。代码先列出 不同轨道面的合法候选及距离,再做reciprocal nearest-first matching(互惠最近优先匹配), 并用augmenting-path repair(增广路径修复)尽量让每颗卫星获得两个cross-plane(跨轨) 端口。匹配是全局确定性的,但输入完全来自这一时刻的几何候选。

在当前正常配置且候选充分时,一颗卫星的基础度数约为:

2条同轨边 + 2条跨轨边 = 4个星间邻居

“候选很多”与“实际端口很多”因此是两回事。

16.10 飞机/船舶怎样得到一条实际接入边

端侧到卫星的**elevation(仰角)**是视线相对本地水平面的角度。设端侧ECEF为e, 卫星为s,视线为s-e,本地天顶方向为e/|e|。代码用点积求仰角,仅保留至少5°的 卫星,排除已损毁卫星,然后选择距离最近者。

provider为每个端侧至多生成一条当前物理接入关系。这不是把“endpoint 233属于卫星17” 写进控制器;它只是决定此时哪条无线物理边能送达Ethernet帧。端侧与接入节点仍必须通过 真实HELLO/ACK形成观察,handover也必须由旧帧收不到、新帧实际收到自然发生。

16.11 无向关系为什么写成两条有向边

几何上选出的是一对节点,但fabric按有向边执行,因此每个关系展开为:

A -> B
B -> A

当前两方向使用相同几何距离和基础速率,但各自拥有独立token、队列和统计。传播时延按光速 估算:

delay_us = max(1, ceil(distance_m / 299,792,458 × 10^6))

例如3000 km的真空几何传播时延约为10,007 us。该数只描述传播,不包含令牌等待、排队、 Linux调度、算子计算或控制面收敛。

16.12 环境输入、降速和断链

环境输入只能按明确的无向节点对影响链路,并带生效仿真时刻。rate_ratio_milli以千分比 表达:

1000 -> 恢复设计速率,移除override
800  -> 当前速率为设计速率的80%
400  -> 当前速率为设计速率的40%
0    -> connected=false

design_rate_bps保留基线,rate_bps保存当前有效值。物理干扰不应偷偷修改任务、算子或 路由答案;它只改变链路事实,之后影响由协议和业务自然传播。

16.13 10%节点损毁到底怎样发生

正式森林火灾场景使用200颗卫星中的固定20颗,即10%。当前60秒epoch下:

generation 1..10:0 <= t < 600 s,全部卫星健康
generation 11起:t >= 600 s,固定20颗卫星损毁
rotation_per_generation = 0:损毁集合不轮换

20个ID通过SHA-256(seed, generation, action, node_id)的确定性排序选择,只依赖seed和 物理代次,不读取业务源、目的、路径、算子或结果。损毁使所有关联数据边和该节点控制附件 断开,但容器和协议进程继续存在。于是实验比较的是“从100%节点健康的成功率A自然下降到 90%节点健康的成功率B”,而不是事先挑一组恰好不影响业务的故障。

16.14 Scene frame与Topology Generation

**Scene frame(场景帧)**用于前端按当前elapsed time展示连续位置; **physical topology generation(物理拓扑代次)**是在完整时间槽内有效的链路快照。

当前epoch为60秒。generation g对应:

simulation_time = (g - 1) × 60 s
[valid_from_ns, valid_until_ns)

前端可以更频繁采样位置,但链路线条必须来自当前已提交generation,不能用画面插值临时 发明一条数据面不存在的边。

16.15 为什么只准备current和next

scripts/topology_scene_runtime.py以monotonic clock(单调时钟)为仿真时间源,根据 (now-origin)/epoch推导current generation,并只缓存current与next。这样既能在边界前 准备下一代,又不会一次生成一小时的未来答案。

runtime记录Linux boot ID;若SQLite文件来自另一轮宿主启动,则拒绝把旧单调时钟坐标当作 当前事实。未来代次也不能越过next无限look-ahead(前视),防止观察未来。

16.16 一份物理快照里有什么

每份完整snapshot至少包含:

schema
generation
valid_from_ns / valid_until_ns
node_count
provider名称、模型版本和参数
damaged_node_ids
全部有向link:source, destination, kind, connected,
              delay_us, rate_bps, design_rate_bps

快照内容计算SHA-256 digest(摘要)。同一generation若再次出现不同digest会被拒绝,防止 “版本号没变但事实偷偷改变”。

16.17 SQLite事务怎样保证300节点只看到完整拓扑

scripts/topology_slot_store.py使用SQLite WAL(Write-Ahead Logging,预写日志)和 synchronous=FULL。发布过程是:

  1. BEGIN IMMEDIATE取得写事务;
  2. 验证generation严格连续;
  3. 验证前后有效时间连续、node_count稳定;
  4. 插入generation元数据和全部link rows;
  5. 重新核对实际link count;
  6. 写入完整snapshot JSON与digest;
  7. COMMIT一次变为可见;任何异常执行rollback。

因此消费者不会读到“前100条边是新代、后400条边还是旧代”的半份拓扑。SQLite只位于 低频控制路径,不在AF_XDP逐包热路径。

16.18 物理快照怎样变成AF_XDP活动矩阵

scripts/topology_slot_fabric.py严格等待下一代,不允许跳号。在current的 valid_until_ns到达时:

读取next不可变snapshot
  -> topology_snapshot_to_fabric.py生成文本矩阵
  -> 同目录临时文件flush + fsync
  -> os.replace原子替换活动配置
  -> 向fabric发送SIGHUP加速检查
  -> fabric解析并发布完整内存矩阵
  -> 等待state文件确认exact scenario generation已应用
  -> 记录reload duration和应用结果

fabric配置每行表达一条有向边:

source destination connected delay_us rate_bps design_rate_bps
burst_bytes queue_bytes

没有出现在稀疏配置里的矩阵项保持断开。AF_XDP worker随后只查询已发布的内存状态,绝不 为每个包访问SQLite。

16.19 物理拓扑不会直接通知路由协议

这是整个系统最重要的自然性规则:

错误做法:scene计算A-B断开 -> 直接告诉controller删边 -> 直接改节点FIB

当前做法:scene关闭AF_XDP中的A-B
       -> A/B真实HELLO或ACK无法通过
       -> 节点自己的miss/RTO达到失败条件
       -> 节点上报自己观察到的LINK_DELTA
       -> controller只组合节点事实
       -> 各节点收到新图后本地重算FIB

反方向恢复同理:打开物理边不等于邻居立即恢复,必须等新的真实HELLO/ACK通过。

16.20 HELLO/ACK wire与自适应超时

邻居协议定义在scripts/neighbor_hello_wire.py:magic为NHLO,版本2,HELLO kind为1, ACK kind为2。33-byte wire message携带sender、role、进程instance、sequence、请求方 instance、发送时间戳和CRC32。

节点当前默认每1000 ms发送HELLO,策略以3次未确认探测判down。它还维护:

  • SRTT(Smoothed Round-Trip Time,平滑往返时延);
  • RTTVAR(RTT Variation,往返时延波动估计);
  • RTO(Retransmission Timeout,重传超时)。

近似更新为:

RTTVAR <- 0.75 × old_RTTVAR + 0.25 × |SRTT - RTT_sample|
SRTT   <- 0.875 × old_SRTT   + 0.125 × RTT_sample
RTO    <- SRTT + max(clock_granularity, 4 × RTTVAR)

RTO被限制在合理上下界。peer进程instance变化表示对端重启;故障前的延迟旧ACK不能在故障 后错误恢复邻居。HELLO线程独立于控制上报、FIB安装和日志,某个观察器慢不能停止探测。

16.21 节点怎样把局部邻居变成控制器协议图

节点先发送一次完整STATE_DIGEST作为baseline(基线),之后即时发送LINK_DELTA并低频 发送完整anti-entropy snapshot(反熵快照)。delta不能凭空创建一个从未建立baseline的 节点行。

控制器TopologyStore保存“每个节点声称看见谁”。只有:

A reports B  AND  B reports A

边A-B才进入confirmed graph。单向报告留在diagnostics(诊断信息)里,不进入可路由图。 某些节点尚未上报不阻塞其他节点形成partial graph(部分图);图内容不变时也不会无意义地 增加代次。

16.22 物理generation和控制generation不是一个数字

必须区分:

代次谁拥有何时变化
physical scenario generationscene/slot/fabric链每个60秒物理时槽严格递增
controller topology generationTopologyStore双向确认的协议图内容实际变化时递增

物理generation 11关闭20个节点后,不会神奇地产生“控制拓扑generation 11”。不同邻居按 各自HELLO/RTO时间发现,控制图可能经历多次自然变化。日志必须同时记录各自身份,不能仅写 一个含糊的generation后强行对齐。

16.23 控制器怎样把图送给200个卫星节点

access服务把confirmed graph编码为稀疏、对称的完整snapshot,并向已注册的200个网络节点 各发一份。每个节点有独立pending ACK状态:

  • 最新完整snapshot取代该目标尚未确认的旧snapshot;
  • 不为同一慢节点无限累计历史wire datagram;
  • EAGAIN只留下该目标的latest pending状态;
  • 每秒重试尚未ACK的最新拓扑;
  • 一个节点控制socket慢,不阻塞其他199个节点。

控制socket一次被唤醒时会drain所有已就绪datagram,逐个处理观察,但只发布这一burst后 最新的图。这里的latest-only成立,是因为后一份完整拓扑包含前一份的全部现状;资源报告 不是相同语义,不能照抄。

16.24 每个节点怎样只计算自己的FIB

节点接收完整拓扑后先验证schema、代次和内容,再交给唯一 TopologyRouteWorker。worker只保留latest;旧代忽略,同代内容变化拒绝。它区分:

accepted generation:用户态已验证并接受
applied generation:本地Linux FIB已成功安装

calculate_node_routes不做200节点all-pairs shortest paths(全源最短路)。它从自己做一次 BFS,再从每个直接邻居做BFS,复杂度约为:

O(local_degree × (V + E))

对每个destination选择最短hop的primary first hop,并计算满足LFA条件的backup。然后用 一次iproute2 batch协调四类目的前缀:

fd42:1::<N>/128
fd42:2::<N>/128
fd42:3:<N>::/64
fd42:4:<N>::/64

本地直接邻居down时,节点可先切受影响目的的合法backup或删除路由,不必等待下一份全局 图;全局图随后用于收敛其他节点。

16.25 “300节点拓扑更新”的完整时间线

以某个60秒边界上一条17-18跨轨链路消失、17-44新链路出现为例:

t0之前:generation g的AF_XDP矩阵仍活动
t0:SQLite中完整g+1已经准备好
t0:slot consumer原子发布g+1并等fabric准确ACK
t0之后:17<->18帧被connectivity规则阻断;17<->44帧可通过
随后:17/18各自HELLO超时;17/44交换真实HELLO/ACK
随后:节点发LINK_DELTA;controller检查双向性并形成新协议图
随后:access向200个已注册节点发布最新完整图,各目标独立ACK
随后:每节点单worker计算自己的primary/backup并批量安装本地FIB
最后:节点上报TOPOLOGY_STATUS;控制器可记录全体已应用事件

最后的“全体已应用”只是observation(观察),不是让业务开始的global barrier(全局屏障)。 在收敛窗口内,不同节点处于不同已应用代次是分布式系统的正常事实。

16.26 当前路由成本的限制

物理链路有几何传播时延和真实速率,但当前控制面只上报二值邻接:

network_cost = hop_count × 1000

所以数据面会真实经历每跳delay_us、令牌等待和排队,规划器却仍按跳数选择。不要把 delay_us已经存在误读为路由一定选传播时延最短路径;加入latency-aware routing需要新的 协议事实与算法设计。

16.27 前端为什么能与后端保持同一事实

前端位置来自同一TopologySceneRuntime的当前时刻计算,链路来自当前已提交snapshot, 损毁ID也来自该物理代次。SRv6路径、HEFT重算、Anycast漂移和业务事件则来自真实节点、 controller、ClickHouse与MinIO证据,并携带同一非空Run ID。

前端可以把连续位置动画与60秒链路时槽叠加显示,但不能:

  • 根据画面距离自行补一条“看起来应该连通”的线;
  • 因为页面没收到下一帧就关闭后端链路;
  • 点击卫星反向命令控制器安装路由;
  • 用伪造300点代替真实Docker节点。

16.28 从源码验证本章的阅读顺序

建议按因果顺序阅读:

  1. configs/issue255-physical-scene-300.json:先看场景参数;
  2. scripts/physical_scene_provider.py:位置、LOS、匹配、接入、损毁和link字段;
  3. scripts/topology_slot_store.py:事务与不可变snapshot;
  4. scripts/topology_scene_runtime.py:monotonic时间、current/next;
  5. scripts/topology_slot_fabric.py、scripts/topology_snapshot_to_fabric.py和 scripts/continuous_polar_fabric.py:物理代次激活;
  6. src/host_link_fabric.c:活动矩阵如何逐包生效;
  7. scripts/neighbor_hello_wire.py与neighbor_hello.py:真实邻居发现;
  8. scripts/sdn_node_agent.py与sdn_topology_module.py:上报、双向确认、本地FIB。

读任何一条日志时都先问:它属于运动、物理图、节点观察、控制协议图还是Linux applied 状态?这一步通常比先调大超时更快找到真正边界。

17. 控制面到底控制什么

17.1 事实与命令的区别

**Fact(事实)**描述某组件已经观察到的状态,例如:

  • 节点17实际收到节点18的HELLO/ACK;
  • 节点20当前上报gray算子存活;
  • endpoint 233当前被节点42本地接入服务观察到;
  • 节点20 CPU busy为某个量化值。

**Command(命令)**要求另一个组件执行动作,例如“给节点17安装到节点42的FIB”。

本项目控制器传播事实,不向节点发送单任务策略命令。节点收到全局事实后,只计算和 安装自己的FIB、Anycast路由和本地任务SR Policy。

17.2 为什么不是“控制器什么都做”

集中控制器拥有全局视图,不等于它必须拥有全部状态修改权。若控制器替200个节点执行 所有ip route:

  1. 某一个节点内核慢会占住控制器;
  2. 单节点失败会扩大成全局队列;
  3. 控制器必须维护每个节点的安装事务;
  4. 入口任务失败会变成远程控制协议失败;
  5. 节点无法根据本地HELLO快速切换自己的下一跳。

当前边界把失败限制在最小所有者:控制器维护全局事实,节点维护本地内核状态。

18. 四个控制器容器

控制器被拆为四个长期运行的容器,而不是一个全能进程。

18.1 access

**access(接入模块)**拥有节点控制附件上的会话和UDP I/O。它负责:

  • 接收节点JOIN、邻接、端点和资源上报;
  • 向已接入节点发送最新拓扑和端点快照;
  • 跟踪每个节点自己的ACK/STATUS;
  • 统计控制字节和发送错误;
  • 对拓扑/端点快照执行latest-only重发。

access不运行HEFT,不安装节点FIB,不产生业务。

18.2 host

**host(主机事实模块)**维护:

  • 已注册网络节点身份;
  • 端点接入声明;
  • 各节点已应用的拓扑代次;
  • 各来源节点的资源事实。

它形成全局事实视图,但不进入任何节点namespace修改路由。

18.3 topology

**topology(拓扑模块)**接收节点邻居观察。只有一条边的两端都报告彼此时,边才进入 确认图。一侧DOWN立即删除;恢复要求双方重新确认。

当确认图变化时,topology形成新的generation。它不等待全部200节点同时出现;当前 可见的部分图立即服务当前可达业务。

18.4 route

**route(路由成本模块)**计算控制器内部的网络成本视图,供资源事实和入口本地HEFT 使用。当前成本是可达最短跳数乘1000。

route不下发FIB或SR Policy。名字叫route module,不代表它拥有节点Linux route。

18.5 控制信息流

节点邻居上报 ─► access ─► topology ─► global topology generation
节点端点上报 ─► access ─► host     ─► endpoint ownership snapshot
节点资源上报 ─► access ─► host/route─► source-scoped resource facts

global facts ─► access ─► 每个已接入节点

19. 控制线格式与代次

19.1 固定Header

节点到控制器的主线格式使用9-byte头:

type | node_id | flags | payload_length | generation | sequence
 1B      1B       1B         2B             2B          2B
  • type:消息类型;
  • node_id:发送/接收网络节点,范围1..255;
  • flags:FULL、UP、OK等有限标志;
  • payload_length:payload字节数;
  • generation:全局快照版本;
  • sequence:同一发送源的消息序号。

**Sequence(序号)**解决同一来源消息的新旧和重复;generation标识一份全局事实版本。 二者不能互换。

19.2 主要消息

消息方向意义
JOINnode→access节点接入控制面
LINK_DELTA/邻居快照node→access本节点观察的邻接变化
TOPOLOGY_UPDATEaccess→node一份完整稀疏无向图
TOPOLOGY_STATUSnode→access本节点已计算并安装该代FIB
ENDPOINT_UPDATEnode→access本节点当前实际接入的端点集合
ENDPOINT_SNAPSHOT_UPDATEaccess→node当前端点归属事实
ENDPOINT_SNAPSHOT_STATUSnode→access节点已接收应用端点快照
RESOURCE_UPDATEnode→access本节点CPU、内存、算子和有效期
RESOURCE_FACT_UPDATEaccess→node某来源节点的一条资源事实

控制协议中没有DAG_REQUEST、COLOR_UPDATE或SR_POLICY_UPDATE。任务请求不去控制器。

19.3 Snapshot

**Snapshot(快照)**是某一代完整自洽的状态视图,不是逐行补丁。拓扑snapshot包含:

version | node_count | edge_count | sorted undirected edges

节点收到后先完整校验,再提交给forwarding worker。不能读取一半边就开始安装FIB。

19.4 Latest-only

**Latest-only(只保留最新值)**表示中间代可以被更新的事实取代。若节点还在计算 generation 12,而控制器已经产生13、14、15,节点不必依次安装所有过期中间态; 它在完成当前边界后处理最新完整代。

这不是丢失历史证据。不可变事件流保留真实状态转换;latest-only只用于当前操作状态。

19.5 每节点独立背压

**Backpressure(背压)**是消费者来不及处理时限制生产或保留量的机制。access对每个 节点最多保留一个未确认拓扑/端点快照;慢节点只对自己形成背压,不阻塞其他199节点。

同样,资源事实按来源独立更新。一个损毁节点的下行失败不进入无限重试队列;只有该 节点后续新的有效资源上报,才重新启用向它分发资源事实。

20. 节点内部的并发边界

一个网络节点至少包含以下长期活动:

线程/进程责任明确不能做什么
HELLO thread发送HELLO、接收ACK、维护liveness不等待日志或ip route
control receive thread收控制报文并校验快照不直接修改FIB
forwarding worker串行安装FIB、uSID、Anycast和task policy不写同步业务日志
resource sampler量化本地CPU/内存/算子inventory并上报不主动探测远端资源
task ingress bridge验证REQUEST来源并交本地planner不把任务转给控制器
async event writer批量写JSONL失败不能反压协议线程
operator supervisor创建并守护本节点实际算子不按单个任务临时起停算子

**Liveness(存活性)**表示对方是否仍通过协议活动被观察到。 **Inventory(清单)**表示本节点实际存在且存活的算子实例。

20.1 accepted与applied

节点必须区分:

  • accepted snapshot:已经接收和校验;
  • applied snapshot:已经由forwarding worker成功安装到本地内核。

任务规划只能使用applied事实。否则节点可能用generation 15的图计算策略,却仍用 generation 14的FIB转发,得到跨代组合错误。

20.2 为什么只有一个forwarding worker

FIB、Anycast nexthop和task route可能共同修改Linux路由状态。如果多个线程并发执行 ip route replace,单个函数都正确仍可能产生顺序竞态。唯一worker提供清晰所有权:

事实线程提交状态
→ worker依次重算并安装
→ 成功后发布applied状态

这不是全局barrier,只是单个节点内核配置的串行所有权。

21. 节点怎样形成自己的FIB

21.1 从全局图到本地一行

节点17收到同一份全局图,但只计算“从17出发”的路由。节点42计算“从42出发”的 路由。它们不互相复制FIB。

节点为每个目的计算:

primary_next_hop
backup_next_hop(满足LFA时)

21.2 同一个目的节点需要哪些前缀

对每个可达网络节点N,本地FIB协调四类前缀:

fd42:1::<N>/128         普通节点IPv6
fd42:2::<N>/128         物理/交付SID
fd42:3:<N>::/64         N拥有的具体算子SID locator
fd42:4:<N>::/64         N拥有的独立端点交付SID locator

它们共享到N的物理下一跳,但语义不同。

21.3 邻居故障快速切换

HELLO thread观察直接邻居DOWN后,只向forwarding worker提交不可用邻居集合。worker 对受影响目的从primary切换到合法backup。它不等待下一份完整拓扑才处理入口第一跳。

下游故障不一定能被入口直接看到;相邻节点的报告最终形成新全局generation,入口再 基于新图重算任务。

22. 资源事实与端点事实

22.1 Resource Summary

**Resource Summary(资源摘要)**包含:

  • instance:节点进程实例标识,重启后变化;
  • cpu_logical:逻辑CPU数量;
  • cpu_busy_milli:CPU忙碌度,0..1000;
  • memory_total/available:总内存和可用内存;
  • operator_bitmap:当前存活算子位图;
  • lifetime_seconds:这条事实的有效期。

**Bitmap(位图)**用一个整数的不同bit表示不同算子是否存在。统一镜像包含二进制, 但只有实际inventory中的存活进程才能置位并成为HEFT候选。

22.2 资源只来自节点上报

控制器不得SSH、ping或RPC主动探测节点CPU和算子。节点定期量化本地事实;数值不变时 自然抑制重复上报;接收方按lifetime淘汰过期事实。

这保证“资源存活”的权威来源只有资源所有者,而不是控制器猜测。

22.3 Endpoint Ownership

**Endpoint Ownership(端点归属)**表示endpoint当前被哪个access node实际观察。 它是持续变化的事实,不是永久配置。

handover期间可能短暂出现:

  • 旧节点仍声明endpoint;
  • 新节点也已声明endpoint;
  • 某个瞬间没有节点声明endpoint。

这些都必须保留为物理事实。控制器不使用固定等待或人为owner仲裁制造“永远唯一”。 入口只等待当前任务涉及的目的事实,不让无关端点成为全网屏障。

22.4 本地正面事实优先

外部端侧从本地链路发送的合法REQUEST,是入口看到的正面接入事实。控制器快照可帮助 解析目的端,但不能因为全局快照暂时冲突就撤销源入口的本地任务。

只有同一入口自己的HELLO/ACK服务实际观察到source detach,才撤销本地task、Color、 rule和policy。新入口只响应端侧自然重发的REQUEST。


总目录 · 上一篇:第二编:SRv6 与 SR Policy · 下一篇:第四编:统一任务、Color 和策略安装