
我的博客拆成两个仓库:公开的 shirone-blog 放主题和站点代码,私有的 shirone-content 放写作内容。内容推到 main,一条 Deploy Hook 让 Vercel 重新构建站点。
管理端(ShironeAdmin)是给内容仓配的图形界面:选类型、新建、写正文、改配置、提交推送。它一直挺好用,但只能在那台装好环境的电脑上跑——接口绑在 127.0.0.1,直接读写本地目录,用本机 git 提交。想在外面改个错别字,就得让那台电脑一直开着。
这篇记录把它搬上免费 Serverless 的过程。结论放前面:能做,而且不花钱。代价不是钱,是把「文件系统 + git」抽象成「API + 草稿分支」的那次改造,以及六个坑。
免费 Serverless 缺的正好是它最依赖的两样
管理端依赖一个持久可写的目录,和一个能执行的 git。免费 Serverless 两样都没有。
函数实例的文件系统是临时的,实例一换,未提交的改动就没了;git 这个二进制也不存在。所以「打包上传」毫无意义——要换的是存储层。
用草稿分支代替「工作区」
本地模式下,「未提交的改动」就是工作区里的脏文件。远程模式没有工作区,我拿一个分支顶上:
- 写入先落
shirone-admin-draft; - 只有
main上的提交会触发站点重建,所以草稿不会把半成品发上线; - 点「发布」时,以当时最新的
main树为底做路径级覆盖——草稿创建之后main上的其它改动不会被回滚。
flowchart LR A[浏览器<br/>手机 / 公司] -->|HTTPS| B[Vercel 函数<br/>界面 + API] B -->|GitHub API| C[(内容仓<br/>shirone-content)] B -.->|写入先落这里| D[草稿分支<br/>shirone-admin-draft] D -->|点「发布」才合并| E[main] E -->|Deploy Hook| F[Vercel 重建站点]
「没有草稿分支」就等于「没有待发布改动」——界面上的状态判断就靠这一条。
界面本身没动。两种模式共用同一套组件和前端代码,差异收敛在两个接口上:文件读写、版本控制。
六个坑
一、路径别名只在本地成立
现象:本地所有测试全绿,线上函数加载失败。
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@shared/pageSchemas'imported from /var/task/server/pageConfig.js@shared/* 是 tsconfig 里的 paths 别名。本地 tsx 会解析它,Vercel 构建函数时不读 paths。同一个 import,本地是绿的,线上是死的。
修法有两层:改回相对路径;再给构建加一道闸门——从真实入口出发静态遍历 import 图,禁掉路径别名、前端代码和未声明的依赖,并接进 pnpm build。这类问题从此在构建阶段就以人话报出来。
二、配置缺失不该让整个函数无法启动
现象:部署完成后打开页面,只看到一行 Unexpected token 'A', "A server e"...。
原因是漏了一个环境变量:程序按本地模式启动,在模块加载期去检查内容仓目录是否存在(Serverless 上当然不存在),抛异常,函数加载失败,平台返回一张纯文本错误页,前端的 response.json() 只好抛解析错误。整条报错和真正的原因毫无关系。
修法:内容仓路径解析不再抛异常;配置问题集中到 /api/health 的 problems 里;再加一个 env 清单,只报每个变量是 set 还是 missing,不回显值。现在同样的坏配置会直接说清楚:
{ "mode": "local", "problems": [ "本地模式,但内容仓不存在:/var/shirone-content\n如果这是部署到 Serverless(Vercel / Cloudflare),几乎一定是漏配了环境变量:\n SHIRONE_STORAGE=github\n配好后需要重新部署(Redeploy)才会生效。" ]}三、重写规则的捕获名撞上了业务参数
现象:文章列表正常,点开任意一篇报 文件不存在:doc。
错误里的路径就是字面的 doc——那是路由段。原因是 Vercel 的重写规则捕获名和业务查询参数重名:
{ "source": "/api/:path*", "destination": "/api/index?__path=:path*" }平台除了应用目标里的查询参数,还会把匹配到的捕获段按捕获名注入成查询参数。于是 /api/doc?path=真实路径 变成「带 path=doc 的请求」,真实路径被覆盖,服务端就去内容仓里找一个叫 doc 的文件。列表接口没有 path 参数,所以只有点文章会坏。
修法:捕获名换成不会与业务参数重名的 __route,还原时把所有 __ 前缀参数当作桥接参数删掉。另外加了一个 /api/_bridge,把函数真实收到的 URL 原样吐出来。
四、「本地很快」的并发策略,在网络上会变成限流
仪表盘要对每篇文章读一次 frontmatter。本地文件系统上 Promise.all 一把梭最快;换成网络请求之后,文章数就等于并发数——60 篇文章就是 60 个并发请求。
GitHub 除了明面上的小时额度,还有一层针对并发突发的次级速率限制(secondary rate limit),触发后返回 403,而且不计入小时额度。线上的症状就是「仪表盘转圈然后报错」。
修法:所有 GitHub 请求过一个并发闸门(默认 8),并识别次级限流、按 retry-after 重试。实测 60 篇文章、人为加 12ms 延迟:
| 操作 | 最大并发 | 耗时 |
|---|---|---|
| 文章列表(65 次请求) | 8 | 366ms |
| 仪表盘 | 8 | 452ms |
五、workerd 里 import.meta.url 不是合法 URL
现象:Cloudflare Workers 本地启动直接退出,只留一行 Uncaught TypeError: Invalid URL string,指向打包产物的某一行,完全看不出是哪段源码。
原因是 new URL("..", import.meta.url) 在模块加载期执行,而 workerd 里 import.meta.url 形如 worker.js,不是合法的绝对 URL。
修法:包一层 try/catch 兜底(那个路径在远程模式下本来也用不到)。
六、抽象泄漏:虚拟路径跑进了真实的 fs 调用
删除一篇文章之后会自底向上清理空目录,用的是 rmdir。而远程模式下 resolveInRepo() 返回的是 /content/posts 这样的虚拟路径——把它交给真实的 rmdir,等于「去删文件系统根目录下的同名目录」。
它当时没出事,是因为 ENOENT 被 catch 兜住了。运气,不是设计。修法是让目录清理只在本地模式执行:git 的树是扁平的路径集合,本来就没有「空目录」这回事。
一次自己的事故
测试脚本原本的做法是:改真实内容仓里的文件,再用 git checkout 还原。有一次我以为已经杀掉的旧 API 进程还在监听端口,测试连上了它,把 config/site.yaml 的主题色改了。发现后逐字段核对、还原。
之后的规矩是:所有端到端测试自建临时内容仓或内存版假服务,没有一个能碰到真实内容。现在跑测试,内容仓的修改时间不会变。
不用真仓库也能端到端
这六个坑有个共同点:本地测试几乎都测不出来。所以验证方式也跟着换了。
- 一个内存版假 GitHub(只实现用到的那几个 Git Data API 端点),能在不碰真仓库、不需要 token 的前提下跑完「登录 → 写草稿 → 撤销 → 发布」整条链路;
- 一个真 workerd 上的冒烟(
wrangler dev加同一套断言),确认非 Node 运行时也能跑; - 远程断言 183 条:存储模式 112、Vercel 入口 17、部署自检 18、并发与限流 23、坏配置退化 13;本地另有 4 套自建仓端到端和 6 套单测;
- 这些都在 CI 里跑,不需要任何 secret。
现在的日常
写一篇的完整流程
新建
选类型,填标题、分类、标签。管理端生成文件,落到草稿分支。
写正文,保存
此时
main和线上站点没有任何变化。发布前反悔
提交面板列出所有待发布文件,每一项都能单独撤销:新增的直接删掉,改过的还原成
main的版本。发布
填一句说明,点「发布到 main」。管理端以当时最新的
main树为底合并草稿,触发站点重建。几十秒后,线上就是新的了。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 6 天
部分内容可能已过时
评论