☰
Cloudflare Workers实战:用R2、D1和Durable Objects搭建边缘OS骨架
2026/10/28 14:25:03 网站建设 项目流程

1. 当"cloudflare-os"这个组合出现时,先别急着把它当成操作系统

大概一年前,我在一个开发者社群里看到有人抛出一张架构图,标题就写着 cloudflare-os。图里的内容不是 Linux 内核,也没有进程调度器,而是一堆方块:Workers 在最外层挡请求,中间是 KV、R2、D1,再下面是 Durable Objects 和 Queue。那个人管这套东西叫"边缘上的操作系统"。当时群里一半人觉得这是在造概念,另一半人问这玩意和普通的 Serverless 后端有什么区别。

我得承认,我一开始属于"造概念"那派。直到我自己把一个原本跑在单台机器上的项目拆到 Cloudflare 的这套组件上,我才意识到这个命名其实挺准确的。传统意义上的"操作系统"负责管理 CPU、内存、磁盘、进程和权限,给上层应用一个稳定、统一的运行环境。当你把 Cloudflare 的 Workers、R2、KV、D1、Durable Objects 组合到一起,你会发现你正在做的恰恰是同一件事:用一套统一的接口,去调度分布在全世界几百个节点上的计算资源、存储资源和状态资源。

所以这篇文章,我会以我自己做的一套"边缘OS"骨架为主线,把 cloudflare-os 这种设计思路完整拆开:它到底解决什么问题、每个官方组件在架构里扮演什么角色、代码怎么组织、部署之后有哪些坑。如果你正在头疼"一台服务器扛不住怎么办""状态不知道该放哪里""定时任务和会话管理乱成一团",这篇文章值得看到最后。

适用人群我也可以直接说清楚:玩过一点 Cloudflare Workers 但没用过 R2 和 Durable Objects 的人,以及想把手头单体服务改造成边缘架构但不知道如何下手的人,都可以照着文章里的步骤复现。前端朋友也没关系,我会把涉及协议和存储的部分尽量讲得直白,你把它当"一个跑在浏览器外、但能用 HTTP 调用的云端服务"来理解即可。

1.1 它不是虚拟机,而是一场"借用系统能力"的实践

先破除一个可能的误解:cloudflare-os 不是要在 Workers 里装一个 Linux,也不是 Cloudflare 出的桌面系统。它是一套设计方法论——借用操作系统"内核 + 驱动 + 文件系统 + 进程管理"的思路,来组织边缘平台上的各种能力。你写的代码依旧是一个个 Worker 函数,你处理的数据依旧存放在对象存储和数据库里,但你的心智模型变了:你不再是伺候一台机器的运维,而是像写内核模块一样,去编排一组"系统原语"。

这个转变带来的直接好处是,问题被精确地分类了。登录态该放哪、上传的文件该放哪、实时会话怎么维持、定时任务谁来触发,每一类需求都能映射到平台的一个组件上。而不像单机时代,什么都往内存和磁盘里塞,最后出一堆馊主意。

1.2 从"买服务器"到"组合原语"

传统模式里,我们要先去买服务器,装系统,配 Nginx,装 Node,写 systemd 服务,再考虑监控告警。服务器是一个物理实体,你得伺候它。而 cloudflare-os 的思路截然相反:没有一台属于你的服务器,只有一组暴露出来的"系统能力"——请求入口、函数计算、键值存储、对象存储、关系数据库、持久会话、定时任务。你要写的是这些能力之间的调度逻辑,而不是跑在某台机器上的进程。

说个最直观的对比:我以前导数据服务要处理几十 MB 的 CSV,单机方案是"读文件、拆任务、塞内存队列、后台线程慢慢跑"。换成 cloudflare-os 的形态后,上传直接打到 R2,异步任务交给 Queue 或者 Cron Trigger 触发,任务状态放进 D1,用户并发时由 Durable Object 保证单一处理。整个系统没有一台固定的服务器,但什么功能都在。

2. 为什么单机架构会让人想换成"边缘OS":三个绕不过去的坎

2.1 进程边界与请求边界天生错配

