RandallFlare
by Randall · 12 Jun 2026
RandallFlare —— 从零搓一个自托管的 Cloudflare#
Workers / Pages / R2 / KV / D1 / Queues / Durable Objects / Analytics / Service Bindings / Cron / Cache API —— 全栈,一台机器,一行 systemd,零 CF 账号依赖。
一、它是什么#
RandallFlare 是一个把 Cloudflare 主要数据面 + 编程接口克隆出来的开源自托管平台。控制面跑一份 Next.js,边缘节点跑一个 Go 写的 agent,所有 V8 隔离都用 Cloudflare 自家开源的 workerd 跑 —— 也就是说你的 worker / pages 代码真的就是 CF Workers 写法,改个 host 部署进去能跑。
它不是"另一个 Vercel"也不是"另一个 Fly.io"。它的设计目标是:给个人 / 小团队一个"CF 服务挂了 / 被 ban 了我能直接搬"的退路,以及把 CF 那种"几行 JS 加 binding 就能跑"的开发体验原地复制到自家服务器上。
如果你熟 CF Workers,RandallFlare 的开发者体验对你来说会零迁移成本;如果你不熟,看后面 demo 就明白了。
它解决了什么具体痛点(点开看)
- CF 免费额度被打爆 → 自家机器无量
- CF Workers 在国内访问抖 / 偶尔被墙 → 自部署在你想放的机房
- 想给 Workers 加自定义中间件(WAF / 鉴权代理 / 调试 hook) → CF 关着,自部署敞开
- 想在多台节点之间共享对象存储 + DO 状态,但不愿意被锁进单家厂商
- 想给客户演示 "Workers 风格" 的 demo 但不想曝光你的 CF 账户
- 想顺便学一遍"边缘计算平台到底是怎么搭起来的"
二、整体架构#
控制面跟边缘节点是完全解耦的两层:
- plane 只关心数据库 + 编排,不接公网流量
- edge node 只关心"接住请求 + 跑 workerd",不需要 DB
中间是一条 5 秒一次的 poll loop,plane 把"该跑啥"推过来,agent 自己拉源码 + 启 workerd。崩了就重启,不丢状态。
三、技术选型#
| 层 | 技术 | 为啥 |
|---|---|---|
| 控制面 web | Next.js 16 App Router | server actions 让 admin UI 写得跟普通 React 一样;无独立 BFF |
| ORM | Prisma + PostgreSQL | migration 系统是这次项目最大依赖之一,迭代极快 |
| 任务队列 | Redis (BLPOP/RPUSH) | 比 SQS 轻;比内存队列耐崩溃 |
| 边缘 agent | Go | 静态二进制 + 单文件分发 + syscall 直接 exec workerd;依赖最少 |
| V8 runtime | workerd | CF 官方开源,等于跑同一个 isolate;兼容 nodejs_compat flag |
| capnp 配置 | workerd 自带 .capnp DSL | 每个 worker 一个动态生成的配置文件 |
| TLS | ACME DNS-01 (Let's Encrypt) | wildcard 一证全 cover,自定义域名走 alias CNAME |
| 静态资源 | content-addressed blob (sha256) | 跨 deployment 跨项目共享同一份 vendor.js |
| 对象存储 | 本地磁盘 + rendezvous hashing | 多节点复制,replica = 2 默认 |
| 日志面 | DB ring buffer + 1Hz 前端轮询 | 不上 SSE/WebSocket,SSR friendly |
| 自更新 | GH Actions → public/edge-agent/latest/ + heartbeat 比对 | 一推就推,无需 docker pull |
关键选择是plane = Next.js + 一个 PG。这意味着 plane 本身可以部署到任何能跑 Next 的地方(包括 Vercel,虽然我没这么做),也意味着 admin UI 就是 plane 的 server component,没有独立的 API server。
四、组件逐个拆#
4.1 Plane(控制面)#
Plane 干的事:
- 用户在
/me/edge/workers写代码 / 配 binding / 改 hostname - server action 直接读写 PG,顺手 bump version 字段
- 下一次 agent 5 秒 poll → plane 比对
known[workerId] == version,不等就把整包(files + entryFile + envJson + bindings + crons + compat flags + CORS allow list)塞回去 - agent 应用完 → POST ack,plane 把
EdgeDeployment.status翻 OK
整套没有 webhook / push 通道,纯 pull。简单粗暴,跨节点容灾近乎免费。
核心表(节选)
User
├── EdgeWorker (slug, files, envJson, kvBindings, …, corsManualOrigins)
│ ├── EdgeHostname (hostname, isAutoSub, certIssuedAt, …)
│ ├── EdgeDeployment (per (worker, node) pair, status PENDING/OK/FAIL)
│ └── EdgeWorkerBuild (git build artefacts + log)
├── EdgePagesProject (similar shape + static manifest)
│ ├── EdgePagesDeployment (versioned manifest = { files: { path → {hash, size, mime} } })
│ ├── EdgePagesHostname
│ └── EdgePagesPreviewAlias (per-branch preview URL)
├── EdgeKvNamespace → EdgeKvEntry (expiresAt, metadata)
├── EdgeD1Database (SQLite file per DB, lives on plane)
├── EdgeR2Bucket → EdgeR2Object → EdgeR2Blob (content-addressed)
│ └── EdgeR2BucketHostname (public direct URLs)
├── EdgeQueue → EdgeQueueMessage
└── EdgeAnalyticsDataset → EdgeAnalyticsEvent
EdgeNode (one per VPS, agentTokenHash, drain flag)
EdgeBuildSshKey (singleton, ed25519 deploy key for SSH git clones)
EdgeRuntimeLog (stdout/stderr ring buffer, 5 min retention)
4.2 Edge Agent(Go)#
每个节点跑一个 systemd 单元,二进制 < 14 MB,启动后做三件事:
/edge/agent/register拿 nodeID + agent token- 起 4 个 poll 循环:workers / pages / R2 / heartbeat,每 5 秒一轮
- 监听
:80+:443,把入站请求 dispatch 到本地 workerd 子进程
workerd 子进程怎么管:
- agent 给每个 worker 写一个
worker.capnp(动态生成,内含 service + Fetcher + bindings + compat date/flags) os/exec起 workerd,捕获 stderr 5 KB ring buffer 用于死亡诊断- 端口池(18800–28800)pick / release
- stdout/stderr 同时 tee 到 plane 的
/edge/agent/runtime-logs→ 实时 live tail
4.3 路由 / 派发#
入流量经过的唯一一层就是 agent 的 router。其判定优先级:
- R2 hostname(bucket 公开 URL,
<bucket>-r2-<user>.<edge-zone>或自定义) - Pages hostname(包括
<branch>--<project>-<user>.<edge-zone>preview) - Worker hostname(custom 域 / auto-sub)
- 都没命中 → 404
no worker or pages project bound to host
每条都按"hostname → projectID/workerID/bucketID"哈希表查,O(1)。
同 hostname 路由顺序是 R2 > Pages > Workers。如果你把同一个域名绑了 R2 又绑了 worker,会被 R2 抢走 —— admin UI 已经禁止重复绑定。
4.4 Workers#
CF Workers 该有的全有:
- 多文件 module 模式(
export default { fetch, scheduled, queue, email }) compatibility_date+compatibility_flags(nodejs_compat等)- 所有 binding(KV / D1 / R2 / Queue / Service / Analytics / DO)
- 自动注入
request.cf.country等(从cf-ipcountryheader / 入站 IP 地理推断) process.env自动同步 string bindings(Nuxt / Nitro 友好)- 自带
/_edge/*.jsshim,把 raw service binding 包成 D1Database / R2Bucket / KVNamespace 等熟悉的对象
写法:
// main.js
export default {
async fetch (request, env, ctx) {
const url = new URL(request.url)
if (url.pathname === '/') {
const v = await env.MY_KV.get('hits')
ctx.waitUntil(env.MY_KV.put('hits', String(Number(v ?? 0) + 1)))
return new Response(`Hits: ${v}`)
}
return new Response('Not Found', { status: 404 })
}
}
跟 CF 一模一样。env.MY_KV 就是 KV namespace,env.DB.prepare(...) 就是 D1。
4.5 Pages#
支持两种入口:
- Static-only:仅静态文件 +
_routes.json/_headers/_redirects - Functions advanced mode:
_worker.js/index.js+ 全部 chunk(Nuxt / SvelteKit / Astro 都打这种)
Functions simple mode (
_worker.js单文件)我也支持,但是上述框架基本不再产出这种格式。
Manifest 形如 { files: { "/index.html": {hash,size,mime}, ... } },文件 body 走 content-addressed blob(assets/<aa>/<sha256>),跨 deployment 跨项目共享。两个 Nuxt 站用同一个 vendor.js → 磁盘上只一份。
Preview 部署走 <branchSlug>--<project>-<user>.<edge-zone>,跟 CF 一模一样。
4.6 R2#
存储模型:
EdgeR2Blob (sha256 → bytes, refCount)
↑ many-to-one
EdgeR2Object (bucketId, key) → sha256
↑ many-to-one
EdgeR2Bucket (slug, publicAccess, corsManualOrigins, expireObjectsAfterDays)
- 对象上传 → 算 sha256 → blob 表 refCount++,对象表插一条
- 同样的 sha256 已存在 → 直接复用,自动去重
- 删除对象 → blob refCount-- → 0 时被 GC 回收
复制策略:rendezvous hashing,默认 replica = 2(blob 写到两个最近节点)。读时优先打本地 cache,miss 时 fallback 到 plane fetch + 写盘。
R2 binding 的 .put / .get / .head / .delete / .list / multipart upload 全实现,包括 Range header(视频 seek / iOS Live Photo / curl -C 续传都需要)。
公开桶有三种访问路径:
| 形态 | URL | 谁服务 |
|---|---|---|
| Auto-sub | <bucket>-r2-<user>.edge.bigrandall.io/<key> | edge agent 直接发 |
| Custom domain | cdn.your-domain.com/<key> | 同上,DNS CNAME 进来 |
| Central fallback | bigrandall.io/r2/<user>/<bucket>/<key> | plane 直接 stream(冷启用) |
4.7 KV / D1 / Queues / DO / Analytics / Cache#
KV —— 简单粗暴,CF 兼容
PG 表 EdgeKvEntry(namespaceId, key, value, expiresAt, metadata)。
支持:
put(k, v, { expirationTtl, expiration, metadata })get(k)/getWithMetadata(k)list({ prefix, limit })- TTL sweep 后台 worker(
scripts/notify-worker.ts每 60 秒批 1000 条)
agent 端 shim 还有节点本地内存 cache,热 key 命中亚毫秒。
D1 —— SQLite,直接面对面
每个 D1 库就是一份 SQLite 文件,跑在 plane 端的 better-sqlite3。binding shim 把 db.prepare(...).bind(...).all() / first() / run() RPC 进 plane。
加了一条管理面 .sql 导入:admin UI 上传 → plane 直接 db.exec(sql)。Schema migration / 旧 wrangler dump 直接搬。
Queues —— Redis 撑腰
env.MY_QUEUE.send(body) → Redis LPUSH。
消费侧:每 (queue, workerLoaded) 对一个 goroutine,BLPOP 拿一批 → POST 到 worker 的 /__edge_queue__/<queueId> synthetic URL → 解析 ack / retry 状态。
batchSize / maxWaitMs / maxRetries / visibilityTimeoutMs 都跟 CF 一致。
Durable Objects —— 单一作者 + 跨节点反代
每个 DO instance 有一个"owner node"(最早创建它的节点)。其它节点收到 env.DO.idFromName("foo").get().fetch(...) 调用时,通过 plane 查 owner,然后跨节点用 fleet shared secret 反代过去。
storage.put/get 都落在 owner 节点的 SQLite 文件里。owner 节点挂了 → plane 触发 ownership migration。
简单不完美,但能用。
Analytics Engine —— 写多读少
env.AE.writeDataPoint({blobs, doubles, indexes}) → 批量进 PG EdgeAnalyticsEvent。admin UI 有 SQL-lite 查询面板,聚合走 PG 自家 time_bucket (TimescaleDB 可选)。
Cache API —— 本地磁盘
caches.default.put(req, resp) → agent 把 response body + headers 序列化进 cache/<sha-of-cacheKey> 文件。TTL 走 Cache-Control: max-age,过期 lazy delete。
跨节点 NOT shared(同 CF)。
4.8 Service Bindings#
最爽的功能之一。worker A 想调 worker B → admin 加一条 bindingName=API, target=B,代码里:
const resp = await env.API.fetch(new Request("https://internal/anything"))
agent 把这个 fetch 不走公网,直接 HTTP loopback 到 B 的 workerd 端口。延迟亚毫秒,无 DNS,无 CORS,token 不需要漏出。
Pages 也能绑 worker。这就是 "pages 怎么调 worker 最方便" 的答案 —— 不要走公网 fetch,绑一下就完事。
五、横切关注点#
5.1 自定义域 + ACME#
<edge-zone> (*.edge.bigrandall.io) 走一张 wildcard cert,所有 auto-sub 共用。自定义域走 per-host cert + alias CNAME 流程,operator 不需要 DNS API token,只需要在自家 DNS 加一条 CNAME。
实测一条 hostname 从创建到 cert 落地 ~30 秒。
5.2 CORS#
跟 R2 / Workers / Pages 都做了平台级 CORS allow-list:
| 层 | baseline 自动算 | 加什么 |
|---|---|---|
| R2 bucket | 桶自己 hostnames + 绑这个桶的 workers/pages 全部 hostname | 手动 list |
| Worker | 自己 hostnames + 同 owner 所有 workers/pages | 手动 list |
| Pages | 同上 | 同上 |
agent 在 ModifyResponse 注 Access-Control-Allow-Origin: <echo> + Vary: Origin,但只在用户代码没自己设 ACAO 时(operator-wins 语义,跟 CF Workers 一致)。OPTIONS preflight 在 router 层直接 204 短路,不打扰 workerd。
* 作为 manual entry 触发 wildcard 模式。
5.3 Git 部署管线#
要点:
- HTTPS PAT 或 SSH deploy key(plane 全局共享一把 ed25519)二选一,SSH 不漏明文 token
- build env 继承 runtime env:
{...envJson, ...gitBuildEnv},gitBuildEnv 覆盖同名 key。一次配置,build + runtime 都能用 - per-project 缓存目录:
/var/lib/edge-builds-cache/{kind}/{id},跨 build 共享node_modules/ pnpm-store - Docker 模式可选(
EDGE_BUILD_USE_DOCKER=1),build 跑在node:22-bookworm-slim隔离环境,适合多租户 plane
5.4 Live Runtime Logs#
不上 SSE / WebSocket 是有意为之:1Hz polling + DB 撑得住几十 worker 同时直播。
前端 RuntimeLogStream 还能解 JSON 结构化日志,按 level 自动染色 + 把 msg 提到前面(其他 binding 折成 k=v 行尾)。pino / 自家 logger 想用都行。
5.5 Self-Update#
GH Actions 在每次 edge-agent/** push 跑:cross-compile linux-amd64 / linux-arm64 / darwin → 算 sha256 → SCP 到 plane /app/bigrandall.io/public/edge-agent/<tag>/ → 翻 latest symlink。
agent 每次 heartbeat 收 desiredAgentVer,跟自身比对 → 不等就 selfupdate.Apply:
- 下载
<tag>/<binaryName>+.sha256 - 校验 hash
- 原地 rename(atomic swap)
syscall.Exec自己 → 新二进制接管,所有 workerd 子进程不动(parent 切换无关 child)
整个过程对入流量近乎零影响(切换 < 100 ms)。
六、踩过的坑#
坑 1:workerd 把任何属性当 RPC method#
症状: env.MY_BUCKET.put(key, value) 抛 TypeError: Fetch API cannot load: <key>。
根因: workerd 新 compat date / RPC compat flag 下,raw Service Binding 上任何 property 访问都被当 RPC method。.put(key, value) 被翻译成 fetch(key, value) → key 不是合法 URL → throw。
特别坑的是 Nitro CF Pages preset(以及任何把 env 存进 context 再读的 framework):{...env} / Object.entries(env) / getOwnPropertyDescriptor 这些访问路径绕开 Proxy 的 get trap,拿到 raw binding。
最终方案: env wrap 别用 Proxy,用急切 copy。每次 fetch 直接遍历 own keys,命中的 binding 立刻实例化成 shim,生成 plain object:
function __wrapEnv(env) {
const out = {};
for (const k of Reflect.ownKeys(env)) {
out[k] = __wrap[k] ? __wrap[k](env) : env[k];
}
return out;
}
任何访问路径都拿到 wrapped 实例,没有 Proxy 可绕。代价 O(bindings) 每请求,微秒级,看不见。
坑 2:workerd 只认 node:* 前缀的内建模块#
症状: bundle 装进 workerd 起来时报 No such module "crypto"(即使加了 nodejs_compat flag)。
根因: 很多老 npm 包(常见于 2020 年前的代码)用 bare 形式 import e from "crypto",没加 node: 前缀。workerd 的 nodejs_compat 注册 module 表时只挂 prefixed 形式(node:crypto、node:url 等),bare 名根本不存在。
最终方案: esbuild alias 在 bundle 阶段把所有 bare 内建名改写成 node:*:
const NODE_BUILTINS = ['crypto', 'url', 'buffer', 'util', 'stream', 'fs', 'path', 'os', 'process', /* ... */]
build({
alias: Object.fromEntries(NODE_BUILTINS.map(n => [n, `node:${n}`])),
external: NODE_BUILTINS.map(n => `node:${n}`),
})
emitted bundle 里全是 from "node:crypto",workerd 直接 resolve。
坑 3:workerd 的 createCipheriv 拒绝 null IV#
症状:
TypeError: The "iv" argument must be of type string or an instance of
Buffer, TypedArray, or DataView or null. Received null
(报错文本自相矛盾 —— 说明明 "or null" 但实际拒 null。这是 workerd polyfill 的 bug,不是设计)
根因: Node 的 crypto.createCipheriv("aes-128-ecb", key, null) 是合法的(ECB 不用 IV),workerd node:crypto 实现更严格,ECB 也要求 IV 是 Buffer 之类。
最终方案: shim createCipheriv / createDecipheriv,null/undefined IV → Buffer.alloc(0)(OpenSSL 自家约定下等价于"不用 IV")。esbuild plugin 把所有 crypto / node:crypto import 路由到 shim,shim 自己用 node:crypto external import 拿真实现:
// node-crypto-shim.js
import nodeCrypto from 'node:crypto'
function safeCreateCipheriv(algo, key, iv, options) {
if (iv == null) iv = Buffer.alloc(0)
return nodeCrypto.createCipheriv(algo, key, iv, options)
}
// Proxy 让其它 API(createHash / randomBytes / …)原样透传
export default new Proxy(nodeCrypto, {
get(target, prop) {
if (prop === 'createCipheriv') return safeCreateCipheriv
return Reflect.get(target, prop)
}
})
坑 4:ssh-keygen 读不了 Node 生成的 ed25519 PEM#
症状: crypto.generateKeyPairSync('ed25519', { privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }) 出来的私钥,喂给 ssh-keygen -p -m RFC4716 -f <path> 转 OpenSSH envelope 时报 Failed to load key: invalid format。
根因: ssh-keygen 不实现 PKCS8 PEM 读取(至少对 ed25519 是这样),只接受 OpenSSH 自家私钥格式。Node crypto 模块只能产 PKCS8/SPKI PEM 或 DER,出不了 OpenSSH 格式。
最终方案: 别在 Node 里生成,直接 shell 出 ssh-keygen 让 openssh 自己生成:
ssh-keygen -t ed25519 -N "" -C "<comment>" -f /tmp/key -q
产物 /tmp/key 是 OpenSSH 私钥,/tmp/key.pub 是单行 public key,git / sshd / GitHub Deploy Keys 全认。
坑 5:npm install 在 NODE_ENV=production 下跳 devDependencies#
症状: build runner 跑 npm install && npm run build,esbuild 找不到 —— 即使它写在 devDependencies 里。
根因: npm 看到 NODE_ENV=production 自动 omit devDeps。如果 build env 继承了 runtime env(常见做法,避免重复配置),里面又有 NODE_ENV=production,esbuild / tsc 这种 build 时工具会消失。
最终方案: build 必需的工具挪到 dependencies。语义上 esbuild / tsc 就是 build pipeline 的依赖,跟运行时无关但跟 build 强相关,放 devDeps 其实不准确 —— devDeps 应该是只在"本地开发"用的(lint / test / typecheck);CI 也要的就该是 deps。
坑 6:zod optional() 不接受 null#
症状: 用 zod 校验 FormData,某字段表单里没出现时 schema 报 invalid_type: expected string, got null。
根因: z.string().optional() 等价于 z.union([z.string(), z.undefined()]),不接 null。FormData.get(key) 在字段缺席时返回 null,跟 undefined 字面不同。
最终方案: 表单收集层统一 null → undefined 归一化:
function pickStringOrUndefined(fd: FormData, key: string): string | undefined {
const v = fd.get(key)
return typeof v === 'string' && v.length > 0 ? v : undefined
}
// usage
const parsed = schema.parse({
filesJson: pickStringOrUndefined(formData, 'filesJson'),
entryFile: pickStringOrUndefined(formData, 'entryFile'),
})
每个 optional 字段都得这么处理,直接散在 formData.get(...) 一定会漏一两个 —— TypeScript 也提示不到,跑起来才炸。
坑 7:pino 跑不动 workerd#
症状: 把现成 Node 项目移植到 workerd,import pino from 'pino' 起来时报 Cannot find module 'worker_threads' 或一堆 fs / process 引用失败。
根因: pino 内部 lazy-load worker_threads 做 async transport;pino-pretty 直接吃 fs / process 控制 stdout。workerd 这两类原生模块都不开。
最终方案: 自己 ~100 行实现 pino 表面 API,内里就 console.log(JSON.stringify(...))。agent 端 stdout 是 workerd 自带的捕获通道:
const LEVEL_PRIORITY = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }
function makeLogger(bindings = {}, minLevel = 'info') {
const threshold = LEVEL_PRIORITY[minLevel] ?? 30
function emit(level, payload, message) {
if (LEVEL_PRIORITY[level] < threshold) return
console.log(JSON.stringify({
time: new Date().toISOString(),
level,
...bindings,
...(typeof payload === 'object' && payload !== null ? payload : {}),
msg: typeof payload === 'string' ? payload : (message ?? ''),
}))
}
return {
info: (p, m) => emit('info', p, m),
warn: (p, m) => emit('warn', p, m),
error: (p, m) => emit('error', p, m),
debug: (p, m) => emit('debug', p, m),
child: (extra) => makeLogger({ ...bindings, ...extra }, minLevel),
}
}
logger.info({reqId, status}, 'request done') 这种 pino 风格调用全保留,改原项目代码量近乎为零。控制面那边 log tail 解 JSON 按 level 染色,体验跟 pino-pretty 没区别。
致谢#
- workerd —— Cloudflare 开源的 Workers runtime,RandallFlare 的整个 V8 隔离层就是它。这步 open source 让自托管 CF-style 平台第一次成为可能
- Prisma / Next.js / Go std lib —— 标准栈,不展开
迁移测试床(都跑在自家 plane 上):
- moments.randallanjie.com —— 朋友圈 SNS,Nuxt + R2 + D1 + KV
- music.rapi.rest —— Meting-API 移植,workers + nodejs_compat + service binding
后记#
写这个项目最大的收获是把分布式系统里所有'设计权衡'都体会一遍:
- pull vs push,选了 pull
- 状态在哪一层,选了 plane 是 single source of truth
- 失败模式,选了"agent 崩了重启就行,plane 崩了等修"
- 升级路径,选了 self-update 不重启 child(子进程跨 agent 升级仍存活)
- 跨节点共享,选了 content-addressed + rendezvous hashing(blob)+ owner-node(DO)
每一条 select 都意味着放弃了别的可能,而且每条都在实测中被坑过两次以上。这就是平台层软件好玩的地方 —— 一个表面"用着像 CF"的产品,内里全是一连串"知道这些权衡之后怎么选"的过程。