跳转到正文

博客:Workers、自部署与 Pages Functions #ai

mars-blog 的架构在三周里换了三次:先在 Cloudflare Workers 上做全动态站,然后搬去自己的服务器自部署,最后回到Cloudflare,但改成「静态站 + 边缘函数」。三次迁移没有一次是白折腾——同一个问题被越拆越清楚:一个人写的博客,到底需要多少「动态」?

起点:为什么离开纯静态站

前身是 astro-paper-blog:内容以 Markdown 存在 git 仓库,发布 = 写文件、提交、推送。在电脑前这条链路很顺,离开电脑就断了——手机上发一条带图短文,要在快捷指令里搭二十多个动作;长文干脆没法写。

我最初的判断是「缺一个写作后台」,于是做了 v1:一个带后台的动态博客。回头看,这个判断只对了一半。

v1:Workers + D1 + R2 的全动态站

技术栈一句话:Astro 全站 SSR 跑在 Cloudflare Workers 上(@astrojs/cloudflare),内容存 D1,图片存 R2,后台是 /admin 里的 React island 编辑器(CodeMirror),登录先用 GitHub OAuth、后来换成单口令 + PBKDF2。

// wrangler.jsonc(v1):一个项目、一个部署、两个绑定”d1_databases”: [{ “binding”: “DB”, “database_name”: “mars-blog” }], “r2_buckets”: [{ “binding”: “MEDIA”, “bucket_name”: “mars-blog-media” }]

跑起来之后,写作后台确实解决了手机发布的问题。但三个代价慢慢显出来:

  1. 公开页面每次访问都要跑一遍 SSR 加若干条 D1 查询,首页尤其亏,只能靠 60 秒边缘缓存兜着。
  2. 内容锁在数据库里。D1 是唯一真相源,「哪天想搬走就能搬走」靠的是 GitHub Actions 每日拉 /api/export 把 Markdown 提交回仓库——这是一根明晃晃的保险丝,说明数据不归我。
  3. Workers 的运行时上限会撞到具体功能。PBKDF2 的迭代次数有硬上限,只能降到 10 万次;服务端跑不了 sharp,图片压缩只能放在浏览器端用 Canvas 做。

第三条后来成了整条项目线最持久的约束:图片始终在浏览器里压成 400/800/1600 三档 webp + jpeg 再上传,三代都没改过。

v2:自部署,脱离 Cloudflare

然后我决定把博客搬回自己的服务器:Node + SQLite(node:sqlite)+ 本地磁盘,Docker 镜像发布(GHCR / Docker Hub),Nginx反代 + microcache,登录是环境变量里的单口令 + HMAC 签名 cookie + 同 IP 十分钟五次限流,/settings 里还能配 S3 兼容存储。

自部署把 v1 的约束全解掉了:没有运行时硬上限,SQLite 就是文件,备份就是 .backup 加目录同步。但换来一台要养活的服务器:镜像要构建和同步、容器运行时要升级、备份要自己排 cron、反代缓存与会话规则要自己维护。

v2 没有活多久。它不是不好,而是和这个站的流量形态不匹配:读者看的是同一批页面,真正需要「动态」的只有作者一个人(登录、编辑、审核),加上评论和浏览数几个低频动作。为了这些养一台服务器,重了。

v3:静态站 + Pages Functions

第三次迁移回到 Cloudflare,但换了个思路:把「全站动态」拆成「静态内容 + 一薄层边缘动态」。

  • 内容真相源回到文件:R2 内容桶里的 Markdown(posts/、notes/、pages/ 三个前缀)。构建期 scripts/sync-content.mjs 用 S3 协议把 R2 拉到 src/content/,Astro 以 output: static 渲染成纯 HTML。内容重新变成能 grep、能带走的东西。

  • 动态能力收敛到 Pages Functions:口令登录与 HMAC 会话、/api/content 读写 R2 并触发 Deploy Hook 重建、评论与浏览量走 D1、图片经 /media/* 从私有 R2 桶代理出图。

  • D1 从「全部内容」退到「只有动态数据」:评论(post_comments)、浏览量(page_views)、限流(rate_limits)三张表。

  • 保存 = 把 Markdown 写回 R2 + 触发 Deploy Hook,Pages 重建后约一两分钟上线:PUT /api/content/posts/xxx.md → R2 → Deploy Hook → 重建 → 上线

为什么这次是对的:一个个人博客的阅读流量是压倒性的,静态页零计算、全缓存、免费额度用不完;需要动态的部分(一个月写几篇、几条评论、几个浏览数)边缘函数完全扛得住。

v3 有一个明确的取舍:编辑保存不是即时的,要等一两分钟重建。代价换来的是读者端永远是缓存友好的纯静态页——作者等得起这两分钟,读者等不起每次 SSR。

三代没丢的东西

三次迁移动过界面、数据层、部署形态,但有四件事从头到尾没变:

  1. 阅读页就是编辑界面。v1 最初有 /admin 后台,很快拆掉了——评论是回应某段话的,草稿是某天写了一半的,都得和所在位置待在一起才判断得了。登录之后点铅笔就地变编辑态,这个形态从 v1 后期一路沿袭到 v3。
  2. 图片在浏览器里压。服务端跑不了 sharp 的约束,反而沉淀成了固定方案:端上压三档再传,谁也不用在服务端做图像处理。v3后期主档切成 jpeg、只下发 400/800 两档,1600 只归档。
  3. 正文只存短引用:![](/media/<uid>)。渲染时才查变体拼 srcset,换域名、换存储只用改渲染一处,正文不动。
  4. 单用户的极简。没有用户表、没有标签系统、没有分享按钮;登录只有一个口令加一个签名 cookie,「能证明你输对过口令」就够了。

总结

三代演进可以压缩成一个问题:一个博客需要多少动态? v1 的答案是「内容也是动态的」(全存数据库),v2 的答案是「要动态就得自部署」,v3 的答案是「内容可以是静态文件,动态只留边缘一层」。

v1v2v3
托管Cloudflare Workers自部署 Docker + NginxCloudflare Pages
内容D1SQLiteR2 Markdown,构建期拉取
图片R2本地磁盘 / S3私有 R2 + /media/* 代理
动态数据D1SQLiteD1(仅评论 / 浏览量 / 限流)
保存写 D1写 SQLite写 R2 + Deploy Hook 重建
登录OAuth → 口令口令口令

内容回归文件、动态收缩到边缘,这大概是我对这个博客架构最满意的一版。