传统单机架构里,你的服务进程是常驻的。Node.js 进程从启动那一刻起就占着内存,不断接受请求。进程内部保存着所有状态:内存里的缓存、数据库连接池、WebSocket 连接对象。这套模型在流量低的时候很舒服,开发者可以非常自然地写代码,想存什么就存一个全局变量。

但流量一旦上来,麻烦就来了。同一个进程可能同时收到几千个请求,任何一段同步阻塞代码都会拖垮整台机器。你想加机器,就要面对"我的状态到底存在哪台机器上"这个灵魂拷问。session 存内存?负载均衡一转发就丢。上传的文件存磁盘?两台机器不共享。就算你用 Redis 把状态抽出来,还得自己维护 Redis 集群。这个坎的本质是:你写业务代码的时候,默认了自己运行在一个"无限可靠的单机"上,而这个假设在大流量下不成立。

2.2 全局状态和弹性扩容互相冲突

这个我印象特别深。之前我维护过一个导数据服务,用户上传一个几十 MB 的 CSV,后端解析完写进数据库。在单机上,我把任务放进内存队列,后台线程慢慢处理。换成两个实例之后,A 实例接收任务、B 实例空闲,任务就只能在 A 上慢慢排队。我不得不用 Redis 重新实现分布式队列,锁、重试、幂等全来了。

这不只是"换个存储"的问题。分布式环境下,状态从"我看看内存里的值"变成了"我要去问问某个存储服务"。你要设计缓存失效、要处理并发写覆盖、要容忍各种最终一致。这就像一台单机操作系统,突然要把所有进程分散到不同机器上运行,还要它们共享同一套文件系统。

2.3 可用性维护成本被严重低估

单机还有一个隐藏账单:运维。磁盘报警、证书过期、半夜三点的内核更新,都是成本。云厂商的虚拟机稍微好一点,但你还是得考虑实例规格够不够、磁盘要不要扩容、安全组规则对不对。Cloudflare 这类边缘平台把这一层完全吃掉了,你不需要知道你的代码跑在哪台物理机上,也不需要关心怎么在凌晨给某台机器重启容器。你需要面对的问题变成:这些平台提供了哪些"原语",我怎么用它们凑出一套有状态、有计算、有存储的系统。cloudflare-os 的整套思路,就是对这个问题的直接回答。

3. 我到底用 Cloudflare 的哪些能力装出这台"联邦电脑"

3.1 组件角色总览:先看这张表再深入

传统操作系统cloudflare-os 里的对应负责的事情
CPU 执行体Worker Script接收请求、执行函数、返回响应
物理内存KV低延迟读取的小型键值数据,适合全局配置、轻量状态
磁盘文件系统R2对象存储,适合文件、图片、大体积数据
数据库引擎D1SQL 关系型数据库,适合结构化查询
进程 / 线程Durable Object有状态、单线程执行的实体,可以在多次请求之间保留内存数据
硬件中断 / 定时器Cron Triggers、Queues定时触发任务、异步任务分发
系统调用fetch、bindings、Service Bindings业务代码与基础设施交互的接口

这张表不是让你背的,而是让你在写代码之前先做一次"需求分类":这个状态是全局的、还是某个用户私有的?这数据是小 size 高频率访问、还是大 size 低频访问?需要强一致还是最终一致?分类分完了,组件选型自然就出来了。

3.2 Workers 是执行体,但它不是万能的

Worker 本质上就是一个在边缘节点运行的 JavaScript 函数。它最大的特点是"用完即走",函数执行完,内存里的一切都销毁。这和 CPU 执行指令很像:指令跑完,寄存器里的值不会替你保存。所以你要有意识地规划:哪些状态放 KV,哪些放 R2,哪些放 Durable Object,而不是逮着一个变量就全局存储一把梭。

很多人第一次从传统后端转过来会不适应:"我写个计数器放全局变量,多用户访问不就行了吗?"行,但只在单个实例的单个轮次内行。下一次请求可能被路由到另一台边缘节点,你的"全局变量"其实是碎片。理解这个之后,你再看 KV 和 R2 就会觉得它们是救命稻草,而不是额外的存储服务。

3.3 R2 不是"S3 替代品",它更像是边缘文件系统

