DrasiWake 更新:桥不再是单点了,用 DotNext.AspNetCore.Cluster 给桥接上 Raft 集群

上一篇文章《国庆假期我干了件事:让 Agent 平时睡大觉,数据一变就醒来干活
》
的结尾,我留了一句话:DrasiWake 当前是 V1 阶段,单 Host、非 HA。

这话写出来的时候我自己是有点心虚的。感知层的 Drasi 可以多副本,执行层的 Gateway 可以集群化,唯独中间这座桥,靠一个文件锁保证「同一时刻只有一个进程在写 SonnetDB」。它一挂,整条「数据变化 → Agent 唤醒」的链路就断在那儿,什么时候恢复全看人什么时候发现。

这周把这个尾巴收了。DrasiWake 现在支持三节点 Raft 集群,用的是 DotNext.AspNetCore.Cluster 6.9.0。这篇写写我做了什么,以及做的时候几个绕不开的取舍。

DrasiWake-Raft-高可用架构图


选型那点儿事

给桥做 HA,我先认真考虑过的方案有三个。

第一个是引入外部协调服务,etcd 或者 Redis 锁之类的,做 leader 选举,存储还是每节点本地。想了两天放弃了——选举和状态复制分开做,脑裂窗口、租约续期这些坑都得自己填,等于半个 Raft 重写一遍,还凭空多一个运维对象。

第二个是干脆换个共享的分布式数据库当 outbox 后端。这个也否了。SonnetDB 本地嵌入式的体验就没了,单节点部署被拖累不说,V1 好不容易捋顺的「先记账再喊人」的事务语义,换存储又得重新论证一遍。

剩下第三条路:进程内嵌一个共识协议,每节点持完整副本,写操作多数派提交后落到本地。选型就落到了 DotNext.AspNetCore.Cluster。.NET 生态里嵌入式 Raft 基本就它一个能打的:HTTP/2 传输、持久化 WAL、快照、动态成员变更都是现成的,跟 Generic Host 模型也贴合。而且 Slik 项目里的 SlikCache 已经把这套模式趟过一遍,UseConsensusProtocolHandler、UsePersistentConfigurationStorage、UseStateMachine<T> 这套装配姿势有现成的参照,不用自己摸黑试 API。

目标定得很保守:三个投票节点,容忍一个节点故障。不搞弹性扩缩容,不搞跨地域,第一阶段把「一个节点倒了链路不断」这件事做扎实就够了。

这次最根本的一个变化:SonnetDB 不再是真相

V1 里 SonnetDB 就是权威。HA 版把它降级了——真相是 Raft 已提交的命令日志,SonnetDB 只是每个节点本地的物化投影,丢了、坏了、落后了都能从快照加后续日志重建。

权力交接落到代码上其实挺轻。原有的 SonnetBridgeStore 不动,改当投影读写器;新写一个 RaftBridgeStore 实现同一个 IBridgeStore 接口,所有写操作翻译成确定性命令走 Raft 复制:

public sealed class RaftBridgeStore(
    IRaftCommandExecutor commandExecutor,
    IRaftBridgeProjection projection) : IBridgeStore
{
    public async ValueTask MarkAcceptedWithCheckpointAsync(
        Guid outboxId, WakeAcceptance acceptance,
        SnapshotCheckpoint checkpoint, CancellationToken cancellationToken)
    {
        // 校验 checkpoint 和 outbox 的绑定/会话/指纹一致……
        var command = ReplicatedBridgeCommand.Create(
            BridgeCommandKind.MarkAcceptedWithCheckpoint,
            new MarkAcceptedWithCheckpointPayload(outboxId, acceptance, checkpoint));
        await _commandExecutor.ReplicateAsync(command, cancellationToken);
    }
}

DI 里一行替换,上层管线完全无感:

services.AddSingleton<SonnetBridgeStore>();
services.AddSingleton<IRaftBridgeProjection>(p => p.GetRequiredService<SonnetBridgeStore>());
services.AddSingleton<IRaftCommandExecutor, DotNextRaftCommandExecutor>();
services.AddSingleton<IBridgeStore, RaftBridgeStore>();

对账器、分发器、恢复协调器面对的还是 IBridgeStore,一行没改。V1 忍住没把持久化细节漏到管线里,这次算是收到利息了。

有个老规矩原样保留了下来:MarkAcceptedWithCheckpointAsync 必须是一个命令,受理状态和快照 checkpoint 要么一起提交要么一起不提交。V1 靠数据库事务,现在靠「同一条 Raft 命令」。载体换了,不变量没换。

命令进门之前的三道岗

DotNextRaftCommandExecutor 是所有命令进 Raft 之前的最后一道门,代码很短,但每一条检查我都跟人解释过为什么:

