订阅以接收新文章的通知:

Workers 如何为我们的内部维护调度流程提供支持

2025-12-22

10 分钟阅读时间
这篇博文也有 English日本語한국어繁體中文版本。

Cloudflare 在全球超过 330 个城市设有数据中心,因此您可能会认为我们在计划进行数据中心操作时,随时可以轻松中断其中几个而不被用户察觉。然而,现实情况是,破坏性维护需要精心规划,而且随着 Cloudflare 的发展,通过我们的基础设施和网络运营专家之间的手动协调来管理这些复杂性几乎变得不可能。

人类已无法实时跟踪每一个重叠的维护请求,也无法兼顾特定于每一个客户的路由规则。我们达到了一个临界点:单靠人工监督已不足以保证,在世界某地进行的例行硬件更新不会意外地与另一地的关键路径发生冲突。

我们意识到,需要一个集中化、自动化的“大脑”来充当保障——一个能够同时洞察整个网络状态的系统。通过在 Cloudflare Workers 上构建这一调度器,我们创造了一种以编程方式强制执行安全约束的方法,确保无论我们推进速度多快,都不会牺牲客户所依赖服务的可靠性。

在这篇博客文章中,我们将解释它的构建过程,并分享目前的成果。

构建系统,以降低关键维护操作的风险

设想一台边缘路由器,它是连接公共互联网与某个大都市区域内众多 Cloudflare 数据中心的冗余网关小组中的一员。在人口密集的城市,我们必须确保位于这一小群路由器背后的多个数据中心不会因为所有路由器同时下线而被切断连接。

另一个维护挑战来自我们的 Zero Trust 产品 Dedicated CDN Egress IPs。它允许客户选择特定的数据中心,让用户的流量从这些数据中心离开 Cloudflare,并发送到地理位置靠近的源服务器,以实现低延迟。(为简洁起见,在本文中我们将该产品称为“Aegis”,这是它之前的名称。)如果客户所选的所有数据中心同时下线,他们将会遇到更高的延迟,甚至可能出现 5xx 错误,而我们必须避免这种情况。

我们的维护调度器解决了类似这样的问题。我们可以确保在某一区域内始终至少有一台边缘路由器处于活跃状态;并且在安排维护时,能够判断多个预定事件的组合是否会导致某客户的 Aegis 池中所有数据中心同时下线。

在创建调度器之前,这些同时发生的破坏性事件可能导致客户的服务中断。而现在,调度器会向内部运维人员提示潜在冲突,使我们能够提议新的时间,以避免与其他相关的数据中心维护事件重叠。

我们将这些运营场景(例如边缘路由器的可用性以及客户规则)定义为维护约束,这让我们能够规划更可预测且更安全的维护。

维护约束

每个约束都始于一组提议的维护项目,例如一台网络路由器或一组服务器。接着,我们会查找日历中所有与提议的维护时间窗口重叠的维护事件。

然后,我们聚合各类产品 API,例如 Aegis 客户 IP 池列表。Aegis 会返回一组 IP 范围,这些范围对应客户要求从其特定数据中心 ID 发出流量的位置,如下图所示。

[
    {
      "cidr": "104.28.0.32/32",
      "pool_name": "customer-9876",
      "port_slots": [
        {
          "dc_id": 21,
          "other_colos_enabled": true,
        },
        {
          "dc_id": 45,
          "other_colos_enabled": true,
        }
      ],
      "modified_at": "2023-10-22T13:32:47.213767Z"
    },
]

在该场景中,数据中心 21 和数据中心 45 是相互关联的,因为对于 Aegis 客户 9876 来说,我们需要至少一个数据中心保持在线,以便其能够从 Cloudflare 接收出口流量。如果我们试图同时关闭数据中心 21 和 45,调度协调器就会提醒我们,该客户的工作负载将因此受到意外影响。

最初我们尝试了一个简单的方案:将所有数据加载到单个 Worker 中。这包括所有服务器关联关系、产品配置,以及用于计算约束的产品和基础设施健康指标。但只是在概念验证阶段,我们就遇到了“内存不足”的错误。

