第一次看到 cloudflare-os 这个项目代号的时候,我第一反应是:谁会拿 Cloudflare 做“操作系统”?后来自己想通了——这不是要在浏览器里跑个 Linux,而是把 Cloudflare 全家桶(Pages、Workers、D1、R2、KV、Cron Triggers)当成一块拼图,组装成一个“跑在云端边缘的个人工作台”。我在自己的小项目里连续折腾过几个星期,从用户认证、文件管理到定时备份全流程走了一遍,中间踩过的坑、验证过的方案、最后沉淀下来的架构,今天一次性拆开讲清楚。
这个玩法适合谁?适合不想买服务器、不想维护 Linux 环境,但手里又有些 APP 或小产品想快速落地的人。Cloudflare 免费档的额度对个人项目和早期产品来说相当能打,而且它的运行时是分布在全世界边缘的,用户访问时比传统单机房服务器绕路更少。下面我把这套方案的思路、核心模块、实操过程和排查经验一条条列出来。
1. cloudflare-os 到底是什么:一次把整套边缘服务组装成个人云的尝试
1.1 核心需求解析
cloudflare-os 这个名字本身没有官方定义,它更像一个社区共识:用 Cloudflare 提供的各类服务,组合成一个“云端个人操作系统”。我的需求很明确,就是希望有一个能随时通过浏览器访问的个人空间,它得能登录、能传文件、能存数据、能跑定时任务,最好还能有一个比较好看的桌面式 UI。传统做法是想都不用想的——买一台云服务器,部署一个 Web 应用,再配数据库、对象存储、反向代理。但这套方案的痛点也很明显:服务器要花钱、要有人盯补丁、要处理备份,一旦流量上来还得考虑扩容,而这些事情对个人项目来说全是沉重的负担。
用 Cloudflare 全家桶来组装,最大的不同是把“运维”这件事降到了接近零。Pages 负责托管静态前端,Workers 负责跑后端逻辑,D1 提供关系型数据库,R2 提供对象存储,KV 做全球低延迟的键值缓存,Cron Triggers 做定时任务。也就是说,一个完整的后端系统所必需的组件,几乎全部能在 Cloudflare 的生态里找到对应物,而且它们天然是分布式部署的,不需要我操心节点挂了、磁盘满了这些基础设施问题。
我知道有朋友会质疑:这些东西能扛住真实业务吗?我的体会是,对于个人工具、小团队内部系统、早期 SaaS 产品,它们完全够用,而且免费额度相当慷慨。我自己在 cloudflare-os 里跑了用户管理、文件上传、每日自动备份这些模块,使用体验和一台低配云服务器部署出来的方案没有本质差别,甚至在全球访问速度上反而更稳。
1.2 为什么选这套方案而不是传统服务器
首先说成本。一台入门级云服务器一年下来几百上千块,还要单独买存储和数据库服务。Cloudflare 这几件套的免费档对个人项目来说,基本能做到“零成本起步”。其次是部署体验:传统方案要至少经过代码上传、依赖安装、进程守护、域名配置、HTTPS 证书发放这几道工序;在 Workers 上只需要npm run deploy,几秒钟后代码就跑到全世界边缘节点上了,HTTPS 是默认给的。
第三个理由是我最看重的——低延迟。Workers 被部署在全球两百多个城市的边缘节点上,用户请求会直接打到离他最近的节点。我之前测过,同样一个查询接口,放在传统单机房服务器上,跨地区的访问延迟可能会到 200 毫秒以上,而放到 Workers 上,大部分地区基本能在 50 毫秒以内返回。对个人项目的体验提升是很直观的。
不过要注意,这套方案并非没有代价。Workers 的执行模型是“短生命周期”的,它不是一个能跑常驻进程的服务器,所以像 WebSocket 长连接、后台任务队列这类需求,需要换一种思路来实现。另外 D1 目前是基于 SQLite 的,单库并发写能力和关系型数据库老大哥们比有一定差距,这些限制我在后面的章节里会详细展开。整体判断是:如果你的项目是 API 服务、个人工具、数据管理后台这一类形态,这套组合拳非常合适;如果你要跑一个每分钟都要写入大量数据的高并发业务,那还是老老实实上传统数据库吧。
2. 核心功能模块与关键实现细节
2.1 用户认证:Access 和自建 JWT 的取舍
一个“操作系统”最前面的模块是登录。Cloudflare 自己有一套 Access 产品,你可以用它给应用套一层企业级认证,优点是不用写任何登录代码,页面跳转后由 Cloudflare 帮你搞定身份验证。但对 cloudflare-os 这种想要更像“自己的系统”的项目来说,我更推荐自建认证逻辑,原因有几个:多用户管理更方便、Session 可以自定义、数据表可以覆盖到用户资料和个性化设置。
我实际用的是 Workers + Hono 这个组合来写 API。Hono 是一个专门针对边缘运行时的 Web 框架,API 设计很像 Express 但体积小得多。登录接口的大致逻辑是:接收用户名和密码,从 D1 读出用户记录,用密码哈希校验,通过后签发一个 JWT,并把 JWT 写入 KV 里带上过期时间。这样每次请求时只需要在中间件里校验 JWT 即可,不需要反复查数据库。
import { Hono } from 'hono' import { sign, verify } from 'hono/jwt' import { cors } from 'hono/cors' type Bindings = { DB: D1Database FILES: R2Bucket SESSIONS: KVNamespace JWT_SECRET: string } const app = new Hono<{ Bindings: Bindings }>() app.use('*', cors()) app.post('/api/auth/login', async (c) => { const { username, password } = await c.req.json() const user = await c.env.DB.prepare('SELECT * FROM users WHERE username = ?') .bind(username).first() if (!user) return c.json({ error: '用户不存在' }, 401) // 密码哈希用 bcrypt 或 argon2,Workers 里可用 WebCrypto PBKDF2 兜底 const valid = await verifyPassword(password, user.password_hash) if (!valid) return c.json({ error: '密码错误' }, 401) const token = await sign( { sub: user.id, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 }, c.env.JWT_SECRET ) await c.env.SESSIONS.put(`session:${user.id}`, token, { expirationTtl: 7 * 24 * 3600 }) return c.json({ token }) })中间件校验部分的思路也很直白:从Authorization头里取出 Bearer Token,调用verify校验签名,再把用户 ID 塞进上下文,方便后续接口使用。
这里有一个经验要分享:不要把敏感数据一股脑都放进 JWT 的 payload 里,JWT 只是用来证明“你登录过”,具体权限判断还是该去查数据库。我在早期图省事把用户角色、分组都写进 token,后来发现只要改一次角色,token 没失效前一直带着旧角色,很容易出权限边界问题。权限信息还是尽量走服务端查库,JWT 只留一个用户 ID 就够了。
2.2 数据层设计:D1 库表结构与 R2 文件存储的分工
数据库我用了 Cloudflare 的 D1。它是基于 SQLite 的关系型数据库,支持传统 SQL 语法,还提供了和 Workers 深度集成的 client API。个人系统的表不用太复杂,我建了用户表、文件表、任务表和通知表,核心是前两张:
CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, filename TEXT NOT NULL, object_key TEXT NOT NULL, size INTEGER NOT NULL, content_type TEXT NOT NULL, created_at INTEGER NOT NULL );为什么文件元数据和文件本体分开存储?因为 D1 适合存结构化的小数据,文件本体是二进制大对象,放进数据库里既不高效又会让备份变重。Cloudflare 的 R2 是对象存储,和 AWS S3 接口兼容,更适合存这类东西。我这边上传接口收到文件后,先把文件流写到 R2,拿到返回的object_key,再把文件名、大小、归属用户这些元数据写入 D1 的 files 表。查询列表时只查 D1,要下载文件时再用 object_key 去 R2 取,逻辑非常清晰。
R2 的读写有两点要注意:一是免费层额度虽然给得多,但单次上传请求体有大小上限,传大文件建议前端做分片或采用 S3 兼容的预签名直传方式;二是 R2 的 bucket 需要在wrangler.jsonc里显式绑定,绑定后 Worker 代码里才能通过环境对象访问到。
2.3 前端桌面式 UI 的布局思路
cloudflare-os 的前端我用了 React + Tailwind,构建完成后静态文件丢到 Cloudflare Pages。Pages 会沿着 Cloudflare 的 CDN 全球分发,配上 Worker 的 API 后端,整个项目就是一个典型的全栈边缘应用。
前端的结构参照桌面操作系统的观感做一个启动器,里面是图标式的应用入口。点开图标后对应弹出一个窗口,窗口可以拖动、最大化和关闭,这样用户会觉得自己在用一个 OS 而不是一个普通网站。窗口内部是某类业务模块:相册、笔记、文件管理、系统设置。每个模块本质上都是独立的业务页面,通过前端路由和状态管理串联起来。
这个 UI 部分实现起来不难,真正要留神的是和 API 的对接方式。所有请求都要带上 JWT,我用一个统一的请求封装,在拦截器里自动读取 token 并塞到 Header 里,同时处理 401 跳回登录页。这套机制写一次之后,后面加新模块就只需要专注业务组件。
2.4 定时任务与系统自动化
“操作系统”还应该会自己在后台干活。Workers 的 Cron Triggers 可以配置定时触发,比如我让它在每天凌晨 2 点跑一次:扫描 D1 里的文件表,把当天的变更记录整理成一份摘要,再用 Workers 的邮件发送能力推到我自己的邮箱,最后把 D1 数据库做一次导出并放到 R2 的备份目录里。
定时任务的配置写在wrangler.jsonc的triggers字段里,一个0 2 * * *就搞定了。它和执行普通 Worker 请求不同,没有 HTTP 触发,代码入口通常是fetch之外的scheduled方法。调试的时候要注意,本地跑定时任务需要用wrangler dev --test-scheduled这类参数模拟触发,不然你只能在部署后等着它自己跑。
这套自动化帮我省了很多事,尤其备份这件事,以前总要惦记着手动导出数据库,现在完全躺在云上自动完成。Cron Triggers 对免费用户也有配额限制,个人项目的使用频率完全不必担心。
3. 实操全过程:从空目录到跑起来的完整步骤
3.1 初始化项目和本地开发环境
我推荐直接用官方脚手架npm create cloudflare@latest起步。它的交互式命令会问你项目名、要哪种模板(可选 Hono、Node 兼容性、是否启用 Git 等),选择后就会生成一个最小的 Worker 项目。本地调试用npm run dev,wrangler 会起一个本地运行时,同时提供热更新的开发服务器。有一点要注意:本地开发时 D1 和 R2 默认是用模拟器实现的,并不会真连云端,数据是临时写在本地目录里的,这样可以放心改表结构和测接口。
项目初始化之后我习惯先把目录理干净。最核心的就是src/index.ts(Worker 入口)、wrangler.jsonc(配置文件)、package.json。如果用了 React 前端,我会单独建一个web/子目录,前端和后端代码分开管理,前端最后通过 Pages 发布。
3.2 编写核心配置:D1、R2、KV、Cron 一次配齐
wrangler.jsonc是整个 cloudflare-os 的“总装车间”,各种云资源都要在这里声明和绑定。我贴一份实际用过的配置(字段按个人需要调整):
{ "name": "cloudflare-os", "main": "src/index.ts", "compatibility_date": "2025-01-01", "workers_dev": false, "scheduled": { "crons": ["0 2 * * *"] }, "d1_databases": [ { "binding": "DB", "database_name": "cloudflare-os-db", "database_id": "你的-D1-database-id" } ], "kv_namespaces": [ { "binding": "SESSIONS", "id": "你的-KV-namespace-id" } ], "r2_buckets": [ { "binding": "FILES", "bucket_name": "cloudflare-os-files" } ], "vars": { "JWT_SECRET": "本地开发用随机串,云端用环境变量覆盖" } }配置里最容易踩坑的是 D1 的database_id。在本地新建项目时,它还没有对应云端的数据库实例,你需要先执行wrangler d1 create cloudflare-os-db,把输出里的 database_id 复制到配置里,然后再执行wrangler d1 execute cloudflare-os-db --file=./schema.sql把表结构推上去。KV namespace 和 R2 bucket 同理,都需要先用命令行创建好,再把 ID 填回来。我第一次配置时跳过了创建步骤直接写 ID,结果部署后接口全部报绑定错误,排查了半天才发现是 ID 是空壳。
3.3 用 Hono 实现核心 API 的实际代码
Worker 入口文件的核心路由,除了上一节讲过的登录之外,还有文件上传和文件列表这两个代表性接口。文件上传我在生产环境用的是 Worker 中转方案,前端通过 POST 请求把文件二进制直接送给 Worker,Worker 再写到 R2。简单项目够用,文件大了就不行了,那时候要改成 S3 预签名直传方案。
下面是文件上传接口的简版实现:
app.post('/api/files/upload', async (c) => { const userId = c.get('userId') const formData = await c.req.formData() const file = formData.get('file') as File if (!file) return c.json({ error: '缺少文件' }, 400) const objectKey = `${userId}/${Date.now()}-${file.name}` await c.env.FILES.put(objectKey, await file.arrayBuffer(), { httpMetadata: { contentType: file.type } }) const result = await c.env.DB.prepare( 'INSERT INTO files (id, user_id, filename, object_key, size, content_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ) .bind(crypto.randomUUID(), userId, file.name, objectKey, file.size, file.type, Date.now()) .run() return c.json({ ok: true, id: result.meta.last_row_id }) })文件列表接口更简单,本质上就是一条带分页的 D1 查询:
app.get('/api/files', async (c) => { const userId = c.get('userId') const page = parseInt(c.req.query('page') || '1') const pageSize = 20 const offset = (page - 1) * pageSize const { results } = await c.env.DB.prepare( 'SELECT id, filename, size, content_type, created_at FROM files WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?' ).bind(userId, pageSize, offset).all() return c.json(results) })这两个接口可以当作新模块的模板,后续加笔记、任务、相册都按同样的模式:先建表,再写查询和写入接口,最后前端调接口渲染。
3.4 部署发布:一次 push 全球生效
部署分为两块。前端部分在项目根目录执行构建命令,生成dist目录,然后在 Cloudflare 控制台里创建一个 Pages 项目并连接你的 Git 仓库,选择好构建命令和输出目录,之后每次 push 代码会自动触发重新部署。后端部分就是前面说过的npm run deploy,wrangler 会把你当前目录下的 Worker 代码打包上传到云端。
有个细节:当 Worker 和 Pages 都在同一个 Cloudflare 账号下时,前端请求 API 的地址不需要手动填公网域名,直接填https://你自己的-worker名.subdomain.workers.dev就行。如果绑定了自定义域名,则在 Worker 的配置里添加路由规则,把api.你的域名.com/*的请求指向这个 Worker,Pages 那边就绑定app.你的域名.com。这样外部访问入口就统一了。域名解析处也顺带说明一下,Cloudflare 的 DNS 面板里配置好 A/AAAA 或 CNAME 记录,其他什么都不用管,HTTPS 证书它会自动申请和续期。
4. 跑起来之后遇到的坑:排查实录与避坑清单
4.1 首次请求延迟偏高,该不该担心
有朋友第一次部署后,用浏览器打开页面发现白屏了好几秒,就开始怀疑这套方案性能不行。我实际遇到的情况是:对于冷启动的 Worker,爱丁堡、新加坡这些远端节点首次请求时确实可能需要一秒左右的加载时间,因为运行时要创建隔离实例,但等实例热起来之后,后续请求基本都是几十毫秒级别。
如果你的服务对响应延迟极其敏感,比如是线上支付回调这类业务,那冷启动确实是个隐患。但 cloudflare-os 这种个人系统里,完全不是问题。我习惯做一件事:部署完成后,用定时任务每 5 分钟去请求一次核心接口,相当于变相做预热。这个办法简单,也没有额外成本,实测对改善首次访问体验有明显帮助。
4.2 CPU 时间和请求次数被“薅”光了
Workers 免费档最值得注意的是每日请求数和 CPU 耗时配额。我早期给每个列表接口都返回全量数据,结果用户在文件多的时候一次请求就要查几百条记录,CPU 时间里很大一部分花在序列化和传输上。后来所有接口都强制分页,同时把列表页做缓存,问题基本就消失了。
这里想重点说一个原则:能用 KV 或缓存解决的请求,就不要反复查 D1。比如用户头像、系统配置这类不常变的数据,完全可以一分钟缓存一次。KV 的读取延迟远低于数据库查询,而且 KV 免费配额是十万级阅读量,绝大部分个人系统用不到这个量级。
4.3 D1 的读后写不一致,为什么刚插入的数据查不到
D1 的架构是主节点写入、全球副本异步复制。也就是说,你插入一条记录后立刻去查,如果请求被路由到了还没同步到新数据的副本节点,就可能查到旧数据。我第一次做“创建文件后立刻刷新列表”的功能时就在这上面栽过:上传成功返回 200,页面上列表却看不到新文件,过了几秒刷新又出现了。
解决思路有几种。最稳妥的是保证“写读同源”:创建成功后,前端不要立刻发列表请求,而是用接口返回的新记录自己把列表里对应位置插进去,或者干脆延迟几百毫秒再刷新。接口层面也能做文章:在写入后如果确实要立刻读,就带上?__d1_primary这类标记强制走主库读取,虽然不是所有场景都推荐,但 D1 确实支持通过配置或查询参数来指定主库读。这个机制不展开说,大家知道有这回事就行,日常业务里“写完延迟刷新”是最省心的做法。
4.4 R2 直传方案的最终选择
前文提到上传文件我一开始用 Worker 中转,文件很小没问题,但等到我开始管理大量照片,单张可能 5MB、10MB 的时候,Worker 中转方式就出现了两个问题:一是请求体大小受限制,大文件传不上去;二是所有流量都经过 Worker,占用了请求配额。
后来我换了方案:改用 S3 兼容的预签名地址。思路是在 Worker 里临时生成一个带签名的上传 URL,前端直接用这个 URL 把文件 PUT 到 R2,整个过程不经过 Worker 的数据转发,只消耗极少请求次数。写着麻烦些,但一劳永逸。如果 Cloudflare 的 Workers 绑定配置允许,也可以直接用 R2 的createMultipartUpload等 API 配合 R2 分片上传。 我这里推荐大家优先考虑预签名直传,这不仅能绕开大小限制,也让前端到存储的链路更短。
4.5 CORS 跨域问题和安全头配置
前端的 Pages 域名和后端 Worker 域名毕竟是两个不同源,在浏览器上会产生跨域请求。第一版我在 Hono 里只是简单加了cors()中间件,后来发现有时预检请求会丢失某些响应头,特定浏览器下登录一直报错。最终的处理方式是在 Worker 入口手动控制 CORS 响应头,对OPTIONS请求主动返回允许的 Header,再放行。
安全方面也要说两句。JWT_SECRET 绝不能硬编码在仓库里,我在wrangler.jsonc里只留本地变量,云端通过 Cloudflare 控制台的 Variables and Secrets 面板设置成保密变量。文件上传接口在写入 R2 之前,要校验文件后缀和 Content-Type,防止别人拿你的存储空间当图床或恶意文件仓库。这些细节不处理好,后面被刷流量才知道痛。
编排到这里,cloudflare-os 的核心玩法基本就完整了。我个人在实际操作中的体会是,这套方案最有价值的地方在于“心无旁骛写业务”:不用管运维、不用管扩容、不用管证书,只要把接口设计好、把数据模型想清楚,剩下的基础设施问题 Cloudflare 全都帮你扛了。最后再分享一个小技巧:新项目起步时别急着加太多模块,先把“登录 + 上传文件 + 列表查询 + 定时任务”这四个最小闭环跑通,熟悉了这套数据流之后,再往里面填应用就像拼乐高一样快了。