大规模内部测试:将 cdnjs 迁移到 Cloudflare 的开发者平台

发布于

截至2026年6月23日,cdnjs 这一互联网最繁忙的开源 CDN 之一,已完全运行在 [Cloudflare 的开发者平台](https://www.cloudflare.com/en-gb/developer-platform/) 上。在此过程中,cdnjs 发现了平台的局限性,而平台也在不断成长以应对这些限制。

[cdnjs](https://cdnjs.com/) 是一个免费的开源内容交付网络,主要用于 JavaScript 和 CSS 库。用户只需插入一行 `<script>` 标签,指向 [cdnjs.cloudflare.com](http://cdnjs.cloudflare.com),即可从 Cloudflare 的边缘节点即时加载库,全球范围内皆可,无需注册、无 API 密钥、无速率限制。这为许多“JavaScript 入门”教程、CodePen 示例以及 Stack Overflow 答案的基础设施提供了支持。

作为一个社区驱动的项目,cdnjs 被 [大约 12% 的所有网站](https://w3techs.com/technologies/details/cd-cdnjs) 使用,在 JavaScript CDN 市场中占据了 48.3% 的份额。它的日均请求量高达 9 亿,每秒处理 10.8 万个请求,覆盖超过 [330 个 Cloudflare 数据中心](https://www.cloudflare.com/en-gb/network/),缓存命中率达到 98.6%。这真是太酷了,互联网!

早在 2011 年,当打包工具仍属少见时,npm 刚问世不久,用户在网页上“只需插入一个 `<script>` 标签”就是 JavaScript 的标准做法,Ryan Kirkman 和 Thomas Davis 建立了 cdnjs,作为每个流行开源库的免费社区镜像。

Cloudflare 介入后,于几个月后无偿提供托管,并在 [2019 年接管了项目维护](https://blog.cloudflare.com/an-update-on-cdnjs)。当时,Cloudflare 尚未拥有能够完全支撑整个 cdnjs 生态系统的成熟开发者平台。经过十五年和大量构建后,平台现已成熟,能够全面运行 cdnjs,利用 Workers、Workflows、D1、Queues、Workers Cache、R2、KV 和 Containers。

### 为什么 cdnjs 每天有 9 亿请求

互联网的变化令当年不可同日而语。我们有 ES 模块(ESM),浏览器本地理解的标准化导入/导出语法;我们有导入映射,Vite,Bun,Turbopack;我们有可以在几秒钟内生成整个应用的 AI 助手。打包工具无处不在。那么,一个仅为 `<script>` 标签提供服务的 CDN 为什么仍旧能每天处理 9 亿个请求呢?

原因之一是:大型语言模型(LLMs)喜欢 cdnjs。当 ChatGPT、Claude 或 Cursor 构建快速的 HTML 示例时,它们会选择 cdnjs,因为它们的训练数据中充满了 cdnjs 的相关信息。过去的十五年里,博客帖子、GitHub README、教程网站和问答线程都在指向 [cdnjs.cloudflare.com](http://cdnjs.cloudflare.com)。这个 URL 模式是一致的,版本也是不可变的——正是模型可以可靠生成而不产生幻觉的依赖项。

cdnjs 上的每个文件都有 SRI 哈希值(*我们仍在努力确保所有现存的哈希值与真实匹配,因旧系统存在 Bug*),镜像是可审计的,整个项目是开源的。在当今越来越担忧供应链攻击的环境中,一个不可变、哈希验证的知名库镜像,这是不可或缺的。

*而且这是免费的,永远对每个人开放。没有 API 密钥。没有速率限制。没有“注册以继续”。这在如今的互联网中是十分少见的,值得保护。*

### 我们为什么迁移

我们迁移并非因为 cdnjs 速度慢,而是因为我们希望不断改进它。

之前的架构为用户提供了良好的服务:98% 的缓存命中率,数十亿次请求,无宕机。但在内部,快速处理任何新建或修复现有包的问题变得越来越困难。更改意味着要协调在 GCP Functions、虚拟机(VM)和 Cloudflare 之间的部署,观察性也令人痛苦。

### 痛点

在2020年, [我们将 cdnjs 迁移到无服务器架构](https://blog.cloudflare.com/migrating-cdnjs-to-serverless-with-workers-kv/),将文件服务迁移到 Cloudflare Workers 和 KV,外加一个裸金属原作为备用。此变更显著提高了弹性和可扩展性,并让我们能够对每个资产进行 Brotli 和 gzip 预压缩,以获得更小、更快的响应,但仅限于服务端。

发布端——监控 npm 和 GitHub 新库版本的流水线、下载它们、处理它们并写入结果以供 cdnjs 服务——仍然驻留在谷歌云平台(GCP)。那时,Cloudflare Workers 旨在快速处理短暂的 HTTP 请求;它们还没有构建大型、长流程的数据提取、CPU 密集型的压缩和长时间工作的多步骤流水线的能力。Workflows、Queues、Durable Objects、R2 和 Containers 还不存在。

所以我们根据当时可用的构建了一只发布机器人:一系列 GCP Functions、一个运行 git-sync 的 VM,以及 [GitHub 存储库作为真实来源](https://github.com/cdnjs/cdnjs)。这是可行的,但六年之后,那个架构已经露出了老化的迹象。以下是之前架构的示意图:

该架构有五个痛点。痛点中最严重的是观察性:调试意味着需要手动拼接日志。在这里我们首先开始。

1. **没有共享追踪**
单个包更新可能需要经过 Cloud Functions、Google Cloud Storage (GCS) 对象事件、Pub/Sub 主题、一个 git-sync VM 和 Workers KV 才能到达用户。所有这些系统没有共享的关联 ID。GCP 日志记录了一部分故事,而 Cloudflare Logpush 则记录了另一部分,两者之间没有共同的关键可以关联。

问题不是完全失败,而是部分成功。一个处理干净、写入 KV 的版本,之后却在 GitHub 仓库中静默失败,会让用户能够正常使用好几周,直到有人注意到两个存储之间的分歧。对此没有任何警报。没有人能够做到,因为系统中没有知道完整流水线状态的东西。

2. **分裂的存储**
文件同时存在于两个地方:边缘的 Workers KV(带有裸金属原作为备用)和 GitHub 存储库作为真实来源。每次运行结束时,摄取流水线都向两者写入数据。没有一个是权威的,当它们漂移时,没有清晰的方式来协调。

3. **通过对象事件黏合的流水线**
摄取流水线是小型 GCP Cloud Functions 的链,每个函数执行一步并通过共享存储将结果传递给下一个。一个函数从 npm 获取包的发布归档并放入一个桶中。存储桶触发的“新文件”事件激活下一个函数,它解压缩并写入结果,触发下一个循环,以此类推。存储机制在此充当消息队列,但没有死信队列、没有可见的积压、也没有干净的重放功能。

4. **26 个字母对应 26 个函数**
仅仅检查 npm 中的更新就需要 26 个 Cloud Functions,一个字母一个函数。每个分片都有自己的部署和日志,确保整个队列健康的唯一办法就是检查所有的 26 个函数。

5. **GitHub 仓库无法提供的 GitHub**
另一个虚拟机运行 git-sync,将每个处理过的文件镜像到 cdnjs/cdnjs。多年的发布推动它突破 1.1TB 的存储,足够大以至于 GitHub 自身的存档服务拒绝生成 tarballs 或 zip 下载。分叉变得不切实际,克隆变得缓慢,`.gitignore` 增长到 274 个手动创建的条目,阻止破损或版本不一致的发布。它成为了一个记录,保存了流水线无法合理拒绝的所有东西。

临近迁移带来的另一个好处是减少了需要保护的活动部分。Cloud Functions、git-sync 的 VM、容器镜像、GCS 桶、服务帐号密钥——每一个都是需要保护、打补丁和审计的对象。关闭流水线消除了最近修复的 cdnjs 漏洞。

### 我们如何重建它

新的 cdnjs 架构完全运行在 Cloudflare 的开发者平台上。

[R2](https://developers.cloudflare.com/r2/) 是文件内容的单一真实来源。它没有实际的大小限制,因此之前无法放入 KV 的文件,如源映射、大捆和字体包,如今可以与其他文件一起存放。作为额外的好处,S3 API 使整个 cdnjs 目录对任何 S3 客户端可访问。*想要维护一个镜像?在 [cdnjs 仓库](https://github.com/cdnjs/cdnjs) 上开个问题,我们将为你提供只读凭据。*

[KV](https://developers.cloudflare.com/kv) 现在只存储元数据:包信息、版本列表、SRI 哈希。KV 是为高读取量、低频写入而构建的,这正好与元数据访问的形状相吻合。

在 Worker 前面是 [Workers Cache](https://blog.cloudflare.com/workers-cache/),这是 Cloudflare 今年推出的分层缓存。在此之前,我们依赖于边缘和 Worker 之间的一个单独内部缓存层。现在,这个层已经被开发者平台控制的缓存所取代,正是这个平台运行着 cdnjs 的其余部分。这样移动的部分又减少了!

新的架构还延续了长期以来的合作关系。[DigitalOcean](https://www.digitalocean.com/) 多年来一直是 cdnjs 网站的赞助商,现在也提供了存储。每个发布到 R2 的文件都会镜像到 DigitalOcean Spaces:在架构上这是一个灾难恢复副本,同时在操作上也是一个实时备份。当 R2 无法返回文件时,服务工人会读取其内容。整个链条是缓存 → R2 → DigitalOcean,因此 R2 的坏日子不会让 cdnjs 崩溃。*在过渡期间,Cloudflare 托管的原始节点仍然存在,但在 GitHub 的数据回填落地后,它将被退役。*

摄取流水线基于 [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) 构建。每十分钟,一个 cron 任务触发 PackageUpdatesWorkflow,检查 npm 和 GitHub 的新版本。对每个找到的新版本,它会生成一个 DownloadPackageWorkflow,将归档文件获取到 R2,接着为每个文件生成一个 ProcessingWorkflow,进行提取、最小化和压缩。最后,PublishingWorkflow 将结果写入 R2 和 KV,并更新 [Algolia](https://www.algolia.com/) 搜索索引。

因为 Workflows 提供持久执行,每一步的状态会被保存。如果发生任何失败——网络超时、压缩错误——该工作流会从上一步恢复。

更为复杂的是我们如何将 Workflows 与 [外部压缩容器](https://developers.cloudflare.com/containers/) 结合。我们对基于文本的文件进行预压缩,以优化交付过程。但由于压缩对于 Worker 来说 CPU 密集,因此我们委派给 [Cloudflare Containers](https://developers.cloudflare.com/containers/),等待压缩完成后再继续。

该流水线有两种等待方式:

*每个文件:* 每个 ProcessingWorkflow 会将未压缩的文件写入 R2 桶,并向队列发送一个任务,然后进入休眠。一个在容器中运行的 Rust 压缩服务会接管这个任务进行压缩,并将结果写入另一个桶中的内容。R2 事件通知会唤醒工作流,以便继续处理。

*每个包:* 父工作流需要在所有子项完成后才能继续发布。一个有数千个文件的包意味着数千个子项并行运行。我们使用一个小的 Durable Object 作为计数器:父项在每生成一个子项时递增,子项完成时递减。当计数器归零时,父项会被唤醒。

新的架构概述以 R2 作为真实来源,Workflows 执行流水线:

### 推动极限

设计新的架构是一个挑战,而将现有目录迁移到此架构而不造成已存在文件的任何干扰则又是另一个挑战。

我们在此之前实际上尝试过一次,但不得不回滚。那时的计划是重新处理旧包并将结果直接写入 R2,但重新生成的文件与 KV 提供的字节不匹配。由于最小化器和压缩工具在跨版本时并不是完全确定的,因此新输出是正确的,但具有不同的 SRI 哈希。对于 CDN 来说,用户在其 HTML 中锁定这些哈希,任何不一致都会导致服务中断。所以我们回退了,现在我们将现有内容从 KV 迁移到 R2,保持原样,而不是重新生成。

这个决定将问题从“重新处理数百万个文件”转移到“在帐户之间复制数百万个文件,确保没有遗漏”。我们在此过程中遇到了 Workers 子请求限制,付费计划每次调用限制在 1000 个。一个拥有数千个文件的包会在一次调用中耗尽该限制。并行化也没帮助,因为每个 Worker 都必须遵循相同的限制。因此,我们按包名称对迁移进行了分片,通过 [Queues](https://developers.cloudflare.com/queues) 进行多次调用,确保不会有包静默地中断迁移。

在迁移过程中,我们碰到了两个平台限制:每次 Worker 调用 1000 个子请求以及 Workflows 每 1024 步的限制。我们不只是想方设法地规避这些限制,而是向 Workers 和 Workflows 团队请求提高了这些限制——他们最终做到了。子请求现已至少提高到 1000 万;Workflows 默认允许 [10,000 步,可配置至 25,000 步](https://developers.cloudflare.com/changelog/post/2026-03-03-step-limits-to-25k/)。

cdnjs 流水线基于任何人都可以使用的相同构建块:Workers、Workflows、R2、KV、Queues、Containers 和 Durable Objects。我们所面临的限制,也是我们为大家提升的限制。如果 Cloudflare 开发者平台能够每天处理 9 亿请求并发布数十万个压缩、最小化的文件包,这大概率也能支持你正在构建的东西。

### 接下来是什么

在这一切中,有一个显而易见的下一个问题:cdnjs 是否也可以服务现代的浏览器本地 ES 模块?同样的包,发布时转换,可以直接导入,而无需打包工具。架构并未排除这一可能。当前预压缩文件的 Workflows-plus-Containers 模式同样适用于对文件进行转换。我们并未承诺这一点,但这是现在可以考虑的事情,而不是一年前的情况。


`git commit -m "with love" --author="cdnjs team"`


*我们关注 GitHub 上的每个开源问题,并希望听取你的反馈。请不要犹豫,参与进来,帮助让互联网对每个人都更美好。*

---

原文链接:[点击查看](https://blog.cloudflare.com/cdnjs-dev-platform-migration/)

评论

暂无评论。

0.072177s