很多文章把 R2 只说成"S3 的替代品"。只从 API 角度看是对的,但从架构角度看,R2 在边缘的定位更像分布式文件系统。在你的 cloudflare-os 里,R2 是唯一的"长期磁盘",上传的头像、生成的报表、导入的 CSV 备份,都该进 R2。

我开发时最喜欢的一点是:R2 的 API 和 S3 兼容,但不需要维护访问密钥的生命周期,通过 Worker 的 binding 直接读写。安全模型由身份体系决定,而不是一把躺在服务器上的 AK/SK。另外,R2 的 put 支持流式写入,数据不用先读完缓存在内存里再落盘,这对大文件上传特别关键,后面实战部分我会演示具体写法。

3.4 Durable Objects 是"有状态进程",也是最难用好的一块

Durable Object 给开发者的是一个单线程、有持久存储的对象。同一个 DO 实例,同一个时间点只处理一个请求。这从源头上消灭了并发冲突,但如果你把它当普通数据库用,很快就打满限额。我自己的用法是:一个 DO 对应一个"房间"或者一个"会话",比如聊天室、在线文档编辑、用户登录态。它天然适合做需要强一致的小范围状态。

需要注意,DO 的单线程特性意味着所有访问要排队。你在里面放一个耗时三秒的死循环,其他请求全部卡住。所以 DO 内部只做轻量逻辑,重活丢给 R2 或者 D1,DO 负责"状态的唯一裁决者"这个角色。

4. 从零搭建 cloudflare-os 骨架:能登录、能传文件、能跑命令

这一节进入实战。我会搭一个最小的 cloudflare-os 骨架,包含四部分能力:用户登录、文件管理、模拟终端、会话保持。最终部署在 workers.dev,或者你自己绑定的域名上。

4.1 工程初始化:一次性把五段配置写对

我用官方脚手架创建项目,核心配置如下:

name = "cloudflare-os" main = "src/index.js" compatibility_date = "2025-01-01" [[kv_namespaces]] binding = "AUTH_KV" id = "你的KV命名空间ID" [[r2_buckets]] binding = "FS_BUCKET" bucket_name = "cloudflare-os-fs" [[d1_databases]] binding = "DB" database_name = "cloudflare-os-db" database_id = "你的D1数据库ID" [[durable_objects.bindings]] name = "SESSION_DO" class_name = "SessionObject" [[migrations]] tag = "v1" new_sqlite_classes = ["SessionObject"]

这里有个我踩过的坑:durable_objects 的 migration 必须写好,否则首次部署会报"Class not found"。我第一遍就漏了,线上一直 500,查本地却没问题,排查了好久。另外,KV 命名空间 ID 和 D1 数据库 ID 在创建项目时会自动生成,但你也可以在命令行用wrangler kv:namespace create和wrangler d1 create手动建好再填进来。

4.2 入口路由:把请求分发给"内核子系统"

所有能力都通过一个入口进入。我的 index.js 就是一个纯路由,不写业务逻辑:

export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname; if (path.startsWith('/api/session')) { return handleSession(request, env); } if (path.startsWith('/api/fs')) { return handleFs(request, env); } if (path.startsWith('/api/exec')) { return handleExec(request, env, ctx); } return serveStatic(request); } }

这个设计对应操作系统里的"中断向量表":不同中断号走不同的处理函数。好处是后续新增子系统时,不需要改既有三块代码。注意入口函数要保持薄,不要堆积解析逻辑,不然以后拆 Service Binding 会很痛苦。

4.3 文件系统:让 R2 看起来像一个目录树

R2 本身没有目录概念,它只有扁平的 key。我们可以用/前缀模拟目录。上传接口长这样:

async function handleFs(request, env) { if (request.method !== 'PUT') { return new Response('Method Not Allowed', { status: 405 }); } const url = new URL(request.url); const key = decodeURIComponent(url.pathname.slice('/api/fs'.length)); await env.FS_BUCKET.put(key, request.body, { httpMetadata: { contentType: request.headers.get('Content-Type') || 'application/octet-stream' } }); const head = await env.FS_BUCKET.head(key); return new Response(JSON.stringify({ key, size: head.size }), { headers: { 'Content-Type': 'application/json' } }); }

