本站原先跑在 Ghost 6 上(Node/Express + SQLite,单容器)。这篇记录把它整体换成 Hugo + Blowfish 静态站的全过程:迁移前实测的对照数据、内容与 URL 的搬家方式、版本化部署与回滚,以及过程中踩到的十个坑。适用环境:Ghost 6.64 / Hugo 0.166.0 extended + Blowfish 主题 / nginx:alpine 容器 + Nginx Proxy Manager + systemd / 前置 Bunny CDN,2026-09-18 实测。结论先说:值得迁,但"省内存、省带宽"不是理由——客户端那一千 KB 里最肥的一块与换不换平台无关;真正的理由是内容变成 git 里的 Markdown、发布链路可验证、回滚变成一条命令。
一、迁移前先把"更轻便"量出来 #
迁移前本站的规模:4 篇文章、5 个标签、3 个附件脚本、一共 12 条 URL。全部实测数据如下(不是文档推断,是在真实环境里量出来的)。
服务端:
| 项目 | 迁移前(Ghost 6.64) | 迁移后(Hugo + Blowfish) |
|---|---|---|
| 运行形态 | Node/Express 服务端渲染 + SQLite | 纯静态文件,nginx:alpine |
| 镜像体积 | 639 MB | 62.9 MB |
| 常驻内存 | 117.7 MiB | 4.9 MiB |
| 数据库 / 后台 | SQLite + Admin API + 登录 + 两步验证 | 无 |
| 发布通路 | Admin API(签名 JWT + 一堆必填参数) | 写 .md → 构建 → 同步 |
| 渲染开销 | 每个请求现渲染 | 构建一次 148–190 ms(30 页) |
| 内容体积 | 数据目录 21 MB(其中主题 15 MB、日志 2.7 MB、数据库 2.1 MB) | 仓库 + 构建产物 861 KB |
客户端(页面全资源,含 CSS/JS/字体/图标):
| 页面 | 迁移前 | 迁移后 |
|---|---|---|
| 首页 | 1008 KB(9 个子资源) | 218 KB 原始 / 61 KB gzip |
| 文章页 | 1030 KB | 232 KB 原始 / 68 KB gzip |
| 体积大头 | mermaid.min.js 764.8 KB 每页无条件加载、搜索 82.5 KB、KaTeX 74.1 KB、代码高亮 59.5 KB |
主样式 128.7 KB(gzip 21 KB)、主脚本 33.2 KB(gzip 11.4 KB)、缩放 8.6 KB |
| mermaid | 每页强载 | 构建产物里 0 字节(没有页面用就不发) |
上线后实测:首页联网传输 63.7 KB(迁移前 1008 KB,大约 1/16)。
但客户端这份收益不该记在迁移头上。 那 764.8 KB 的 mermaid 是旧主题里一行无条件加载造成的:
<script defer src="{{asset 'js/mermaid.min.js'}}"></script>全站没有任何一张 mermaid 图,把这一行删掉或改成按需判断,留在原平台也能拿到同样的页面瘦身。同理静态资源缓存、图片优化这些,都是 CDN 与主题层面的事,与换不换引擎无关。
二、为什么迁:维护面,不是内存 #
把迁移前的账摊开看,真正的过度工程在 CMS 这一侧:4 篇纯文本内容,养着一套完整的 Node 运行时 + SQLite + Admin API + 登录 + 两步验证 + 主题模板手术,外加一条相当脆弱的发布路径——签名用的是十六进制密钥(必须先解码再作 HMAC key,用错就 401)、更新内容必须带特定参数否则静默不生效、必须有乐观锁字段、某些接口直接抛 501 只能绕过 API 改数据库、换新设备登录会被强制邮箱验证码拦下。这些都不是能力,是维护面。
换成静态站之后,值得写在纸上的收益只有三条:
- 内容主权。内容从数据库行变成 git 仓库里的 Markdown 文件,版本、差异、审阅、回滚、增量全是现成的。
- 发布链路可验证。写
.md→ 构建 → 同步,构建可以在本地跑通再上线;运行态的故障面(Node 运行时、数据库、后台登录)收缩成一个"构建管线",而它失败时线上可以不受影响。 - 现在迁最便宜。4 篇内容、无图片、无会员、无评论、无注册,URL 一共 12 条。内容越多、外链越多,迁移只会更贵。
反过来说,不建议为了省内存而迁:宿主机的内存本来就不缺这一百多 MiB;而迁移会把"跟着 CMS 镜像升级"的维护精力,换成"Hugo 版本窗口 + 主题兼容"的维护精力——故障面是换了种类,不是消失了。
唯一不可逆的东西是 URL 与订阅(RSS 的 <guid>),所以整套方案按"先保 URL 与订阅、再谈别的"的顺序设计。
三、处置过程 #
1. 内容搬家:从数据库的 HTML 列,而不是导出 JSON #
内容源直接取 CMS 数据库的 html 列(避免二手导出数据),用一次性脚本转成 Markdown。保真度实测:表格(6 张)、围栏代码块(含语言标注)、引用块全部保留;把两边归一化后做 token 级比对,差异只剩 Markdown 记号本身(转义下划线、分隔线、列表序号)。
过程中有一个必须知道的坑:CMS 在数据库里把站点地址存成了占位符(形如 __GHOST_URL__),渲染时才替换成真实地址。直接迁移会得到一堆 __GHOST_URL__/content/files/... 的死链,脚本里必须把它替换成站内相对路径。附件(3 个 PowerShell 脚本)落到 static/content/files/<年>/<月>/,URL 与迁移前逐字节一致,构建即发布。
清洗分两步:先用扫描脚本列出所有反斜杠转义(这个平台的转换器会给词内下划线加转义),确认哪些是 Markdown 记号、哪些是 Windows 路径里的真反斜杠,围栏代码块一律不动,再动手还原。
2. URL 保平:12 条旧地址一条不漏 #
文章 URL 形态用配置项固定住([permalinks] posts = "/:slug/"),所以三篇文章的地址原样不变。需要额外处理的是其余地址:
| 旧地址(CMS) | 新地址 | 处理方式 |
|---|---|---|
| 3 篇文章 + 首页 + 关于页 | 同名 | 配置对齐,无需处理 |
/tag/<slug>/(5 个标签) |
/tags/<slug>/ |
一条 nginx 正则 301 全量转过去 |
/author/<名字>/ |
/about/ |
nginx 301 |
/rss/、/rss |
/index.xml |
nginx 301(两个都实测跳转成功) |
占位帖 /coming-soon/ |
/ |
nginx 301(内容本身不迁移:它是订阅引导页,本站没有会员,迁过去只会带出死链) |
| 一条历史改名的旧 slug | 现文章地址 | nginx 301 |
标签页有个静态站特有的坑:新引擎里给标签写 slug: 不生效(实测 0.166 版本),必须写 url:,否则标签页地址会变成中文名的拼音或原文,与旧地址对不上。写法是给每个标签建一个索引页,front matter 里写 url: "/tags/<旧 slug>/"。
还有一个只有反代环境才会遇到的坑:301 的目标必须写死 https://。源站在反向代理后面,看到的请求协议是 http,如果 301 用相对路径或 $scheme,访问者会被降级跳回 http 再被跳一次。正确写法是显式 return 301 https://$host/<新路径>;。
3. RSS 订阅连续性:<guid> 必须是旧的那一串
#
这是整个迁移里最容易翻车、又最容易被忽略的一处。RSS 条目的 <guid> 是不透明字符串,迁移前的引擎用的是数据库内部的文章 id;而 Hugo 默认拿永久链接当 <guid>。两者一换,所有老订阅者会在切换当天把已有的旧文章当成新文章重收一遍。
处理方式:从旧站的 /rss/ 里把每篇文章的 <guid> 取回来写进 front matter,再用一个自定义的 layouts/rss.xml 覆盖默认模板——front matter 里有 guid 就用它,没有才退回永久链接;同时用 mainSections = ["posts"] 把独立页面(关于页等)踢出 feed。
模板里另有一个语法坑:XML 声明必须走模板函数输出,字面写会被转义成 <?xml:
{{ printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}验证方式是逐条比对:feed 条数、每条 <guid> 与线上旧 feed 逐个一致、XML 可解析。
4. 部署:版本化目录 + 符号链接原子切换 #
落地结构:
/srv/site/
├── src/ 内容仓库的工作副本(构建前 fetch + reset)
├── releases/ 每次构建一个带时间戳的目录
├── bin/ hugo 二进制(锁定版本,不跟随包管理器)
├── scripts/ 构建与部署脚本
├── deploy/ systemd 单元等部署配置
├── logs/ 部署与自动部署日志
└── current -> releases/<时间戳> 符号链接,网站根目录指向它部署脚本的语义固定为六步:拉取内容 → 构建到新的 releases/<时间戳> → 校验关键产物非空 → 原子替换 current 符号链接 → 配置有变化则同步并让容器 reload → 只保留最近 5 份。
这样做的意义是"发不出去"不等于"站挂了":构建失败或校验不通过时 current 不动,线上继续跑上一版。
服务容器是 nginx:alpine,只接内网(不映射公网端口),由反向代理转发,前面挂 CDN。回滚有两档:
- 回上一版产物:把
current指回上一个 release 目录(nginx 每个请求都会重新解析符号链接,不需要重启)。 - 回旧平台:把反向代理的转发目标改回 CMS 容器,再把它启动起来(容器与数据一直保留,切换前的数据库行与生成的配置文件都有备份)。
Hugo 的版本是硬约束:主题声明了兼容窗口(extended 0.163–0.166),所以本地二进制、服务器上的二进制、主题三者必须一起动,不追新。
自动部署用 systemd timer 每 5 分钟检查一次远端仓库:有新提交就跑部署脚本,没有就静默退出(不写日志,避免日志噪音),失败时发一条即时通知。两个实测细节:timer 的多时刻要拆成多行 OnCalendar= 写(单行逗号串或 *:0/5 这类重复语法会解析失败或算不出下一次排期);验收要看服务单元的启动时间戳是否往前推进,只看 list-timers 的"下次触发时间"不算数。
5. 十个坑(全部实测) #
| # | 现象 | 机制 | 处理 |
|---|---|---|---|
| 1 | 容器里所有页面 403,服务器上直接 curl 却正常 | 站点目录放在 /root 下,而 /root 是 0700,容器内的 nginx worker(非 root)穿不进去;301 之所以正常,是因为它在 return 阶段就返回了,根本不读文件 |
站点目录移到 /srv 下 |
| 2 | 切换版本后容器还在发旧内容 | bind mount 在容器创建时解析一次符号链接,之后换链接容器仍指旧目录 | 容器挂载父目录,nginx 里 root 指向 .../current |
| 3 | 服务器上 Permission denied,脚本跑不起来 |
Windows 提交时丢了可执行位(100755 → 100644) | git update-index --chmod=+x <脚本> 后重新提交 |
| 4 | 从本机 ssh 推私有仓超时 | 自建 Git 服务的 SSH 端口从公网不可达(CDN 与反代只放 443);本机代理的"端口探测"会假报开放 | 推送走 HTTPS + 访问令牌(令牌存系统凭据管理器,不进日志) |
| 5 | 改了反向代理的转发目标,流量却没切过去 | 反向代理的转发目标同时存在于数据库和生成的 vhost 文件里,改数据库不会重写文件 | 两处一起改,nginx -t 通过后 reload;改前先备份 |
| 6 | CDN 的缓存清除 API 报 401 | 清除用的密钥不在证书配置的 JSON 字段里,而是嵌在一段说明文本中 | 从文本里取出后单独保存;清缓存是逐 URL 调用,主机名级别不可用(返回 500) |
| 7 | 改了主题/升了引擎版本的某天构建突然崩 | 主题声明的 Hugo 兼容窗口是滚动的,越界即失败 | 三处(本地、服务器、主题)一起动,并锁定具体版本 |
| 8 | 一篇 2300 汉字的文章显示"302 字 · 2 分钟" | 引擎默认按空格切词统计字数与阅读时间,中文没有空格 | 打开 CJK 语言开关后实测变为"3817 字 · 8 分钟"(含代码与表格字符) |
| 9 | 站点模板里写的新样式不生效 | 主题的样式表是作者预编译后提交的,只包含主题自己用过的类,新类名根本不存在于产物里 | 新样式写进站点自己的 CSS 文件(主题会自动加载),用自定义类名 |
| 10 | 301 跳转把访客降级回 http | 源站在反向代理后面,看到的协议是 http | 301 目标显式写 https://$host/... |
四、验证 #
上线后的核对清单(全部实测):
| 检查项 | 结果 |
|---|---|
| 三篇文章 + 首页 + 关于页 | 全 200 |
| 附件直链 | 200,路径与迁移前逐字节一致 |
旧地址(/rss/、/tag/*、/author/*、占位帖、历史 slug) |
全部 301 且指向 https |
线上 RSS 的 <guid> |
与迁移前逐条一致 |
| 首页是否残留旧引擎痕迹(关键字扫描) | 0 处 |
| 容器状态 | 静态站容器运行中,4.9 MiB;旧容器已停但保留(回滚用) |
| 首页联网传输 | 63.7 KB(迁移前 1008 KB) |
两件必须做的事:
回读断言。 “推送成功"不等于"已上线”。构建静默崩溃、缓存没清、权限位丢了,都能让推送成功而页面不变;部署脚本的退出码看不出这些。所以每次发布后都要回读一次目标 URL,确认新内容真的可见——这是静态站最典型的失败模式。
CDN 缓存清除。 前置 CDN 会缓存 HTML,改版后不清缓存读者还会看到旧页。清除接口按 URL 逐个调用,切换当天把那十几条地址(含已废弃的旧地址)全部清了一遍。
五、留给后来者的清单 #
- 迁移前先量数据:镜像、内存、页面全资源体积都实测再拍板;动机锚定"内容主权 + 发布链路",别为省内存带宽而迁。
- 内容源取数据库的 HTML 列,别用导出 JSON;先查有没有站点地址占位符。
- URL 保平做成清单:文章用永久链接配置对齐、标签页写
url:而不是slug:、其余用 nginx 301 收口,目标写死 https。 - RSS 的
<guid>沿用旧值,否则老订阅者重收旧文;XML 声明用模板函数输出。 - 部署用"版本化目录 + 符号链接原子切换 + 产物校验",构建失败不动线上;只留最近几份。
- 站点目录别放
/root(0700 会让容器里的 worker 全站 403);容器别直接挂符号链接,挂父目录。 - 附件进版本库前先算体积:几个小脚本可以忽略;将来若要放二进制包(压缩包/可执行文件),考虑对象存储或另建仓库,别让 git 历史无限长。
- Windows 下提交任何脚本前,先确认执行位没有丢。
- 发布闭环固定为:构建 → 部署 → 清缓存 → 回读断言,缺一步就可能误报成功。
- 动手前先把回滚路径写下来(旧容器与数据留几周),改反向代理前备份数据库行与生成的配置文件。
六、碎碎念 #
这次迁移之所以能在一天内做完,是因为站点足够小:4 篇文章、12 条 URL。规模再大一点,任何"迁移"都会变成一场项目。过程中真正花时间的不是搬家本身,而是那十个坑——它们几乎都跟"两层抽象叠在一起"有关:容器里看权限、容器外看符号链接、反代后面看协议、CDN 前面看缓存、主题里面看样式表产物。排错时先把这条链拆开,逐层确认身份,比猜配置快得多。
另一件值得记住的事:旧平台上那行无条件加载 764 KB 脚本的代码,算下来比换成静态站带来的服务端收益还大。主题里一行代码的技术债,可能比平台选择本身更值钱——迁移前后都值得先把这行找出来。