我们需要更加注意 Workers 的平台限制。这就要求仅加载处理约束业务逻辑所绝对必需的数据。如果收到德国法兰克福某台路由器的维护请求,我们几乎可以肯定不需要关心澳大利亚的情况,因为跨区域之间没有关联。因此,我们只应加载德国邻近数据中心的相关数据。我们需要一种更高效的方法来处理数据集中的关联关系。

在 Workers 上进行图处理

在分析约束时,我们发现一种规律:每个约束本质上可归结为两个概念——对象与关联。在图论中,这两类组件分别称为顶点 (vertices) 和边 (edges)。对象可以是网络路由器,而关联则可以是该数据中心内要求路由器保持在线的一组 Aegis 池。我们从 Facebook 的 TAO 研究论文中获得启发,在产品与基础设施数据之上建立了一套图接口。API 示例如下:

type ObjectID = string

interface MainTAOInterface<TObject, TAssoc, TAssocType> {
  object_get(id: ObjectID): Promise<TObject | undefined>

  assoc_get(id1: ObjectID, atype: TAssocType): AsyncIterable<TAssoc>
}

核心理念在于,关联是有类型的。例如,约束会调用图接口来获取 Aegis 产品数据。

async function constraint(c: AppContext, aegis: TAOAegisClient, datacenters: string[]): Promise<Record<string, PoolAnalysis>> {
  const datacenterEntries = await Promise.all(
    datacenters.map(async (dcID) => {
      const iter = aegis.assoc_get(c, dcID, AegisAssocType.DATACENTER_INSIDE_AEGIS_POOL)
      const pools: string[] = []
      for await (const assoc of iter) {
        pools.push(assoc.id2)
      }
      return [dcID, pools] as const
    }),
  )

  const datacenterToPools = new Map<string, string[]>(datacenterEntries)
  const uniquePools = new Set<string>()
  for (const pools of datacenterToPools.values()) {
    for (const pool of pools) uniquePools.add(pool)
  }

  const poolTotalsEntries = await Promise.all(
    [...uniquePools].map(async (pool) => {
      const total = aegis.assoc_count(c, pool, AegisAssocType.AEGIS_POOL_CONTAINS_DATACENTER)
      return [pool, total] as const
    }),
  )

  const poolTotals = new Map<string, number>(poolTotalsEntries)
  const poolAnalysis: Record<string, PoolAnalysis> = {}
  for (const [dcID, pools] of datacenterToPools.entries()) {
    for (const pool of pools) {
      poolAnalysis[pool] = {
        affectedDatacenters: new Set([dcID]),
        totalDatacenters: poolTotals.get(pool),
      }
    }
  }

  return poolAnalysis
}

在上面的代码中,我们使用了两种关联类型:

  1. DATACENTER_INSIDE_AEGIS_POOL,用于检索数据中心所在的 Aegis 客户池。

  2. AEGIS_POOL_CONTAINS_DATACENTER,用于检索 Aegis 池为流量提供服务所需的数据中心。

这两种关联互为反向索引。访问模式与之前完全相同,但现在图实现能更好地控制查询的数据量。以前我们需要将所有 Aegis 池加载到内存中,并在约束业务逻辑里进行过滤;现在我们可以直接获取与应用相关的数据。

该接口的强大之处在于,图实现可以在后台优化性能,而不会让业务逻辑变得更复杂。这让我们可以利用 Workers 的可扩展性以及 Cloudflare CDN 的优势,从内部系统中非常快速地获取数据。

获取管道

我们切换到使用新的图实现后,开始发送更具针对性的 API 请求。响应大小在一夜之间减少了 100 倍——从加载少数几个巨大的请求变为加载许多微小的请求。

虽然这解决了一次性加载过多数据导致内存不足的问题,但我们又遇到了子请求数量过多的新问题。因为现在我们不再发起少量大型 HTTP 请求,而是发起数量级更多的微小请求,结果很快就持续突破了 Workers 的子请求限制。

为了解决这个问题,我们在图实现和 fetch API 之间构建了一个智能中间件层。

export const fetchPipeline = new FetchPipeline()
  .use(requestDeduplicator())
  .use(lruCacher({
    maxItems: 100,
  }))
  .use(cdnCacher())
  .use(backoffRetryer({
    retries: 3,
    baseMs: 100,
    jitter: true,
  }))
  .handler(terminalFetch);