这里要特别提醒:不要把整个文件读进内存再 put。我最早图省事,先 await request.blob() 再转 arrayBuffer,结果传大文件时,Worker 内存超限,直接 OOM。正确写法是直接把request.body这个 ReadableStream 传给 put,R2 内部会流式接收。你甚至可以在 put 的时候加customMetadata和httpMetadata,把文件的原始信息一起存进去,列表展示的时候就省了额外查询。

4.4 模拟终端:一个能"执行命令"的接口

很多"edge OS"项目都喜欢带一个模拟终端,其实本质就是给 Worker 加一个命令解析接口。我用一个很薄的实现:

async function handleExec(request, env, ctx) { if (request.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } const { cmd, args } = await request.json(); const allowed = ['ls', 'cat', 'whoami']; if (!allowed.includes(cmd)) { return Response.json({ error: 'command not found: ' + cmd }, { status: 404 }); } if (cmd === 'ls') { const prefix = (args[0] || '').replace(/\/$/, '') + '/'; const list = await env.FS_BUCKET.list({ prefix, limit: 50 }); return Response.json({ output: list.objects.map((o) => o.key) }); } if (cmd === 'cat') { const object = await env.FS_BUCKET.get(args[0]); if (!object) { return Response.json({ error: 'No such file: ' + args[0] }, { status: 404 }); } return Response.json({ output: await object.text() }); } return Response.json({ output: 'cloudflare-os shell: hello' }); }

"命令"白名单是我故意做的。边缘函数不能像真正的 shell 一样执行任意进程,但我们可以把固定动作映射成命令,这就像把系统调用暴露成受限的 API。前端拿到output字段之后,在一个模拟终端组件里逐行渲染,体验上就很像在敲命令。

4.5 会话与登录:用 Durable Object 当"登录凭证校验器"

登录流程我简化一下:用户提交用户名密码,Worker 校验通过后,创建(或读取)一个 Durable Object 作为会话。这个对象内部存着用户信息和近期操作记录。

export class SessionObject { constructor(state, env) { this.state = state; this.session = null; } async fetch(request) { if (request.method === 'POST') { const data = await request.json(); this.session = { user: data.user, loginAt: Date.now(), ops: data.ops || [] }; await this.state.storage.put('session', this.session); return Response.json({ ok: true, session: this.session }); } const session = this.session || (await this.state.storage.get('session')); return Response.json({ session }); } }

Durable Object 的 storage.put 是持久化的,所以即使实例被回收,数据也能恢复。这是它和普通 Worker 全局变量的最大区别。实际使用时,你还要在入口处加一层鉴权中间件,每次请求都拿 Session ID 去对应的 DO 里验一下。这个"验一下"的开销很小,但换来了带状态的服务能力。

5. 实测上线后的那些坑:一致性、并发与冷启动的不完全清单

骨架能跑之后,真正的考验才开始。以下每个问题都是我在实际部署、压测、换域名上线的过程中遇到的,网上文档往往一句话带过,但真实现场会让你折腾好几天。

5.1 R2 的读后写一致性与 list 延迟

R2 默认是强一致的,但某些操作场景下,写入后立即 list,可能拿不到刚写入的对象。我在做"文件列表刷新"功能时,前端上传完立刻拉列表,结果列表里没有刚上传的文件。排查到最后发现是list操作的索引更新存在延迟,而直接get同一个 key 绝对是准的。

解决办法是业务层接收 put 成功响应后,手动把 key 写入返回结果里,或者前端短暂等待再刷新列表。如果你在做"上传后立刻展示"的场景,不要依赖 list 的实时性,最好用"当前文件的已知集合 + 新增项手动合并"来保证展示一致。

5.2 Durable Object 的并发是 1,别往里堆全量数据

DO 的单实例并发是 1,这意味着你的所有读请求都要排队。我把用户的全量操作日志放进 DO 后,压测时发现吞吐极低,因为每次读取都要等上一次操作写完。最后的架构调整是:DO 只存会话凭证和核心状态,操作日志直接写进 R2 或者 D1,DO 只负责"记账动作",不负责"数据存储"。

