Corrosion:Fly.io 的分布式状态同步

Corrosion 是 Fly.io 用 Rust 写的分布式状态同步系统:它把集群里需要共享的状态收敛到每个节点,靠 gossip 传播、link-state 式的路由来保证一致性。文章讲它是怎么长成现在这样的。

中文
复制
插画:一台被工具围住的机器,旁边的示意图显示状态在节点之间同步

图片来自 Annie Ruygt

Fly.io 把 Docker 容器变成 Fly Machines:跑在我们自己硬件上的微型虚拟机,遍布全球。运营这个平台最难的部分不是管理服务器,也不是运维网络,而是把这两件事粘在一起。

客户的 CI/CD 流水线每秒都会创建或销毁若干 Fly Machines,我们的状态同步系统随之把更新广播到内部网格上,让从东京到阿姆斯特丹的边缘代理都能维护准确的路由表,把应用请求送到最近的客户实例。

2024 年 9 月 1 日东部时间下午 3:30,一台新的 Fly Machine 启动,带着一位开发者刚上线的新“virtual service”配置项。几秒钟之内,整个机群里的代理全部彻底卡死。这是我们经历过的最严重的一次故障:在这段时间里,终端用户的请求完全无法到达客户应用。

分布式系统是爆炸放大器。数据在网络中传播的同时,依赖这些数据的系统中的 bug 也在传播。就我们的状态分发系统 Corrosion 而言,这些 bug 传播得极快。处理那次 Corrosion 更新的代理代码踩中了 Rust 并发里臭名昭著的一个坑:一个作用于 RWLock 的 if let 表达式,在其 else 分支里想当然地(合理,但错误)以为锁已经释放。瞬间死锁,且传染性极强。

这是我们付出代价才学到的教训:没有一段精彩故障史的分布式系统,不值得信任。如果一个分布式系统没毁掉过你的周末,没让你熬过通宵,那说明你还没搞懂它。正因如此,我们这样介绍 Corrosion——我们为自家平台打造并开源的一套非传统服务发现系统。

我们那把专找脸拍的耙子

状态同步是运营我们这种平台最难的问题。那为什么还要为它搭一套有风险的新分布式系统?因为不管怎么试,那把耙子都在等着踩上去。原因出在我们的编排模型上。

几乎所有主流编排系统(包括 Kubernetes)都靠一个集中式数据库来决定新工作负载往哪儿放。单台服务器自己记着在跑什么,但那个中央数据库才是权威来源。而在 Fly.io,为了在全球几十个区域横向扩展,我们把这件事整个翻了过来:每台服务器自己就是其工作负载的权威来源。

在我们的平台里,中央 API 把活儿发包出去,接包的实际上是一个由互相竞争的物理「worker」服务器组成的全球市场。把权威信息来源从中央调度器挪到各台服务器上,我们就能横向扩展,而不会卡在一个既要响应快、又要在圣保罗、弗吉尼亚和悉尼之间保持一致性的数据库上。

竞标模型很优雅,但用来路由网络请求还不够。要让东京的一个 HTTP 请求找到悉尼最近的实例,我们确实需要一张覆盖所有托管应用的全球地图。

在比该有的时间更长的一段日子里,我们靠 HashiCorp Consul 来路由流量。Consul 是极好的软件。但别拿它搭全球路由系统。后来我们又用 SQLite 给 Consul 做缓存。SQLite:同样极好。但这事也别干。

就像露台上无人看管的火鸡油炸锅,真正的全球分布式共识许诺的是美味,端上来的只有自焚。Raft 这类共识协议在长距离上会失效。而且它们跟我们平台的架构是拧着来的:我们那个跑在能买到的最强硬件上的 Consul 集群,把时间浪费在为一堆本来就不可能冲突的更新去保证共识。

腐蚀

为了搭一个全球路由数据库,我们放弃了分布式共识,转而从真正的路由协议里找思路。

OSPF 这类协议和我们有着相同的运行模型,也面临许多相同的约束。OSPF 是一种“链路状态路由协议”,对我们来说恰好有用的是:路由器是自身链路的真相来源,并负责把变化快速告知其他所有路由器,从而让网络做出转发决策。

我们比 OSPF 轻松一些。它的泛洪算法不能假设任意路由器之间连通(解决这个问题正是 OSPF 的意义所在)。而我们在服务器之间运行着一张全局、全连通的 WireGuard 网状网络。我们要做的只是高效地 gossip。

