ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Cloudflare Workers、KV、D1与R2搭建云端网页桌面系统

用Cloudflare Workers、KV、D1与R2搭建云端网页桌面系统

看到 cloudflare-os 这个名字,先别急着去找 ISO 镜像。它不是 Cloudflare 官方发布的操作系统发行版,而是开发者社区里一类项目的统称:把 Cloudflare 的边缘网络当成一台虚拟电脑来设计。浏览器就是屏幕,Cloudflare Workers 就是 CPU,KV 与 D1 是内存和硬盘,R2 是文件盘。我自己的同名项目 cloudflare-os,就是按这个思路搭的一个网页桌面:能登录、能开应用、能管理文件、能把桌面布局同步到云端。这篇文章把整套实现拆开来讲,包括为什么这么选型、每个存储组件怎么接、前端桌面怎么做,以及上线后踩过的一堆坑。

不管你是想在 Cloudflare 上做在线工作台,还是纯粹想搞懂 Workers、KV、D1、R2 之间怎么配合,这篇内容都值得你花几分钟读完。我会尽可能把技术决策背后的理由讲清楚,而不只是甩几个命令给你。

1. cloudflare-os 的设计思路:边缘云就是一台分布式电脑

1.1 把云上组件一一对应到操作系统的部件

我第一次接触这个概念时,觉得最妙的地方在于:传统操作系统里的每个部件,在 Cloudflare 生态里都能找到对应物。用电脑来类比,整套架构的理解门槛会瞬间降低。

传统电脑组件cloudflare-os 里对应的服务承担的角色
CPU / 进程Cloudflare Workers运行后端逻辑、处理 API 请求
内存Cloudflare KV保存会话、配置等需要快速读取的数据
硬盘 / 数据库Cloudflare D1存用户、应用状态、桌面布局等结构化数据
文件盘Cloudflare R2存文档、图片、应用安装包等大文件
显示器浏览器里的 Web 桌面渲染窗口、壁纸、任务栏
操作系统内核前端 API 层与权限校验负责调度、鉴权、资源分配
应用商店Pages 路由与应用清单让不同的应用模块可以独立加载和更新

这个对应关系不是一个无聊的比喻,而是真的有指导意义。当我在设计 cloudflare-os 时,每遇到一个问题,都会先问一句“传统操作系统里是谁管这件事?”比如“桌面图标位置该存哪里?”传统电脑要么写在用户配置文件里,要么写进注册表,对应到云上就是 D1 里的用户表;而“这个应用的主题色是什么?”这种高频读取、偶尔修改的配置,放 KV 里更合适。

通过这种方式,边缘云不再是一堆抽象服务的拼凑,而是一台可以被拆解、被设计、被维护的完整系统。这也是 cloudflare-os 这个项目名最大的价值:它逼着你用系统工程师的眼光去做选型。

1.2 为什么不用传统云服务器,而是用边缘云

做这类项目时,很多人第一反应是租一台 VPS,装个 Nginx,再跑个 Node.js 服务。这当然可行,但放到 cloudflare-os 的场景下,代价不小。

首先是部署成本。VPS 需要自己维护运行环境、处理进程守护、配置防火墙、做日志切割,这些活儿虽然不复杂,但全是日常负担。而在 Cloudflare 生态里,代码提交后平台自动分发到全球边缘节点,你不需要关心某台机器挂没挂、磁盘满没满。

其次是访问延迟。桌面类产品对响应速度非常敏感,用户点一个图标,如果等两秒才有反馈,体验基本就毁了。Cloudflare 的优势在于请求会被路由到离用户最近的节点执行,配合 KV 和 D1 的全球分布能力,冷启动和跨地域读取都比传统单机房方案更平滑。

第三是弹性和额度。对于一个个人项目或者中小规模产品,Cloudflare 免费套餐的额度已经能覆盖相当可观的流量。Workers 每天免费 10 万次请求、D1 每天免费 500 万行读、R2 免费 10GB 存储,这些额度对早期项目来说很难跑满。相比之下,VPS 无论有没有流量,都要付固定月费。

