博客构建与部署时间优化记录
博客构建与部署时间优化记录
在博客构建链路改造记录把”构建”搬进 GitHub Actions 之后,构建环节又花了两天优化时间消耗。这篇文章记录三个优化点、一个踩坑、两个番外排查(npm ci 的 audit 之谜、pandoc 的 dpkg 扫描税)、以及若干”时势决策”,附上实测数据。
背景:从日志里发现的问题
某次 CI 构建的 Generate site 步骤日志暴露了三个现象:
1 | INFO Files loaded in 9.85 s |
- Files loaded 9.85s:112 篇文章全量处理,占总耗时大头
- Inferno is in development mode:主题前端框架的提示噪音
- 全站每篇文章都过了一遍 MathJax 服务端渲染(
every_page: true),大量不含公式的文章白白消耗
针对这三点的优化依次落地。
优化一:CI 缓存 hexo 数据库,启用增量生成
hexo 的 db.json 记录了每篇文章的处理结果与文件 mtime,配合 updated_option: mtime 就能做增量:文件没变就不重新渲染。之前 CI 每次 npm ci 都是全新工作区,没有 db.json,于是每次都是全量。
仓库里已有的 restore-mtime 步骤(用 git 提交时间恢复文件 mtime)为增量提供了前提——只要 db.json 在,hexo 就能判断哪些文章变过。
在 workflow 里加了缓存步骤:
1 | - name: Cache hexo db |
关键点是 cache key 不包含 raw/ 内容:绑定依赖、配置和补丁,但刻意不绑文章内容。这样只改一篇文章时缓存仍然命中,hexo 只重渲染这一篇,而不是重新全量。
优化二:MathJax 按需渲染
_config.yml 里原本 every_page: true——每页都过 MathJax。改成 false 之后,没有显式 mathjax: true 的页面不会触发渲染,但手写 front-matter 标记太麻烦,于是写了一个自动检测脚本:
1 | // builder/scripts/mathjax-autodetect.js |
脚本在 after_post_render 阶段扫描渲染结果,发现行内/块级公式标记就自动开启 MathJax;如果文章显式写了 mathjax: false,则尊重作者意图不覆盖。
效果:112 篇文章里真正含公式的只有 9 篇需要 MathJax,其余 103 篇直接跳过,省掉了大部分渲染成本。
踩坑:pandoc 换行把图片属性拆断了
MathJax 关掉后,本地对比构建产物时发现图片的 fancybox 属性丢失。排查半天,根因在 pandoc 的默认换行行为:
- 主题的
image_filter.js用单行正则匹配<img src="..." alt="..."> - pandoc 默认在 72 列处换行,会把
<img src="...">和alt="..."拆到两行 - 正则匹配不到,图片就丢了 fancybox 的增强属性
- 之前
every_page: true时 MathJax 的 DOM 往返会重新序列化、把标签拼回一行,掩盖了这个问题;关掉之后暴露出来
修复:给 pandoc 加 --wrap=none,保持单行输出:
1 | pandoc: |
验证:产物中 fancybox 属性计数与改造前完全一致。
优化三:消除 Inferno 开发模式提示
Inferno is in development mode. 是 Inferno 检测到 NODE_ENV 未设置时的提示。给 Generate site 步骤加上环境变量即可消除:
1 | - name: Generate site |
番外:npm ci 的 5 分钟之谜
前三项优化落地后的第二次 push,build job 从 37s 又变成了 5m35s——npm ci 一个步骤就花了 5 分钟。这篇文章补一段完整的排查过程。
现象与第一次怀疑
对比两次 run:
| 9/3(快) | 9/4(慢) | |
|---|---|---|
| npm ci 耗时 | 7s | 305s |
| Node / npm | v24.19.0 / npm 11.17.0 | v24.20.0 / npm 11.19.0 |
| runner | centralus | westus3 |
但两份日志的缓存输出完全一样:
1 | Cache hit for: node-cache-Linux-x64-npm-72cbfb... |
cache: npm 没有 miss,lockfile 没变,36.7MB 的 ~/.npm 恢复成功。
当时的第一次怀疑:npm 版本漂移。workflow 写的是 node-version: 24,setup-node 每次都拿最新 24.x;而 setup-node 的 npm cache key 不含 Node/npm 版本,旧缓存被新 npm 消化后,npm ci 就变慢了。于是把 Node 钉死:
- 新增
.node-version(内容24.20.0) - Setup Node 改用
node-version-file: '.node-version'
这个改动本身值得保留:setup-node 的缓存 key 确实不含 Node 版本,将来 npm 大版本升级时旧缓存大概率失效,钉版本能消除这个未来隐患。
反证:版本不是根因
钉死 24.20.0 后的下一次 run,npm ci 依然 37s,而不是回到 7s。更关键的是,9/4 当天两次 run 都是 v24.20.0 / npm 11.19.0:
- 01:15 的 run:305s
- 02:45 的 run:37s
同版本工具链,结果差 8 倍——如果版本漂移是根因,钉死后应该稳定回到秒级。版本假设被推翻。
实验定位:–loglevel=http
npm 的 --loglevel=http 会打印每个 HTTP 请求(URL/状态/耗时),能区分”本地缓存读取”和”真实网络回源”。
本地实验(node 26.4.0 / npm 11.18.0,本地缓存命中):
| 命令 | 耗时 | HTTP 请求 |
|---|---|---|
npm ci(默认带 audit) |
79.9s | 3 个,其中 audit 单个请求占 76.7s |
npm ci --no-audit |
15.1s | 0 个 |
npm ci --no-audit --registry=npmjs.org |
16.0s | 0 个 |
再单独测 audit 端点延迟:registry.npmjs.org 的 audit 端点 60.8s 超时,而 registry.npmmirror.com 只要 0.3s。
在线实验(CI 上给 npm ci 加 --loglevel=http 跑一次):
- 462 个包 tarball 全部
cache hit(0ms 本地读取,零回源下载) - 唯一的网络请求:audit 端点
POST .../security/advisories/bulk,耗时 169.5s - npm ci 总耗时 171s ≈ 169.5s(audit)+ 1.5s(本地安装)
结论
npm ci 的耗时几乎全部来自 npm audit 对 registry.npmjs.org 的单次网络请求。 缓存命中时安装本体只需 ~2s(CI)/ 15s(本地);37s / 171s / 305s 的波动,全是同一次 audit POST 在不同 runner、不同时刻的网络抖动。
修复很简单——关闭 audit:
1 | - name: Install dependencies |
实测:Install dependencies 从 171s 降到 9s,build job 稳定在 ~30s 级。漏洞扫描由 Dependabot 持续保障,关闭 audit 没有安全损失。
这次排查的教训
- 先看日志再下结论:第一次怀疑”版本漂移”时,没有先验证”缓存是否真的被重新下载”,而是直接改配置。真正定位靠的是
--loglevel=http的一行输出。 - 用反证排除变量:钉死后仍慢 + 同版本两次 run 结果差 8 倍,两刀切掉了”工具链版本”这个假设。
- 本地 + 在线实验互补:本地证明”安装本体很快、audit 很慢”,在线证明”CI 上唯一的网络请求就是 audit”,两条证据链闭环。
番外二:pandoc 安装的 12 秒税
npm ci 修好后,build job 里还有一个固定开销:Install pandoc 步骤要 14s。看日志找到了原因。
现象:dpkg 在扫 20 万条文件账本
1 | Selecting previously unselected package pandoc. |
sudo dpkg -i 的 14s 里,12.25s 花在 Reading database ... 201676 files——dpkg 在扫描 runner 镜像的完整文件数据库。GitHub 的 ubuntu-24.04 镜像预装了 Node/Python/Java/构建工具/浏览器等一大堆与本站无关的软件(几十万条文件记录),dpkg -i 要给这个账本付税。
关键认知:hosted runner 是”用完即销毁的编译机快照”,不是长寿命服务器。 包管理器在服务器上的价值(升级、回滚、补丁记录、依赖解算)在 CI 上几乎不存在;这台机器真正需要的是”版本钉死 + 每次构建用同一份二进制”。严谨性应该体现在 cache key 和固定 URL 上,而不是向一个 10 分钟后蒸发掉的系统账本登记。
pandoc 官方 amd64 .deb 恰好是自包含发行物(依赖仅 libc6/libgmp10/zlib1g,全是系统基础库),解压即可运行,不需要 dpkg 的依赖解算。
改动:dpkg-deb -x 解压 + 放到 ~/.local/bin
1 | - name: Install pandoc |
要点:
- 目录组织按 XDG:
~/.cache/pandoc是临时解压(可再生,用完即删);~/.local/bin/pandoc是二进制本体(“你的命令”)。不碰/usr、/bin,不需要 sudo $GITHUB_PATH是 prepend(加到 PATH 最前),即使 runner 镜像自带 pandoc,我们的版本也优先- hexo-renderer-pandoc 通过
spawnSync('pandoc')走 PATH 解析,天然命中
本地实验验证
在本地完整复现(下载 3.10.1 的 deb → 解压 → 软链/install → prepend PATH):
- 解压出的二进制可独立运行,
pandoc --version输出 3.10.1(本地系统版是 3.1.11.1,版本不同容易区分) - prepend PATH 后
spawnSync('pandoc')命中 3.10.1,与 hexo 渲染器机制一致 hexo generate全程无报错- 实验后清理干净:删解压目录、删软链、恢复系统 pandoc 3.1.11.1
本地实验暴露一个关键差异:本地 shell 的 ~/.local/bin 在 PATH 中排在 /usr/bin 之后,裸命令 pandoc 仍会命中系统版;而 CI 的 $GITHUB_PATH 是 prepend,必然命中我们的版本。所以”本地裸命令没生效”不代表 CI 会失败——实验要在与 CI 相同的 PATH 语义下验证。
踩坑:dpkg-deb -x 不会自动创建父目录
第一次 push 直接失败:
1 | dpkg-deb (subprocess): failed to create directory: No such file or directory |
原因:dpkg-deb -x 不会自动创建目标目录的父级,而 runner 的 $HOME/.cache 默认不存在(本地有 .cache 所以没暴露)。修复:先 mkdir -p "$HOME/.cache/pandoc"。
教训:本地能跑 ≠ 远程能跑,runner 镜像的”干净程度”和本地不完全一样。 这类环境差异只能靠真实 CI 日志暴露。
效果
| 项 | 之前 | 之后 |
|---|---|---|
| Install pandoc 耗时 | 14s(dpkg 扫 20 万文件账本) | 0.5s |
| 权限 | sudo | 无 sudo |
| 写入位置 | /usr/bin(系统目录) |
~/.local/bin(XDG) |
| pandoc 版本 | 3.10.1(dpkg 登记) | 3.10.1(install 放置) |
讨论过的替代方案:为什么不直接 cache 二进制
曾考虑直接 cache ~/.local/bin/pandoc(省掉恢复 .deb + 解压),但数据否掉了它:
.deb是压缩包:163MB 二进制压成 34MB(压缩率 ~21%)- actions/cache 恢复带宽实测 ~29MB/s:恢复 34MB 约 1.2s,恢复 163MB 要约 5.6s
- 直接 cache 二进制 = 把压缩解开再传输,恢复时间反而多 3s+,是负优化
保持 cache .deb + dpkg-deb -x 的结构不变。
时势决策(适用边界与变更条件)
这几轮优化里做的几个”现在看合理、将来可能要变”的决策,记录如下,附上”什么情况需要重新评估”。
1. 漏洞扫描交给 Dependabot
npm ci --no-audit 关掉了 CI 里的即时 audit(否则每次构建都要付一次慢速网络请求)。安全扫描由 Dependabot 持续保障(它直接读 lockfile,开 PR 提示漏洞)。
- 前提:Dependabot 已开启且保持活跃
- 何时重新评估:Dependabot 被关闭;或想在任何一次构建都拿到即时审计结果(可以恢复 audit,代价是接受 30s~5min 的网络波动)
2. 强制使用自己的 pandoc,不依赖 runner 镜像
通过 ~/.local/bin prepend 强制用我们钉死的 3.10.1,即使 runner 镜像未来自带 pandoc 也覆盖它。
- 好处:版本确定、构建可复现,不受镜像软件列表漂移影响
- 代价:pandoc 的安装/升级由自己维护(改 cache key 里的版本号即可)
- 何时重新评估:如果未来 runner 镜像自带的 pandoc 版本正好合适,可以简化掉安装步骤;但”版本钉死”这个原则仍建议保留
3. Node 钉死 24.20.0
.node-version + node-version-file,不再用浮动的 node-version: 24。原因:setup-node 的 npm cache key 不含 Node/npm 版本,工具链漂移会让旧缓存被新 npm 消化。
- 何时重新评估:Node 大版本升级(改
.node-version一行 + 接受第一次 cache miss 稍慢)
4. 不缓存 node_modules,只用 npm cache
依赖的”传输缓存”(~/.npm)足够,刻意不缓存 node_modules 本体——避免跨构建的不可复现问题(缓存损坏、平台差异、patch 状态污染)。
- 何时重新评估:如果 npm ci 的 reify 时间明显变长(届时优先排查其他原因,而不是直接加 node_modules 缓存)
5. cache .deb 而非二进制
见番外二的数据:压缩率 21% 使 .deb 的恢复带宽成本远低于二进制。
- 何时重新评估:GitHub cache 的恢复带宽/配额策略变化,或 deb 体积急剧膨胀
6. 工具链尽量放用户目录
pandoc 放 ~/.local/bin、临时解压放 ~/.cache,不写 /usr、/bin,不需要 sudo。符合 XDG 目录规范,也避免与镜像预装软件打架。
- 何时重新评估:无——这条是纯收益,保持即可
实测数据
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 本地全量 Files loaded | 16-18s | ~12s |
| 本地增量 Files loaded(db 命中) | — | 1.21s |
| CI Files loaded | 9.85s | 4.86s(cache miss 全量) |
| CI 458 files generated | 1.38s | 0.93s |
| CI Generate site 步骤 | ~12s | ~6s |
| CI npm ci(Install dependencies) | 7s~305s(audit 网络波动) | 9s(–no-audit 后稳定) |
| CI Install pandoc | 14s(dpkg 扫 20 万文件账本) | 0.5s(dpkg-deb -x 解压) |
| 单篇改动增量重渲染 | 全量 | +0.67s,只重渲染该篇 |
说明:上面 CI 的 4.86s 是首次跑(cache miss,仍为全量构建,但已享受 MathJax 按需渲染的收益);后续 db.json 缓存命中后 Files loaded 会进一步降到 1-2s 量级。
正确性验证
- 数学文章集合与改造前完全一致(9 篇含 SVG 渲染,其余无 mjx-container)
- 图片 fancybox / onerror / lazy 属性计数一致(2106 img 中 36 个带 fancybox,与改造前相同)
- 产物语义对比 368 个文件无功能差异
遗留观察
- babel 弃用警告:日志里仍有
Usage of builders starting with an uppercase letter such as 'ObjectExpression' has been deprecated。来自主题 JSX 编译链路babel-plugin-inferno调用了@babel/types的旧式大写 builder 名。仅警告、不影响产物,属于 node_modules 内部问题,暂不处理。 - 同日文章排序波动:归档/分类/atom 中,日期完全相同的文章相对顺序在全量重建时可能互换(hexo 同日排序不稳定)。与本次改动无关,属既有行为。
?v=时间戳:主题给资源 URL 打毫秒级时间戳做缓存失效,导致每次 generate 都会重写引用这些资源的页面。CI 上无影响,未纳入改动。
Knighthana & DeepSeek
2026/09/04