Cloudflare Workers + D1 + R2
前言
这个博客原来是个纯静态站:内容以 MDX 存在 git 里,next build 导出 out/,交给 Vercel。够用了一年多,但我想加个后台,能在浏览器里写文章、传图片,不用每次改完 Markdown 再走一遍 commit 和 push。
于是花了两个晚上把它搬到 Cloudflare:正文进 D1,图片进 R2,后台和图片代理跑在同一个 Worker 上。
踩的坑比预想的多。有的是 Cloudflare 的默认行为和直觉相反,有的是我自己手滑。下面按遇到的顺序记一遍,重点写症状长什么样——其中几个的症状极具误导性,我至少有一次判断反了方向。
一、三个刻意的取舍
前台不上 SSR
第一反应是"都上 Cloudflare 了,顺手把 SSR 也开起来"。
查完限制放弃了。Workers 免费档的单次请求 CPU 上限是 10ms,而 Cloudflare 自己的限制文档里写着,SSR 和鉴权类负载通常要吃 10~20ms。免费档装不下 Next.js 的 SSR。
所以前台保持 output: 'export',产物交给 Workers Static Assets 直接返回。静态资产的请求不消耗 CPU、不计费、不限量,永远碰不到那条 10ms 的线。代价是发布需要重新构建,1~3 分钟才生效,做不到「保存即上线」。
对个人博客来说这个交换很划算。
后台鉴权自己写
Cloudflare Access 是现成的,本该直接用。但它有个前提:self-hosted 应用要求域名是你自己管理的 zone。站点跑在 *.workers.dev 上,而 workers.dev 属于 Cloudflare,Access 的域名选择器里根本选不到。
于是鉴权自己在 Worker 里实现:POST 密码,校验通过后下发一个 HMAC 签名的 httpOnly cookie,7 天有效。
写的时候有两个细节值得说。
一是密码比较不能直接比字符串。做法是两边都过一次 HMAC 再比摘要,这样比的是等长字节,连"密码有多长"都不会从响应里泄露;比较本身用恒定时间算法,避免通过耗时逐字节推断出正确值。
二是缺配置时必须拒绝,而不是放行。ADMIN_PASSWORD 和 SESSION_SECRET 任何一个为空,所有后台请求一律 401。这个选择的价值马上就体现了:第一次部署完密码还没设,后台直接不可用——这正是我想要的行为。
内容和图片分家
正文进 D1,图片进 R2。
图片没有直接暴露 R2 的公开域名,而是走 Worker 的 /media/** 路由转发。这么做是为了让正文里写的是同域相对路径 /media/xxx.png——以后换域名、换 workers.dev 子域,正文里的路径都不用动。
还有个小设计:日期存成两个字段。date_text 存作者时区(+08:00)的日历日,只用于展示;published_at 存 epoch 毫秒,只用于排序。合成一个字段会让 2026-08-23T02:10:00+08:00 这种带时区的日期经 UTC 换算退到前一天。
二、构建流程
最终跑通的链路:
后台保存 → 写入 D1(此时线上还没变)
点「发布」 → Worker 调 GitHub repository_dispatch
→ Action 触发:
content:pull D1 → content/*.md
fonts 按内容实际字符生成字体子集
next build 静态导出 out/
wrangler deploy
→ 1~3 分钟后线上生效构建顺序不能改。pnpm fonts 扫的是 content/ 和 src/ 里的实际字符来生成 WOFF2 子集,内容没落地就跑字体,新文章里的字会 fallback 到系统字体。
三、九个坑
坑 1:R2 的 wrangler 子命令默认写本地
一次性迁移脚本要把正文里引用的本地图片上传到 R2。跑完看输出,12 条内容全部导入成功,图片上传也打印了 Upload complete.。
但我顺手查了一下远端桶:
GET /accounts/.../r2/buckets/blog-media/objects → 对象数: 0一个是空的。图片全进了 .wrangler/state/v3/r2/blog-media/blobs/,也就是本地模拟桶。
原因是 wrangler r2 object put 默认写本地,要写远端得显式加 --remote。而 wrangler 的输出里其实提醒了:
Resource location: local
Use --remote if you want to access the remote instance.我扫了一眼,没细看。
这个坑真正麻烦的地方在于危害是隐性的。 脚本的逻辑是"上传成功就返回新 URL",然后拿这个 URL 改写正文。所以图片虽然没传上去,D1 里的路径已经被改成了 /media/thoughts/xxx.png。部署之后,正文指向的是一批不存在的对象——3 张图全部 404,而构建和部署全程绿灯。
修法是一行:加 --remote。
坑 2:构建到最后一秒报 EBUSY
第一次跑部署,编译通过、22 个静态页全部生成,然后在最后一步倒了:
[Error: EBUSY: resource busy or locked, rmdir '...\out']有进程占着 out/。
查了一下运行中的 node 进程,是上一个会话留下的 wrangler dev --port 8787——三个进程还活着。上个会话结束前确实"停"过它,但只停掉了 shell 任务,底下真正在跑的 node 进程没被回收。而 wrangler.jsonc 里配了 assets.directory: "./out",这个 dev server 就一直占着那个目录。
把三个进程杀掉,重新构建,一次过。
坑 3:Token 无效,而我猜错了原因
第一次验证凭据:
GET /user/tokens/verify → {"code":1000,"message":"Invalid API Token"}我看了下 token 长度,53 位,然后写下一句判断:Cloudflare 控制台生成的 token 一般是 40 位,53 位对不上,大概是复制错了。
这个判断是错的。 换了个新 token 之后,长度还是 53 位,验证直接通过。53 位就是正常长度,问题从头到尾只是那个 token 本身无效(可能是旧的,或者被撤销了)。
教训是:拿一个记不太准的"常识数字"去推断,比不推断更糟——它会把人引向一个具体但错误的方向。
坑 4:CRLF 偷偷进了环境变量
.env.cloudflare 是 CRLF 行尾。在 Git Bash 里 source 之后:
$ echo ${#CLOUDFLARE_ACCOUNT_ID}
33 # 应该是 32多出来的那一位是 \r。它会一路带进 wrangler 和迁移脚本,报出一堆看不出所以然的错。
用 node 把文件重写成 LF 就好了。改之前我对比了解析出来的值,确认只动了行尾,内容一个字节没变。
坑 5:wrangler secret put 的 key 和 value
这个坑我连踩两次。
wrangler secret put 的用法是:位置参数只有 key,值靠交互式提示输入。
第一次我把密码当第二个位置参数传了:
$ wrangler secret put ADMIN_PASSWORD xxxxxxxxxxxxxxxxxxxxxxxx
X [ERROR] Unknown argument: xxxxxxxxxxxxxxxxxxxxxxxx报错很明确,命令根本没执行。
第二次我理解偏了,把密码当成了 key:
$ wrangler secret put xxxxxxxxxxxxxxxxxxxxxxxx
√ Enter a secret value: ...
✨ Success! Uploaded secret xxxxxxxxxxxxxxxxxxxxxxxx成功提示印得漂漂亮亮。但 Worker 里读的是 env.ADMIN_PASSWORD,一个叫 xxxx... 的 secret 它永远看不见。
这里的教训不是"要好好看文档",而是成功提示的语义范围很窄:它只说明值写进去了,不说明名字对了。当时的验证方式是列一遍 secret:
[ SESSION_SECRET, xxxxxxxxxxxxxxxxxxxxxxxx ]一眼就看出问题。
坑 6:GitHub 上的值被粘贴污染
CI 第一次跑,第一步就挂了:
[content:pull] 失败: D1 查询失败:[{"code":10000,"message":"Authentication error"}]同一个 token 在本机跑得好好的,几分钟前才用它读写过 D1。日志里 CLOUDFLARE_API_TOKEN: *** 说明值确实注入了(空值不会显示星号),所以问题出在那个值本身。
从 .env.cloudflare 里复制很容易带走这些:
❌ export CLOUDFLARE_API_TOKEN="abc123..." 整行,带了 export 和引号
❌ "abc123..." 只带引号
❌ abc123... 尾随空格或换行尾随换行尤其阴,肉眼完全看不出来,但 Cloudflare 拿到的就是带 \n 的串。
修法是别用手选:
. "/blog/.env.cloudflare"
printf '%s' "$CLOUDFLARE_API_TOKEN" | clipprintf '%s' 不加尾随换行,clip 直接进剪贴板,然后原样粘贴。
坑 7:403 不是 401
后台点「发布」,返回:
{"error":"github responded 403"}这里状态码本身就是线索。401 表示凭据不被认可,403 表示凭据被认出来了、但权限不够。 既然拿到了 403,就可以排除"token 写错了",直接往权限方向查。
我用的是细粒度 PAT。细粒度 PAT 创建时默认权限是 No access,不是自动全开,必须手动一项项勾,而 repository_dispatch 需要的是 Contents: Read and write。
改细粒度 PAT 的权限不会改变 token 字符串本身,所以修完不用重设 Worker secret,直接再点一次发布就行。
坑 8:workflow 不在默认分支上
这个值得单独拎出来说,因为它完全静默。
repository_dispatch 只能触发默认分支上存在的 workflow。而当时 .github/workflows/deploy.yml 还在特性分支里,master 上没有。
后果是:后台点「发布」→ Worker 调 GitHub API → GitHub 返回 204 → Worker 回复"已触发" → Actions 一条都不跑。
每一步都成功,每一步都没做事。
如果不是顺手查了一下 Actions 页面,这个问题能在上线后潜伏很久:按钮看着永远正常,内容永远不更新。
坑 9:workers.dev 在国内被 DNS 污染
站点部署成功、本地验证全过之后,我用 curl 直接测线上地址,所有请求都是 000。
000 不是 404 也不是 500,是 curl 压根没连上。查 DNS:
blog.<子域>.workers.dev
→ 2a03:2880:f11c:8083:face:b00c:0:25de
→ 108.160.163.102
api.cloudflare.com
→ 104.19.193.29 ← 真实的 Cloudflare IP解析出来的是 Facebook 和 Dropbox 的 IP 段,典型的 GFW DNS 污染应答。*.workers.dev 在国内是整体被墙的。
每个请求会干等 25 秒然后返回 000,特别容易被误判成"部署失败"。判断清楚之后,挂上代理再测,全部正常。
这一条不是 bug,是选 workers.dev 的固有代价。
四、现在的样子
日常流程:
- 打开
/admin,输密码 - 新建或编辑,保存(此时只写进 D1,线上还没变)
- 列表页点「发布」
- 等 1~3 分钟
数据在哪:
| 数据 | 位置 |
|---|---|
| 文章、碎碎念正文 | D1 entries 表 |
| 图片 | R2 blog-media 桶,经 Worker 的 /media/** 提供 |
| 后台密码、cookie 签名密钥 | Worker secrets |
| 构建期的站点地址 | 仓库变量 SITE_URL |
五、还没做的
站点跑在 *.workers.dev 上,没有绑自定义域——因为我没有域名。
绑了之后有两个好处:国内通常能直连(污染是针对 workers.dev 这个域名的,自己的域名解析到的是 Cloudflare 真实 IP),以及可以用回 Cloudflare Access,把自建的密码登录换成 SSO 加审计日志。
但这是可选项,不是欠账。现在这套东西不依赖自定义域,功能上没有缺任何东西。
小结
这次迁移真正花时间的不是写代码,是排查。上面九个坑里有四个的症状指向了错误的方向:
- R2 上传"成功"了,但远端是空的
- 构建"成功"了,但图片会 404
- secret"上传成功"了,但名字错了
- 「发布」"成功"了,但什么都没触发
共同点是它们都不报错。
所以现在给自己定的一条规矩是:不看"成功"两个字,去看状态本身。上传完就查桶里的对象数,部署完就 curl 一遍真实页面,设完 secret 就列一遍名字。命令的成功退出码和事情真的做成了,中间隔着一整段距离。