Knighthana
文章114
标签154
分类7

文章归档

博客构建与部署时间优化记录

博客构建与部署时间优化记录

博客构建与部署时间优化记录

博客构建链路改造记录把”构建”搬进 GitHub Actions 之后,构建环节又花了两天优化时间消耗。这篇文章记录三个优化点、一个踩坑、两个番外排查(npm ci 的 audit 之谜、pandoc 的 dpkg 扫描税)、以及若干”时势决策”,附上实测数据。

背景:从日志里发现的问题

某次 CI 构建的 Generate site 步骤日志暴露了三个现象:

1
2
3
INFO  Files loaded in 9.85 s
INFO 458 files generated in 1.38 s
Inferno is in development mode.
  • 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
2
3
4
5
6
- name: Cache hexo db
uses: actions/cache@v6
with:
path: builder/db.json
key: hexo-db-${{ hashFiles('builder/package-lock.json','builder/_config.yml','builder/_config.nexmoe.yml','builder/patches/**','builder/scripts/**') }}
restore-keys: hexo-db-

关键点是 cache key 不包含 raw/ 内容:绑定依赖、配置和补丁,但刻意不绑文章内容。这样只改一篇文章时缓存仍然命中,hexo 只重渲染这一篇,而不是重新全量。

优化二:MathJax 按需渲染

_config.yml 里原本 every_page: true——每页都过 MathJax。改成 false 之后,没有显式 mathjax: true 的页面不会触发渲染,但手写 front-matter 标记太麻烦,于是写了一个自动检测脚本:

1
2
// builder/scripts/mathjax-autodetect.js
// 检测渲染后的内容是否含行内/块级公式分隔符,是则启用 MathJax

脚本在 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
2
3
4
pandoc:
args:
--mathjax
--wrap=none

验证:产物中 fancybox 属性计数与改造前完全一致。

优化三:消除 Inferno 开发模式提示

Inferno is in development mode. 是 Inferno 检测到 NODE_ENV 未设置时的提示。给 Generate site 步骤加上环境变量即可消除:

1
2
3
4
5
- name: Generate site
working-directory: builder
env:
NODE_ENV: production
run: npx hexo generate

番外: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
2
3
Cache hit for: node-cache-Linux-x64-npm-72cbfb...
Cache Size: ~35 MB (36697778 B)
Cache restored successfully

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
2
3
- name: Install dependencies
working-directory: builder
run: npm ci --registry=https://registry.npmjs.org --no-audit

实测:Install dependencies 从 171s 降到 9s,build job 稳定在 ~30s 级。漏洞扫描由 Dependabot 持续保障,关闭 audit 没有安全损失。

这次排查的教训

  1. 先看日志再下结论:第一次怀疑”版本漂移”时,没有先验证”缓存是否真的被重新下载”,而是直接改配置。真正定位靠的是 --loglevel=http 的一行输出。
  2. 用反证排除变量:钉死后仍慢 + 同版本两次 run 结果差 8 倍,两刀切掉了”工具链版本”这个假设。
  3. 本地 + 在线实验互补:本地证明”安装本体很快、audit 很慢”,在线证明”CI 上唯一的网络请求就是 audit”,两条证据链闭环。

番外二:pandoc 安装的 12 秒税

npm ci 修好后,build job 里还有一个固定开销:Install pandoc 步骤要 14s。看日志找到了原因。

现象:dpkg 在扫 20 万条文件账本

1
2
3
Selecting previously unselected package pandoc.
(Reading database ... 201676 files and directories currently installed.) ← 卡了 12.25s
Preparing to unpack /tmp/pandoc.deb ...

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
2
3
4
5
6
7
8
9
10
11
12
13
- name: Install pandoc
run: |
if [ ! -f /tmp/pandoc.deb ]; then
curl -fsSL -o /tmp/pandoc.deb \
https://github.com/jgm/pandoc/releases/download/3.10.1/pandoc-3.10.1-1-amd64.deb
fi
mkdir -p "$HOME/.cache/pandoc"
dpkg-deb -x /tmp/pandoc.deb "$HOME/.cache/pandoc"
mkdir -p "$HOME/.local/bin"
install -m 755 "$HOME/.cache/pandoc/usr/bin/pandoc" "$HOME/.local/bin/pandoc"
rm -rf "$HOME/.cache/pandoc"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/pandoc" --version | head -1

要点:

  • 目录组织按 XDG~/.cache/pandoc 是临时解压(可再生,用完即删);~/.local/bin/pandoc 是二进制本体(“你的命令”)。不碰 /usr/bin,不需要 sudo
  • $GITHUB_PATHprepend(加到 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