返回文章

Cloudflare Workers + D1 + R2

11 分钟阅读CloudflareWorkersD1R2部署复盘

前言

这个博客原来是个纯静态站:内容以 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" | clip

printf '%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 的固有代价。

四、现在的样子

日常流程:

  1. 打开 /admin,输密码
  2. 新建或编辑,保存(此时只写进 D1,线上还没变)
  3. 列表页点「发布」
  4. 等 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 就列一遍名字。命令的成功退出码和事情真的做成了,中间隔着一整段距离。

评论加载中...