Corrosion 是一个 Rust 程序,用 gossip 协议传播一个 SQLite 数据库。

和 Consul 一样,我们的 gossip 协议构建在 SWIM 之上。先从你能想到的最简单、最笨的组成员协议开始:每个节点向它知道的每个节点狂发心跳。然后只做两处调整:第一,协议的每一步只向随机一部分节点发送,而不是全部;第二,心跳失败时不要惊慌,把它标记为“suspect”,再让另一批随机邻居帮你 ping 它。SWIM 很快就能在全局成员关系上收敛。

成员关系搞定之后,我们在集群内的节点之间运行 QUIC,用来广播变更并为新节点对账状态。

Corrosion 看起来就像一个全局同步的数据库。你可以用 SQLite 打开它,直接从表里读数据。有意思的是它不做的事:没有锁,没有中心服务器,也没有分布式共识。我们转而利用自己的编排模型:worker 拥有自己的状态,因此不同 worker 的更新几乎从不冲突。

我们确实施加了一些顺序。Corrosion 集群中的每个节点最终都会收到同一组更新,只是顺序可能不同。为了让每个实例得到相同的“工作集”视图,我们使用 cr-sqlite,也就是那个 CRDT SQLite 扩展。

cr-sqlite 的做法是把指定的 SQLite 表标记为 CRDT 管理。对这些表来说,一行中任意列的改动都会记录在一张特殊的 crsql_changes 表中。对表的更新按逻辑时间戳(也就是因果顺序,而非墙上时钟顺序)以 last-write-wins 方式应用。关于它的工作原理,可以在这里读到更多。

当 Corrosion 的普通 SQL 表中有行被更新时,产生的变更会从 crsql_changes 中收集起来,打包成批量更新包,然后通过 gossip 传播出去。

一切顺利的时候,Corrosion 很容易理解。很多使用 Corrosion 数据的客户甚至不需要知道它的存在,只需要知道数据库在哪里。我们不用操心“leader 选举”,也不用盯着指标看更新有没有积压,紧张得咬指甲。而且它快得离谱。

意外总会发生

这是一个关于我们做对了一组工程决策、从此再没遇到任何问题的故事。请鼓掌。

我们已经讲过 Corrosion 卷入过的最严重的问题:把一个死锁 bug 高效地 gossip 给了整个集群里的每一个 proxy,导致整个网络瘫痪。说实话,那次故障里 Corrosion 只是个旁观者。但其他故障确实是它干的。

拿一个经典的运维问题来说:意料之外的高开销 DDL 变更。你写了一个简单的 migration,测试过,合并到 main,然后就去睡觉了,想当然地以为这个 migration 在生产环境跑起来不会导致故障。谁都会犯这种错。

现在加点料。你对一张接入了全局 gossip 系统的 CRDT 表做了一个看似微不足道的 schema 变更。于是部署一跑,全球成千上万台高性能服务器齐声合唱数据库对账消息,把整个集群融化了。

去年我们就遇到了这种事,一位团队成员给一张 Corrosion 表加了一个可空列。新的可空列对大型 Corrosion 表来说就是氪石:cr-sqlite 需要为表中的每一行回填值。结果就像我们平台上的每一台 Fly Machine 突然同时改变了状态,纯粹是为了搞我们。

更离谱的实战故事:有很长一段时间,我们同时运行 Corrosion 和 Consul,因为两套分布式系统意味着双倍的韧性。一天早上,一张 Consul 的 mTLS 证书过期了。集群里的每一个 worker 都切断了与 Consul 的连接。

本来我们应该没事。我们还有 Corrosion 在跑。只不过:在底层,集群里的每一个 worker 都在跑退避循环,试图重新建立与 Consul 的连接。每一次尝试都会重新调用一条更新 Fly Machine 状态的代码路径。而那条代码路径会触发一次 Corrosion 写入。

等我们搞清楚到底发生了什么,我们的上行链路几乎在机队的每个角落都被打满了。向上行链路供应商道歉。

Fly.io 很久没出过这种事了,但防止下一次再发生,基本上已经成了我们唯一在想的事。

迭代

回头看,我们推 Corrosion 时重犯了在 Consul 上犯过的错误:我们建了一个单一的全局状态域。Corrosion 的设计里没有任何东西要求我们这么做,现在我们正在拆掉这个决定。先记住这一点。我们从一些更小的改动里拿到了不小的回报。

