bigrandall.io

← back to writing

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。崩了就重启,不丢状态。


三、技术选型#

技术为啥
控制面 webNext.js 16 App Routerserver actions 让 admin UI 写得跟普通 React 一样;无独立 BFF
ORMPrisma + PostgreSQLmigration 系统是这次项目最大依赖之一,迭代极快
任务队列Redis (BLPOP/RPUSH)比 SQS 轻;比内存队列耐崩溃
边缘 agentGo静态二进制 + 单文件分发 + syscall 直接 exec workerd;依赖最少
V8 runtimeworkerdCF 官方开源,等于跑同一个 isolate;兼容 nodejs_compat flag
capnp 配置workerd 自带 .capnp DSL每个 worker 一个动态生成的配置文件
TLSACME 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 干的事:

  1. 用户在 /me/edge/workers 写代码 / 配 binding / 改 hostname
  2. server action 直接读写 PG,顺手 bump version 字段
  3. 下一次 agent 5 秒 poll → plane 比对 known[workerId] == version,不等就把整包(files + entryFile + envJson + bindings + crons + compat flags + CORS allow list)塞回去
  4. 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,启动后做三件事:

  1. /edge/agent/register 拿 nodeID + agent token
  2. 起 4 个 poll 循环:workers / pages / R2 / heartbeat,每 5 秒一轮
  3. 监听 :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。其判定优先级:

  1. R2 hostname(bucket 公开 URL,<bucket>-r2-<user>.<edge-zone> 或自定义)
  2. Pages hostname(包括 <branch>--<project>-<user>.<edge-zone> preview)
  3. Worker hostname(custom 域 / auto-sub)
  4. 都没命中 → 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-ipcountry header / 入站 IP 地理推断)
  • process.env 自动同步 string bindings(Nuxt / Nitro 友好)
  • 自带 /_edge/*.js shim,把 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#

支持两种入口:

  1. Static-only:仅静态文件 + _routes.json / _headers / _redirects
  2. 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 domaincdn.your-domain.com/<key>同上,DNS CNAME 进来
Central fallbackbigrandall.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 PATSSH 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:

  1. 下载 <tag>/<binaryName> + .sha256
  2. 校验 hash
  3. 原地 rename(atomic swap)
  4. 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:cryptonode: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 installNODE_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()]),不接 nullFormData.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 上):


后记#

写这个项目最大的收获是把分布式系统里所有'设计权衡'都体会一遍:

  • 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"的产品,内里全是一连串"知道这些权衡之后怎么选"的过程。

no comments yet

quick anti-bot check — nothing personal.