当然,这套方案不是没有代价。Workers 的运行环境有脚本体积和依赖限制,不能跑 Docker,也不适合需要 GPU 或长时间保持连接的场景。D1 基于 SQLite,全局强一致性的模型也和传统 MySQL 不同。所以 cloudflare-os 更适合定义为一个轻量级 Web 工作台,而不是一个重型的云端计算平台。

1.3 技术选型:为什么是 Pages + Functions 而不是纯 Workers

我在搭建 cloudflare-os 时,做了一个后来被证明很关键的决策:不把整个项目塞进单个 Worker,而是用 Cloudflare Pages 托管前端,用 Pages Functions 提供 API。这么选有很实际的原因。

前端桌面是一个典型的静态应用,包含 HTML、CSS、JS 和图片,这些资源并不需要动态计算,只需要在边缘节点被快速响应。Pages 本身就是为静态站点设计的,自带全球 CDN 缓存,每次 git push 还会自动触发构建和部署,省掉了手动上传的环节。

后端 API 则通过 Pages Functions 实现。Pages 项目下每个 JavaScript 文件都对应一个路由,比如functions/api/login.js对应/api/login。这样代码组织非常清晰,API 和页面在同一个仓库里,部署时一起发布,不会出现前后端版本不一致的问题。

如果只用纯 Workers,你的身份认证、回调路由、静态资源服务全都要靠 Workers 路由手动处理。不是说做不到,而是当项目越来越复杂时,手动路由很快就变成一团乱麻。Pages + Functions 的组合让静态和动态自然分离,心智负担小很多。我强烈建议第一次做 cloudflare-os 这种项目的人,直接从这个组合入手。

2. 初始化 cloudflare-os:开发环境与项目骨架

2.1 工具链准备:Node 环境与 Wrangler

不管你是在 macOS、Windows 还是 Linux 上开发,第一步都差不多:装好 Node.js 和 npm,然后把 Wrangler 装到全局。Wrangler 是 Cloudflare 官方命令行工具,负责登录、开发、部署、数据库迁移等一系列操作。

npm install -g wrangler

安装完成后,先登录账号:

wrangler login

这个命令会打开浏览器,让你授权 Wrangler 访问你的 Cloudflare 账号。登录后可以用wrangler whoami确认当前账号信息。

这里有一点值得提醒:Wrangler 的本地配置会保存你的 API Token,默认存放在用户目录的配置文件里。如果你在公共电脑上开发过,离开前最好把配置清掉,或者用环境变量注入 token,避免账号信息外泄。

2.2 项目目录结构与核心配置

我用 Wrangler 创建了 cloudflare-os 的项目骨架,然后把目录整理成下面这样:

cloudflare-os/ ├── public/ │ ├── index.html │ ├── assets/ │ │ ├── css/desktop.css │ │ ├── js/wm.js │ │ ├── js/apps.js │ │ └── js/api.js │ └── icons/ ├── functions/ │ ├── api/ │ │ ├── auth.js │ │ ├── files.js │ │ └── state.js ├── migrations/ │ └── 0001_create_tables.sql ├── package.json └── wrangler.toml

public目录是纯静态资源,functions目录是后端 API,migrations目录放 D1 的 SQL 迁移脚本。这种结构的好处是:前端的人只看public,后端的人只碰functions,互不干扰。

核心配置在wrangler.toml里。以下是一个常见的配置示例:

name = "cloudflare-os" compatibility_date = "2025-01-01" [pages] build_output_dir = "public" [[kv_namespaces]] binding = "SESSIONS" id = "你的KV命名空间ID" [[d1_databases]] binding = "DB" database_name = "cloudflare-os-db" database_id = "你的D1数据库ID" migrations_dir = "migrations" [[r2_buckets]] binding = "FILES" bucket_name = "cloudflare-os-files"