首先,也是最重要的,我们给所有东西都加了看门狗。我们给你看过那个会传染的死锁 bug,它之所以致命,是因为我们的风险模型里缺了“这些 Tokio 程序可能会死锁”这一条。现在不会了。我们的 Tokio 程序全都内置了看门狗;事件循环一卡住,服务就会被重启,同时拉响震天响的告警。看门狗已经拦下过好几次故障。代码量极小,收益极大。在你的系统里也这么做。

然后,我们对 Corrosion 本身做了大量测试。我们写过在 Rust parking_lot 库里发现的一个 bug。我们花了好几个月用 Antithesis 找类似的 bug。再说一次:真的推荐。它轻松复现了我们在 parking_lot bug 上的排查路径;如果当时就在用 Antithesis,那个 bug 根本不值得写一篇博客。对分布式系统来说,多宇宙调试是杀手级工具。

再多的测试也不会让我们去信任一个分布式系统。所以我们让从 worker 重建 Corrosion 数据库这件事变得更简单。我们在对象存储上保留 Corrosion 数据库的检查点备份。这个决定很明智。去年真正失控的时候,我们还有重启集群这个选项,最后也确实这么做了。这会花掉一些时间(数据库很大,传播成本也高),但诊断和修复分布式系统的事故要花更久。

我们还改进了 worker 向 Corrosion 喂数据的方式。直到不久前,只要 worker 更新了本地数据库,我们就会把同样的增量更新发布到 Corrosion。但现在我们彻底去掉了部分更新。改成:当一台 Fly Machine 发生变化时,我们重新发布这台 Machine 的整个数据集。由于 Corrosion 处理自身行变更的方式,接收到重新发布的 Fly Machine 的节点会在 gossip 之前自动过滤掉无操作的变更。去掉部分更新堵住了一堆 bug(而且我们认为,还顺手干掉了几个我们一直在追的隐蔽 bug)。一开始就该这么做。

最后,让我们重新审视那个全局状态问题。在经历了那个传染性死锁 bug 之后,我们得出结论:必须进化到超越单一集群的架构。于是我们启动了一个名为“区域化”的项目,它构建了一套两级数据库方案。我们运营的每个区域都运行一个 Corrosion 集群,保存该区域内每台 Fly Machine 的细粒度数据。全局集群则负责将应用映射到区域,这足以让边缘代理做出转发决策。

区域化缩小了状态 bug 的爆炸半径。我们追踪的大部分东西不必在区域之外产生影响(重要的是,针对所追踪内容的多数代码改动也是区域本地的)。我们可以用最坏情况下只危及单个区域的方式来发布这类代码的变更。

新系统行之有效

大多数分布式系统都面临状态同步的挑战。Corrosion 的“形态”与其中大多数系统不同:

  • 它不依赖分布式共识,不像 Consul、Zookeeper、Etcd、Raft 或 rqlite(我们曾非常接近采用它)。
  • 它不依赖大规模集中式数据存储,比如 FoundationDB 或由 S3 风格对象存储支撑的数据库。
  • 尽管如此,它高度分布式(数千个 worker 各自运行节点),收敛迅速(几秒之内),并且对外表现为一个简单的 SQLite 数据库。漂亮!

走到这一步并不容易。Corrosion 是 Fly.io 每一位写 Rust 的工程师工作内容的一大部分。

Corrosion 能运转起来,部分原因在于我们对放进去的东西很谨慎。并非我们管理的每一份状态都需要 gossip 传播。tkdb,我们 Macaroon token 的后端,就是一个简单得多的 SQLite 服务,由 Litestream 支撑。Pet Sematary 也是如此——那是我们为替代 HashiCorp Vault 而构建的密钥存储。

不过,大概有很多分布式状态问题更适合链路状态路由协议,而不是分布式数据库。如果你觉得自己手上可能就有这样一个问题,不妨试试 Corrosion。

Corrosion 是 Jérôme Gravel-Niquet 的创意。过去两年里,它的许多迭代工作由 Somtochi Onyekwere 和 Peter Cai 主导。这项工作时而让人分泌皮质醇,时而让人分泌内啡肽。终于能详细聊聊它了,我们很高兴。

来源: Fly.io← 返回首页