public async ValueTask ReplicateAsync(
    ReplicatedBridgeCommand command, CancellationToken cancellationToken)
{
    if (!IsLeader)
        throw new InvalidOperationException("Only the Raft leader can submit bridge commands.");
    if (!HasQuorum)
        throw new InvalidOperationException("Bridge commands cannot be submitted without a Raft quorum.");

    var payload = BridgeReplicationSerializer.SerializeCommand(command);
    await _cluster.ReplicateAsync(payload, null, cancellationToken);
    var committedIndex = _cluster.AuditTrail.LastCommittedEntryIndex;
    await _waitForApplyAsync(committedIndex, cancellationToken);
}

不是 Leader,拒。没有多数派,拒。这两条好理解。第三条容易被忽略:复制提交只代表多数派记下了,不代表本节点的投影已经更新,提交完立刻返回的话,后续读可能读到旧数据。所以最后要 WaitForApplyAsync,等本地真的应用完才算完。

「没有多数派就拒绝」这条,意味着集群挂掉两个节点时的行为是原地停摆——接收、对账、分发的 worker 全部取消,写不进任何东西。有朋友问过我,这时候降级成 SonnetDB 本地写入继续服务不行吗?不行。两个分区各自本地写,恢复之后你手里是两份都自称权威的账本,那才叫灾难。停在那儿等人修,至少是诚实的停。

Leader 才有干活的资格,按 epoch 生死

集群里任一时刻只有当前 Leader 运行 Drasi 信号接收、快照对账和 outbox 分发,其他节点只参与复制和应用。管这件事的是 RaftLeaderHostedService,它盯着 DotNext 的领导权事件流,按 leadership epoch 组织 worker 的生与死:

  • 当选 Leader 且有 quorum:先恢复中断的 outbox,再枚举 Drasi 查询重新对账,全部做完才启动接收和分发;
  • 丢了领导权或者丢了多数派:当前 epoch 整个取消,worker 全停,提交不了的状态转换一概不算成功;
  • 同一个 term 内遇到明确的暂时性上游故障(HTTP 408/429/502/503/504、连接和 DNS 错误这些):按 1、2、4、5 秒封顶的退避重建 scope 重试恢复。但配置校验失败、指纹不一致、持久化状态损坏这类问题不走这条路,直接失败。

这里面有个 V1 架构必须动的手术:RecoveryCoordinator 原来是「进程启动跑一次」的模型。现在进程可能长期活着,领导权却可能来来去去,每次当选都得能把恢复和对账从头重放一遍。这个改动不大,但不动它 HA 就是假的。

领导权观测这块,我没有简单轮询,而是把 DotNext 的两个取消令牌转成了事件流:

public bool IsLeader => !_cluster.LeadershipToken.IsCancellationRequested;

public bool HasQuorum =>
    !_cluster.ConsensusToken.IsCancellationRequested &&
    DotNextRaftQuorum.IsAvailable(_cluster);

注意失去领导权和失去多数派是两种事故,前者可能只是正常换届,后者是集群病了。LeadershipToken 和 ConsensusToken 分开看,事件流里分开报,后续处理也分开。追平耗时、failover 耗时顺手都用 Stopwatch 记了,进遥测。

那个最疼的窗口,解法还是那把旧锁

HA 版里最危险的窗口和单机版同构:Leader 把唤醒发给了 Gateway,Gateway 也受理了,但「Accepted + checkpoint」这条命令还没来得及复制提交,Leader 倒了。

新 Leader 从已提交状态恢复,看到的还是「待派发」,于是重发。

这就是为什么当初要先写 OpenClaw.NET 的 PR #274 再写 DrasiWake。幂等键是跨节点、跨 Leader、跨崩溃边界稳定的,新 Leader 拿同一个 Idempotency-Key 再发一次 POST /api/integration/meta-invocations,Gateway 查账发现见过,重放结果,MetaSkill 不会跑第二遍。集成测试里专门有一条:Leader 在受理后、提交前倒下,重放用相同幂等键,mock Gateway 最终只创建一个逻辑 invocation。

不过丑话也得说前头:这套机制保证的是副作用恰好生效一次,靠的是幂等去重,请求本身是 at-least-once,不是 exactly-once。文档里写明了,我这里也不吹。

分发侧还有个设计细节值得一提。LoadDispatchableAsync 的语义是「读出候选项并改变状态」,如果让每个副本各自执行「捞一批待派发」,落后的副本会捞出不同结果,命令就没法确定性重放了。所以拆成两步:Leader 先在本地投影上读出候选 ID,把精确的 ID 集合作为 ClaimDispatchable 命令提交,claim 提交成功后才真正调 Gateway。查询留在 Leader 侧,结论作为命令输入——这是 Raft 状态机设计的基本功,但第一次写的时候还是卡了一下才想明白。