这就像操作系统的内核:内核只维护进程表和调度状态,文件内容全丢给磁盘驱动。你如果拿内核去存用户文件,系统早卡死了。

5.3 冷启动与首次存储访问的耗时

Workers 的冷启动在多数地区已经很快,但如果你在冷启动后立即连接 R2 或者 D1,首次鉴权可能要额外耗时。我的优化是:把不依赖状态的健康检查接口单独写成一条极短路径,不读 KV,不读数据库。这样负载均衡的健康探测不会把所有 Worker 实例"暖"得毫无意义。

另外一个经验:尽量把每个子系统拆成独立 Worker,用 Service Binding 互调。这样冷启动影响范围被限制在单个模块,其他模块可以保持热状态。我在拆文件上传和文件下载两个入口时,就是分别建了两个 Worker,上传高峰不会拖累下载路径,排查问题时也能单独看各自的日志。

5.4 本地 Wrangler 与线上行为不一致

最典型的就是 Durable Object 的 storage。本地模拟器会直接落盘,线上是分布式的,两者在超时和失败时表现不一样。我用本地环境测了一个"断网重试"逻辑,线上仍然随机失败,原因就是对存储操作的失败重试处理不够幂等。

对策是:所有写 DO storage 的地方,要么用唯一 key 保证幂等,要么用事务包装。这个习惯早一点建立,线上就少一点 500 错误。另外,本地没有真正的边缘节点,你在 wrangler dev 里开几个 WebSocket 连接体感极佳,一上线才发现跨地区的延迟和连接维持完全是另一个故事。早点用wrangler preview或者直接部署到测试域名,比在本地无限调试靠谱得多。

6. 骨架之上还能长什么:我走过的扩展路线

到这里,一个最小的 cloudflare-os 已经可以跑通。但它离"好用"还很远,我列几个我正在做的扩展,算是抛砖引玉。

6.1 静态资源和桌面前端的加载策略

模拟桌面环境的前端,完全可以推到 Cloudflare Pages 或 Workers Static Assets。API 和静态资源分离后,首屏加载速度会非常稳定。我的策略是:HTML 用最少的 JS,桌面图标直接写进 HTML,避免像 SPA 一样先加载整个 bundle 再渲染。你甚至可以给静态资源配置一个比较长的Cache-Control,让浏览器和边缘节点同时缓存。

6.2 把配额管理做成"资源管理器"

既然套用了 OS 的隐喻,那"资源管理器"也得上。我用 KV 存每个用户的用量计数,在 Worker 入口处统一扣除配额,超限就返回 429。因为 KV 是低延迟的,放在请求链路前段不会拖慢主流程。

const limit = 100; const usageKey = `quota:${userId}`; const usage = (await env.AUTH_KV.get(usageKey, 'json')) || 0; if (usage >= limit) { return Response.json({ error: 'quota exceeded' }, { status: 429 }); } await env.AUTH_KV.put(usageKey, usage + 1);

这个方案不是精确计数,但做限流已经足够。如果你需要精确配额,可以用 Durable Object 做计数器,但成本会高很多。个人经验是:配额管理做"粗粒度限流"就够了,精确到个位数的计费应该放到 D1 做日终汇总,而不是每个请求都在线扣。

6.3 让子系统之间通过 Service Binding 互调

把每个子系统都提供 HTTP 接口,相当于开放了一批"系统调用"。我现在给同一个 Worker 加 Service Binding,让会话模块、文件模块、终端模块之间可以像本地函数一样互相调用,而不是各自发起 HTTP 请求。这既省了网络开销,也让权限统一由入口管控。

我在实际使用中的体会是:cloudflare-os 这种思路,最大的价值不在于替换某台服务器,而在于帮你重新思考"状态放哪里、计算在哪里跑、失败如何隔离"这三个问题。即便你最后不采用这套架构,沿着它的追问方式,也能顺带把自己项目的模块边界理清楚。

最后再分享一个小技巧:给 Worker 的入口埋一个?debug=1参数,在返回头里加几个自定义 header,把命中的容器信息、R2 请求耗时、D1 查询条数全部打出来。线上排错时,你一眼就能看出请求经历了哪些环节,这比看任何日志都直观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询