如果你用的是 Pages 的控制台界面,也可以不写wrangler.toml,直接在项目设置的绑定管理里逐个添加 KV、D1 和 R2。但我个人更推荐用配置文件,因为它能让团队里的每个人都看到项目依赖了哪些资源,而且可以跟着仓库一起版本化。

2.3 本地开发:模拟云环境

Wrangler 最实用的功能之一就是本地模拟。在项目根目录运行:

wrangler pages dev public

这个命令会在本地起一个开发服务器,把public目录作为静态资源伺服,同时把functions下的 API 路由也一起跑起来。KV、D1、R2 的绑定在本地会默认使用模拟实现,也就是说你可以不消耗云资源,先把整个功能流程跑通。

开发过程里我最常用的组合是wrangler pages dev+wrangler d1 migrations apply。改完数据库结构后,跑一次迁移命令,本地和云端结构就保持一致。等我确认功能没问题,再提交代码触发线上部署,整个过程非常顺滑。

3. 接入“内存、硬盘与文件系统”:KV、D1、R2 的实战用法

3.1 KV 管会话与配置:快但别当真

KV 是 Cloudflare 的全球分布式键值存储,读写速度极快,很适合保存高频访问、低频修改的数据。我在 cloudflare-os 里用 KV 管理两类数据:一类是应用配置,另一类是短期的会话标记。

写入示例:

await env.SESSIONS.put(sessionId, JSON.stringify({ userId, role: "admin" }), { expirationTtl: 60 * 60 * 24 // 1天后过期 });

读取示例:

const session = JSON.parse((await env.SESSIONS.get(sessionId)) || "null"); if (!session) { return json({ error: "未登录或登录已过期" }, 401); }

键名设计上,我习惯统一加前缀,比如session:{id}、config:app:{appId},这样将来想枚举或清理数据时,可以通过前缀过滤,不用全量扫描。

但 KV 有个特性必须记住:它是最终一致性模型。当一个值刚刚写入,你立刻从其他边缘节点读取,有可能读到旧值或者读不到。这就是我在后面第 5 节会提到的“KV 延迟”问题。结论是:KV 适合存配置、存可容忍短时间不一致的会话标记,但别依赖它实现强一致逻辑,比如支付回调签名校验这种场景绝对不能放 KV。

3.2 D1 管用户与桌面状态:关系型数据才靠谱

cloudflare-os 里最核心的数据是用户、桌面布局和应用窗口位置。这类数据有明确的关系和结构,需要条件查询、更新、事务操作,KV 没法胜任,所以交给 D1。

D1 是 Cloudflare 基于 SQLite 的全球关系型数据库。我第一次建表时写了这样一段 SQL:

CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE desktop_layouts ( user_id INTEGER PRIMARY KEY, wallpaper TEXT DEFAULT 'default', window_state TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) );

这张desktop_layouts表里,window_state字段存的是 JSON 数组,记录用户桌面上所有窗口的位置、大小、层级和最小化状态。传统数据库设计里,可能每一行是一个窗口、每次移动都 UPDATE 一行,但在云桌面场景下,整个布局作为一个 JSON 整体读写反而更容易管理。SQLite 对 JSON 的支持也让这种用法很顺手。

对 D1 的查询要走参数绑定,避免拼字符串:

const stmt = env.DB.prepare( "SELECT wallpaper, window_state FROM desktop_layouts WHERE user_id = ?" ); const row = await stmt.bind(userId).first();

D1 的迁移流程也是项目里一定要养成的习惯。新加一张表,不要手动去库里执行 SQL,而是创建一个迁移文件,然后提交。这样整个团队的数据库演进历史都是可追溯的,线上和环境之间的结构才不会漂移。

3.3 R2 管文件与应用素材:云盘能力直接送

R2 是兼容 S3 的对象存储,设计用来存大文件。我在 cloudflare-os 里把它当作“文件盘”,用户的文档、头像、图片都丢进去。前端文字太多容易堆满数据库,但 R2 没有这种烦恼。

写入对象时,我记得一定要设置httpMetadata,否则之后下载文件时浏览器可能因为 Content-Type 不对而直接触发不了预览:

await env.FILES.put(key, file.stream, { httpMetadata: { contentType: file.type, contentDisposition: `inline; filename="${file.name}"` } });

读取对象时,直接读流并返回:

const object = await env.FILES.get(key); if (object === null) { return json({ error: "文件不存在" }, 404); } return new Response(object.body, { headers: { "Content-Type": object.httpMetadata?.contentType || "application/octet-stream", "Cache-Control": "public, max-age=3600" } });

R2 另一个让我喜欢的地方是零出口带宽费。传统对象存储最坑的就是下载流量费,R2 不按出站流量收费。对于 cloudflare-os 这种主打文件管理的应用,这个政策直接让成本变得非常可控。

3.4 存储选型对照表:别把所有东西都塞进一个组件

很多新手会把 KV 当万能存储,什么数据都往里塞。实际上,不同的数据有不同性格,用对了工具性能翻倍,用错了迟早踩坑。

存储对应角色适合放什么不适合放什么
KV缓存与配置主题配置、功能开关、短期会话标记强一致数据、需要复杂查询的数据
D1业务数据库用户表、桌面布局、应用列表、操作日志大文件、高频变更的缓存
R2文件系统文档、图片、备份、应用静态资源需要单条修改的状态数据

实际操作中,我的原则是“读多写少、可容忍旧值”的数据优先考虑 KV;“有结构、有关系、需要 ACID”的数据放 D1;“单个体积大、内容不变”的数据进 R2。三者各司其职,整套系统的成本和性能才会均衡。

4. 桌面外壳与“进程调度”:浏览器怎么变成一块屏幕

4.1 桌面外壳:窗口、任务栏与图标

前端这层是用户直接看到的部分,决定了一个项目“像不像一个操作系统”。我选择用原生 HTML/CSS/JS 实现,没有引入 React 或 Vue 重型框架,因为有窗口管理这种场景下,DOM 的即时操作更直接,也更容易控制性能。

桌面外壳主要包含三块:

  • 壁纸层:一张全屏背景,支持用户自定义 URL,换壁纸时直接改background-image。
  • 图标区:桌面图标是网格布局,双击触发openApp(appId)函数。
  • 任务栏:底部固定,左侧显示开始菜单按钮,中间放已打开应用的窗口按钮,右侧显示时钟。

窗口管理是最核心的部分。每个打开的窗口对应一个对象,保存id、title、x、y、width、height、zIndex、minimized这些状态。点击任务栏按钮时,根据状态决定是聚焦、还原还是最小化。

function openApp(appId) { const app = APPS.find((a) => a.id === appId); const win = { id: `win-${Date.now()}`, appId: app.id, title: app.name, x: 80 + Math.random() * 80, y: 60 + Math.random() * 60, width: app.defaultWidth, height: app.defaultHeight, zIndex: nextZIndex++, minimized: false, }; windows.push(win); renderWindows(); }

这里最让我头疼的是拖拽。原生实现拖拽看起来容易,但细节非常多:鼠标按下要记录偏移量、移动时要计算新位置、松开时要避免抖动。我最终用一个比较简单的方式解决:拖拽只改变x/y,通过requestAnimationFrame批量更新 DOM 样式,避免高频状态刷新导致卡顿。

4.2 应用容器:为什么每个应用都用 iframe

既然叫操作系统,就不能只有一个静态页面,还得有“应用”。cloudflare-os 的每个应用本质上都是一个独立页面,我用 iframe 把它们嵌进窗口容器里。

选 iframe 而不是直接在窗口里渲染 HTML,主要有三个理由。

第一是样式隔离。应用内部的 CSS 不会跑到桌面上,桌面自己的样式也不会污染应用。在传统文档流里,全局样式冲突是最难排查的问题之一,iframe 直接从物理层面解决。

第二是 JS 隔离。某个应用的脚本出错不会导致整个桌面崩溃,最多白屏一个窗口。这意味着应用之间可以各自迭代,不用互相迁就。

第三是版本灵活性。每个应用可以是独立的 URL,甚至可以是另一个部署在 Pages 上的子项目。这样 cloudflare-os 天然获得了一种“微前端”的能力,某个应用单独更新,不需要整站重新发布。

应用清单放在一个 JS 文件里:

const APPS = [ { id: "files", name: "文件管理器", icon: "/icons/files.svg", url: "/apps/files/", defaultWidth: 640, defaultHeight: 480, }, { id: "notes", name: "记事本", icon: "/icons/notes.svg", url: "/apps/notes/", defaultWidth: 520, defaultHeight: 420, }, ];

窗口加载时,核心逻辑是把url填进 iframe 的src属性。窗口关闭时,直接销毁对应的 iframe,不会留下任何内存泄漏。

4.3 系统调用:前端如何安全地调用后端 API

应用内部需要和后端交互,比如文件管理器要列出 R2 里的文件、记事本要读取 D1 里的笔记。这套交互我设计成“系统调用”模式:应用不直接存储 token,而是通过桌面壳层统一收发的 fetch 请求。

具体实现非常朴素。应用发请求时带上桌面壳层注入的会话标识,后端在 Functions 里做统一的鉴权校验。举个认证接口的例子:

// functions/api/auth.js export async function onRequestPost(context) { const { request, env } = context; const formData = await request.formData(); const username = formData.get("username"); const password = formData.get("password"); const stmt = env.DB.prepare("SELECT * FROM users WHERE username = ?"); const user = await stmt.bind(username).first(); if (!user) { return Response.json({ error: "用户不存在" }, { status: 401 }); } // 使用 PBKDF2 或更成熟的哈希方案校验密码,切记不要明文比对 const sessionId = crypto.randomUUID(); await env.SESSIONS.put(sessionId, JSON.stringify({ userId: user.id }), { expirationTtl: 60 * 60 * 24 * 7, }); return Response.json({ token: sessionId }); }

登录后,前端把 token 存在内存变量里,或者放进 HTTP Only 的 Cookie 中。使用 Cookie 的好处是浏览器会自动带上,不用在处理跨域时反复传 header;缺点是 CSRF 风险,需要在写接口时校验 Origin。我更推荐把 token 放在内存里,每次请求用Authorization: Bearer <token>携带,桌面应用内跳转不会离开壳层,token 丢失风险也最低。

4.4 把布局持久化回 D1:别让用户白干一场

用户在桌面上调整了窗口位置,关掉浏览器再打开,看到的还是原来的布局,这才算是真正把浏览器变成了“系统”。我实现的策略是:窗口拖拽结束、缩放结束、最小化状态变化时,触发一次保存。

async function saveLayout() { const state = windows.map((w) => ({ appId: w.appId, x: w.x, y: w.y, width: w.width, height: w.height, minimized: w.minimized, })); await api.post("/api/state", { windowState: JSON.stringify(state) }); }

这里有一个很容易忽略的问题:拖拽过程中鼠标每动一次就触发一次保存,会把 D1 的写配额瞬间打爆。所以保存操作一定要做防抖。我在项目里用的就是最简单的setTimeout方案,拖拽结束两百毫秒后再保存一次。D1 的免费写入额度虽然有几万行,但也经不住高频无意义 UPDATE。

5. 上线后踩过的坑:KV 延迟、D1 慢查询与 R2 的 CORS 实录

5.1 KV 的最终一致性差点把登录态搞崩

第一版登录成功后,我把整个用户会话塞进了 KV,并设置 7 天过期。本地测试一切正常,一部署到边缘节点,开始出现零星用户反馈“刚登录成功,刷新又变成未登录”。

排查下来,问题就出在 KV 的最终一致性上。会话写入时,请求命中的是 A 节点;用户刷新页面,请求可能被路由到 B 节点,而 B 节点还没来得及同步 KV 里的新会话,就返回了空结果,然后前端就认为用户没登录。

解决方案分两步:一是把最关键的会话数据从 KV 挪到 D1 里,因为 D1 的读写一致性更强,特别适合这种必须立刻生效的场景;二是 KV 只保留应用配置这类对“旧值”不敏感的数据,就算读到一小时前的主题色,用户也不会有任何感知。

5.2 D1 的首次查询有冷启动感

D1 虽然快,但边缘节点首次查询时,经常要等待数据库实例唤醒。那种感受就是网页打开后,桌面壁纸秒出,任务栏秒出,唯独“加载应用列表”这个接口等了将近一秒。

我做了两个优化。第一是给关键查询加预热,部署完成后用脚本自动请求一次核心接口,让数据库实例在空闲前跑一遍;第二是利用 Cloudflare 的 Cache API 把高频查询结果缓存几分钟,牺牲一点数据新鲜度换取打开速度。对于 desktop_layouts 这种低频修改的数据,缓存带来的体验提升非常明显。

5.3 R2 下载文件时被浏览器拦截

文件管理功能上线后,收到最多的反馈是“点击下载没反应”。我用开发者工具一看,控制台里报的是 CORS 错误,原因是前端直接请求 R2 的公开桶,而桶的跨域策略没有配置。

现在我把几乎所有读取操作都收敛到后端 Functions,由后端去读 R2 再返回给前端,这样需求只要一个入口:/api/files/{key}。再加上对象上传时设置好httpMetadata,下载时的文件名和 Content-Type 都正确,浏览器就会乖乖走下载流程,不再出现跨域拦截问题。

5.4 免费套餐的额度和脚本体积限制

cloudflare-os 开源给网友玩之后,最大的压力不是代码问题,而是免费套餐的隐性边界。Workers 的脚本打包体积有上限,如果你用functions目录塞了太多依赖,部署时会直接失败。这时候要么把不常用的路由拆到单独的 Worker 上,要么用 CDN 引用替代 npm 包,减小打包体积。

另外还要盯着免费额度的分类。KV 的读、D1 的行读取、R2 的操作次数分别计数,一个功能如果设计成“每次请求都读三次 KV”,一天十万请求很快就耗尽。后来我做了大量合并读取,把三次调用合并成一次,额度压力立刻小了很多。

5.5 常见问题排查速查表

现象可能原因解决办法
登录后刷新又变未登录KV 最终一致性导致会话读取为空关键会话状态移到 D1,KV 只存低频配置
首次打开应用列表要等很久D1 冷启动部署后预热接口,高频查询接入 Cache API
文件下载被浏览器拦截CORS 配置缺失统一走后端 Functions 读取 R2,避免前端直连
拖拽窗口时保存请求刷屏没有防抖拖拽结束后 200ms 再保存一次
部署失败或脚本过大依赖体积超限拆分路由、移除重依赖、改用 CDN 引入
本地开发时 API 正常,线上 404Functions 路由路径写错确认functions/api/xxx.js对应/api/xxx路径

5.6 写在最后:给想复刻 cloudflare-os 的人几点建议

这套项目做下来,我最大的体会是:别把 cloudflare-os 想成“做一个高仿 Windows”,那是陷阱。真正有价值的地方在于,你要理解边缘云各个组件之间如何配合,并亲自把每个组件都摸一遍。如果你也想搭一个同名项目,我的建议是:第一版只做一个静态桌面,能显示图标、能开一个 iframe 窗口就够了,不要一上来就把 KV、D1、R2 全接上。等窗口管理稳定了,再逐步增加登录、布局保存和文件上传。先把壳子做好,内容和服务自然有地方放。

另外一个值得测试的方向,是把某个“应用”独立成一个子域名服务,再用 iframe 接入桌面。这样 cloudflare-os 的玩法就跟插件系统一样,应用可以单独开发、单独部署、单独升级。这个扩展思路我还没完全做完,但方向是对的。你要是动手做了,大概率会被这种“浏览器就是系统”的构建方式吸引住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进