快照得装得下整个业务

RaftBridgeStateMachine 继承 DotNext 的 SimpleStateMachine,逻辑很克制:

internal async ValueTask<bool> ApplyCommandAsync(
    ReplicatedBridgeCommand command, long logIndex, CancellationToken cancellationToken)
{
    command.Validate();
    await _projection.ApplyReplicatedCommandAsync(command, logIndex, cancellationToken);
    return ++_commandsSinceSnapshot >= _snapshotFrequency;
}

反序列化、校验、应用到投影、计数,攒够 SnapshotFrequency(默认 1000 条)就导出一轮快照。

关键是快照的内容:outbox 记录、checkpoint、身份映射,重建整个业务投影需要的东西都得在里面,不能只存个「最后应用索引」。不然日志一压缩,新节点加入或者老节点长期落后之后就没法恢复了。

恢复的顺序同样是先验证再动手:先恢复快照,再顺序应用其后的已提交命令;投影没追平之前节点标记未就绪,不跑 worker 也不对外服务;已有的本地投影在快照校验成功之前不许清掉。慢一步,不错一步。

单节点不给第二条路

这个决定可能有争议,但我坚持:SingleNode 开发模式也走同一个 Raft 状态机,不保留「本地直写」的快速路径。

理由很简单。如果单节点和集群是两条代码路径,HA 的 bug 就会一直潜伏到生产才爆;而单节点就是「一个成员的集群」的话,每次 dotnet run 都在真实执行命令序列化、状态机应用、快照恢复这套东西。开发者无意识地天天在帮我测生产路径,这便宜不占白不占。

配置上也尽量无感:不配任何 DrasiWake:Cluster 节就是 SingleNode,HTTP loopback、单成员、无证书,默认体验跟 V1 一模一样。

给想用 DotNext 的读者:装配的几个坑

这段是给同样想在自家项目里用 DotNext.AspNetCore.Cluster 的人写的,胶水代码里最值得抄的几处:

builder.ConfigureWebHostDefaults(webBuilder =>
{
    webBuilder.ConfigureKestrel((context, options) =>
        ConfigureRaftListener(options, /* … */));
    webBuilder.Configure(app =>
    {
        app.MapWhen(ctx => ctx.Connection.LocalPort == cluster.ManagementAddress!.Port,
            management => { /* MapGrpcService<ClusterMembershipGrpcService>() */ });
        app.UseConsensusProtocolHandler();
    });
});

builder.JoinCluster((memberConfiguration, configuration, _) =>
{
    memberConfiguration.PublicEndPoint = settings.ListenAddress;
    memberConfiguration.ProtocolVersion = HttpProtocolVersion.Http2;
    memberConfiguration.ColdStart = IsBootstrapMember(settings);
});

services.UsePersistentConfigurationStorage(
    Path.Combine(raftDataPath, "cluster-configuration"));
services.UseStateMachine<RaftBridgeStateMachine>(new WriteAheadLog.Options
{
    Location = Path.Combine(raftDataPath, "log"),
    FlushInterval = Timeout.InfiniteTimeSpan
});

三个坑,都是 Raft 集群的经典事故:

第一,ColdStart 只给字典序最小的种子节点。首次建群只有它冷启动,其余节点加入。要是几个空目录节点都冷启动,你就得到了好几个互不相识的单节点集群。

第二,初始成员列表只在首次引导时用。集群跑起来之后,成员事实是 UsePersistentConfigurationStorage 持久化的那份配置,重启时绝不能用种子列表去覆盖已提交的 Add/Remove。想改成员,走管理接口,别改配置重启。

第三,Raft 端口和管理端口物理分开。管理面是独立的 code-first gRPC,TLS 加独立 Bearer Token,逐请求校验。Follower 收到成员管理请求会转发给当前 Leader。把管理面挂在 Raft 同一个端口上,等于把集群的心脏手术刀暴露在和心跳同一个网络上,不合适。

另外状态机实例化之前先跑 HostStartupValidator——V1 那套启动期硬校验在 HA 版继续生效:配置不全、证书不合格、指纹不匹配,直接拒绝启动。

安全和一致性,两件事

HA 把攻击面和配置漂移面都撑大了,这次在这两块都做了硬处理。

传输和凭证方面,Cluster 模式强制 HTTPS,证书校验写死在启动路径里:有效期内、含私钥、允许 server authentication、SAN 覆盖本节点 Raft 和管理两个 DNS 名,一条不满足就起不来。证书密码和全集群共享的管理 token 只允许从 secret provider 注入(DrasiWake__Cluster__Certificate__Password / DrasiWake__Cluster__Management__BearerToken),不进 appsettings,不进日志,不进响应体。

