2213 字
11 分钟
页面浏览--访问次数--
博客后台云部署

我的博客拆成两个仓库:公开的 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 重建站点]

「没有草稿分支」就等于「没有待发布改动」——界面上的状态判断就靠这一条。

界面本身没动。两种模式共用同一套组件和前端代码,差异收敛在两个接口上:文件读写、版本控制。

六个坑#

一、路径别名只在本地成立#

现象:本地所有测试全绿,线上函数加载失败。

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 的重写规则捕获名和业务查询参数重名:

vercel.json(有问题的那一版)
{ "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 次请求)8366ms
仪表盘8452ms

五、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。

现在的日常#

写一篇的完整流程

  1. 新建

    选类型,填标题、分类、标签。管理端生成文件,落到草稿分支。

  2. 写正文,保存

    此时 main 和线上站点没有任何变化。

  3. 发布前反悔

    提交面板列出所有待发布文件,每一项都能单独撤销:新增的直接删掉,改过的还原成 main 的版本。

  4. 发布

    填一句说明,点「发布到 main」。管理端以当时最新的 main 树为底合并草稿,触发站点重建。

  5. 几十秒后,线上就是新的了。

博客后台云部署
https://www.klucen.cn/posts/生活/博客后台云部署/
作者
KLucen
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

评论

正在加载评论...