如果您熟悉 Go 语言,可能见过 singleflight 包。我们借鉴了这个思路,在获取管道中的第一个中间件组件实现了对正在进行的 HTTP 请求去重,让同一 Worker 内的重复请求都等待同一个 Promise 返回数据,而不是产生重复的请求。接下来,我们使用轻量级的最近最少使用 (LRU) 缓存,在内部缓存已经请求过的数据。

完成这两项操作后,我们使用 Cloudflare 的 caches.default.match 函数缓存 Worker 运行所在区域的所有 GET 请求。由于我们有多个性能特征各异的数据源,因此我们谨慎地选择生存时间 (TTL) 值。例如,实时数据仅缓存 1 分钟。相对静态的基础设施数据可以根据数据类型缓存 1 到 24 小时。电源管理数据可能需要手动更改且更改频率较低,因此我们可以将其在边缘端缓存更长时间。

除了上述层级外,我们还加入了标准的指数退避 (exponential backoff)、重试 (retries) 和抖动 (jitter) 机制。这有助于减少因下游资源临时不可用而产生的无效 fetch 调用。通过适度退避,我们提高了下一次成功获取数据的概率;反之,如果 Worker 持续不断发送请求而不退避,当源站返回 5xx 错误时很容易突破子请求限制。

综合这些措施后,我们实现了约 99% 的缓存命中率。缓存命中率是指直接从 Cloudflare 快速缓存内存(命中)返回数据的 HTTP 请求,相对于需要从控制平面数据源(未命中)发起较慢请求的百分比,计算公式为 (命中数/(命中数 + 未命中数))。高命中率意味着更好的 HTTP 请求性能和更低的成本,因为在 Worker 中从缓存查询数据的速度比从位于不同区域的源服务器获取数据快一个数量级。经过调优内存缓存和 CDN 缓存的设置后,命中率大幅提升。由于我们的工作负载很多是实时的,命中率永远达不到 100%,因为我们每分钟至少需要请求一次最新数据。

我们已经讨论了如何改进获取层,但尚未说明如何让源站的 HTTP 请求更快。我们的维护调度器需要实时响应网络降级和数据中心机器故障。为此,我们使用分布式 Prometheus 查询引擎 Thanos,将边缘的高性能指标传递到调度器中。

实时 Thanos

为了说明采用图处理接口如何影响实时查询,我们来看一个例子。要分析边缘路由器的健康状况,原本我们会发送如下查询:

sum by (instance) (network_snmp_interface_admin_status{instance=~"edge.*"})

最初,我们向存储 Prometheus 指标的 Thanos 服务请求每个边缘路由器的当前健康状况列表,并在 Worker 中手动筛选与维护相关的路由器。这种方法有很多不足之处。例如,Thanos 返回的响应大小高达数 MB,需要进行解码和编码。Worker 也需要缓存和解码这些大型 HTTP 响应,以便在处理特定维护请求时过滤掉大部分数据。由于 TypeScript 是单线程的,而解析 JSON 数据又受 CPU 限制,发送两个大型 HTTP 请求意味着一个请求会被阻塞,等待另一个请求完成解析。

现在我们改为直接使用图来查找有针对性的关联关系,例如边缘路由器与骨干路由器之间的接口链路,用 EDGE_ROUTER_NETWORK_CONNECTS_TO_SPINE 表示。

sum by (lldp_name) (network_snmp_interface_admin_status{instance=~"edge01.fra03", lldp_name=~"spine.*"})

结果平均只有 1 KB,而不是数 MB,大约缩小了 1000 倍。这也大幅降低了 Worker 内部的 CPU 消耗,因为我们把大部分反序列化工作交给 Thanos 完成。正如前面所说,这意味着我们需要发起更多的小请求,但 Thanos 前的负载平衡器可以将请求均匀分散,从而提高这种用例的吞吐量。