配置漂移方面,同一集群的节点必须跑兼容版本和等价的业务配置——绑定注册表、Drasi 身份、OpenClaw target 映射、重试策略这些。集群引导时把功能性配置的规范化指纹持久化成集群元数据,节点启动时指纹不匹配就不许当可服务 Leader。Add 新成员前,Leader 会先通过管理 gRPC 探测候选节点的版本和指纹,兼容才提交变更。

这跟 V1 那条「Gateway 幂等保留时长必须覆盖最大重试窗口」是一个思路:跨节点、跨系统的配置漂移,运行时根本防不住,那就让它在启动期和成员变更期爆出来,别留到凌晨三点。

部署和红线

仓库里有三节点示例(docs/examples/raft/node-a.json 等),node-a 的核心配置就一块:

"Cluster": {
  "Mode": "Cluster",
  "NodeId": "node-a",
  "ListenAddress": "https://node-a.example.test:5101/",
  "RaftDataPath": "C:\\DrasiWake\\node-a\\raft",
  "InitialMembers": [
    "https://node-a.example.test:5101/",
    "https://node-b.example.test:5101/",
    "https://node-c.example.test:5101/"
  ],
  "Certificate": { "Path": "C:\\DrasiWake\\node-a\\certificates\\node-a.pfx" },
  "Management": { "Address": "https://node-a.example.test:5102/" },
  "SnapshotFrequency": 1000
}

红线几条,每条背后都有真实事故:

  • 每节点独立的 SonnetDB 目录和 Raft 目录,不许共享,更不许用同一个网络共享盘。三个节点写一份共享数据库,Raft 就白干了;
  • NodeId 集群内唯一,重启后稳定;
  • 管理端口必须跟 Raft 端口不同;
  • 换成员严格按顺序:先 Add,等追平,quorum 稳定,再 Remove,再下线旧节点。四节点过渡期多数派是三,提前停旧节点等于亲手制造 quorum 丢失。

丢多数派的行为再说一遍:集群停摆,不提交任何新状态。已经发出去的 Gateway 请求可能已被受理,但不能据此认为 Raft 确认了。恢复足够成员之后,有 quorum 的 Leader 先恢复 outbox、重新对账,再启动 worker。

遥测和验证

HA 的观测全部复用现有的 DrasiWake.Bridge ActivitySource/Meter:节点角色、Leader 变更、当前 epoch、多数派状态、提交索引和应用索引的差距、快照耗时、follower 追平耗时、成员变更成败、failover 总耗时,都覆盖了。

隐私规矩照旧:标识符一律 SHA-256 派生短 ID,token、私钥、幂等键、载荷不进 tag 不进日志,管理操作审计只记 endpoint 哈希和固定类别。默认不接 exporter,不主动配置就一个字节都不发。

测试用的是三个真实 DotNext 节点、独立端口、隔离目录,覆盖的场景包括:三节点提交后投影一致;Leader 故障后新 Leader 完成恢复继续分发;受理后提交前 Leader 倒下、重放幂等键相同;少于多数派不能提交不能分发、恢复后追平;节点重启从快照重建;运行时 Add/Remove 和非法请求拒绝;单节点行为与 V1 一致。

验收标准我给自己写得比较狠:必须用明确的状态断言验证已提交/未提交边界、跨节点数据相等、无多数派期间 outbox 没推进。「进程还活着、API 还能通」不算 HA 验收,那只是进程没死。

RTO 我依然不承诺具体数字。先靠故障测试和遥测采到真实的选举、追平、重对账耗时,再谈 SLO。没测过的数字说出口就是吹牛,这个毛病我不想犯。

最后

上一篇我说 DrasiWake 的哲学是诚实。这次做完 HA,我觉得可以再补半句:诚实也包括承认自己什么时候干不了活。

没有多数派就停,别拿本地写入假装还在服务;指纹不匹配就拒绝启动,别赌「应该没事」;at-least-once 就写 at-least-once,别蹭 exactly-once 的概念。高可用系统最害人的从来不是故障,是故障的时候系统的行为跟你想的不一样。

桥现在有三根墩了。断一根,桥面还在。


相关链接:

  • DrasiWake 仓库:https://github.com/Ai4c-AI/DrasiWake
  • Raft HA 设计规格:docs/superpowers/specs/2026-10-07-drasiwake-raft-ha-design.md
  • Bridge Core V1 运维手册(含三节点部署):docs/bridge-core-v1-operations.md
  • 三节点配置示例:docs/examples/raft/node-a.json / node-b.json / node-c.json
  • DotNext:https://github.com/dotnet/dotNext
posted @ 2026-10-08 08:29  张善友  阅读(143)  评论(1)    收藏  举报