我们的图实现与获取管道成功控制了成千上万个微小实时请求形成的“惊群效应”(thundering herd)。不过,历史分析带来了不同的 I/O 挑战——我们不再只是获取小而具体的关联关系,而是要扫描数月的数据来发现冲突的维护窗口。过去,Thanos 会对我们的对象存储 R2 发起大量随机读取,造成巨大带宽开销。为了解决这一问题又不损失性能,我们采用了 Observability 团队今年内部开发的新方法。

历史数据分析

我们有足够多的维护用例,必须依赖历史数据来判断我们的解决方案是否准确,以及能否随 Cloudflare 网络的扩展而扩展。我们既不想引发事故,也不希望不必要地阻碍已计划的物理维护。为了在这两个目标之间取得平衡,我们可以利用两月前甚至一年前的维护事件时间序列数据,来分析某类维护事件违反我们约束(例如边缘路由器可用性、Aegis 相关规则)的频率。今年早些时候,我们曾在博客中介绍过利用 Thanos 自动向边缘发布及回滚软件的做法。

Thanos 主要将数据查询分发到 Prometheus,但当 Prometheus 的数据保留期不足以回答查询时,就不得不从对象存储(在我们的案例中为 R2)下载数据。Prometheus 的 TSDB 块最初是为本地 SSD 设计的,依赖随机访问模式,这在迁移到对象存储后会成为瓶颈。当我们的调度器需要分析数月的维护历史数据以识别冲突约束时,从对象存储进行随机读取会带来巨大的 I/O 开销。为解决此问题,我们实现了一个转换层,将这些 TSDB 块转换为 Apache Parquet 文件。Parquet 是一种面向大数据分析的原生列式存储格式,按列而非按行组织数据,并结合丰富的统计信息,使我们可以只获取所需的部分数据。

此外,由于我们将 TSDB 块重写为 Parquet 文件,还可以按支持少量大块依序读取数据的方式存储数据。

sum by (instance) (hmd:release_scopes:enabled{dc_id="45"})

在上面的示例中,我们将选择元组“(__name__, dc_id)”作为主要排序键,以便名称为“hmd:release_scopes:enabled”且“dc_id”值相同的指标能够被排到一起。

我们的 Parquet 网关现在会发起精确的 R2 区间请求,仅提取与查询相关的特定列。这将有效载荷从数 MB 减少到数 KB。而且因为这些文件段是不可变的,我们可以在 Cloudflare CDN 上积极缓存它们。

这使 R2 变成了低延迟的查询引擎,让我们可以即时针对长期趋势回测复杂的维护场景,避免了原 TSDB 格式下的超时和高尾延迟问题。下图展示了一次近期的负载测试,结果显示在相同查询模式下,Parquet 的 P90 性能最高可达旧系统的 15 倍。

如果想深入了解 Parquet 的实现原理,可以观看 PromCon EU 2025 上的演讲 Beyond TSDB: Unlocking Prometheus with Parquet for Modern Scale

为扩展而构建

通过利用 Cloudflare Workers,我们从一套会因内存不足而崩溃的系统,演进为一个能智能缓存数据并使用高效可观测性工具实时分析产品与基础设施数据的系统。我们构建了一个能在网络增长与产品性能之间取得平衡的维护调度器。

但“平衡”是个动态目标。

每天,我们都在全球增加更多硬件,而要确保维护过程中不打断客户流量,其逻辑复杂度会随着产品种类和维护操作类型的增多呈指数级上升。我们已经攻克了第一阶段的挑战,但现在正面对只有在如此大规模下才会显现的更微妙、更复杂的问题。

我们需要不怕难题的工程师。加入我们的基础设施团队,和我们一起构建未来吧。

我们保护整个企业网络,帮助客户高效构建互联网规模的应用程序,加速任何网站或互联网应用程序抵御 DDoS 攻击,防止黑客入侵,并能协助您实现 Zero Trust 的过程

从任何设备访问 1.1.1.1,以开始使用我们的免费应用程序,帮助您更快、更安全地访问互联网。要进一步了解我们帮助构建更美好互联网的使命,请从这里开始。如果您正在寻找新的职业方向,请查看我们的空缺职位
Cloudflare WorkersReliabilityPrometheusInfrastructure

在 X 上关注

Cloudflare|@cloudflare

相关帖子