这个站从零到现在六十来个提交。部署本身只花了一下午,剩下的时间全在填坑。
这一路不算绕:Gitee Pages 还没用上就停了,我转去 GitHub Pages;ICP备案下来后,又把站搬到阿里云 ECS。前半篇是从无到有时踩过的坑,后半篇接着写迁移和发版。现在唯一维护的生产站是 ECS 上的 doyouhang.live。
为什么换平台
本来打算用 Gitee Pages,一搜才发现它已于 2025 年 6 月正式下线,官方入口都撤了。只好转去 GitHub Pages。
三步上线
- 用 Astro 写内容,Markdown 放在
src/content/下 npm run build生成静态文件到dist/- 推到 GitHub,在仓库的 Settings → Pages 里把 Source 选成 GitHub Actions
当时配好 .github/workflows/deploy.yml,每次 git push 到 main 都会自动构建上线。迁入 ECS 后,这个工作流已经删除,普通提交也不再更新生产。
当时先处理:项目页还是用户页
- 项目页:仓库名
blog-demo→ 访问https://<用户名>.github.io/blog-demo/,构建要带SITE_BASE=/blog-demo/ - 用户页:仓库名
<用户名>.github.io→ 直接访问根路径,SITE_BASE=/
当时的免费额度对个人博客够用:1GB 存储、每月 100GB 流量。绑自己的域名要在域名商配 CNAME,用 .github.io 子域名则可以直接访问。
GitHub Pages 很快就跑起来了,接下来开始填坑。
一、子路径会击穿一切硬编码的斜杠
项目页那个 /blog-demo/ 前缀是所有麻烦的开始。
冒烟测试里有一句「等 preview 起来」,做法是去请求 /。本地 SITE_BASE=/,一请求就通;CI 上站挂在 /blog-demo/ 下,请求 / 永远 404。结果是本地一直绿,CI 一直红,报错却是「preview 30 秒内没起来」,方向完全错了。
这里其实有两个检查:端口是否有人应答,以及网站能不能打开。前者拿到 404 也算成功,后者必须是 2xx。混用一个函数,路径问题就被报成了启动失败。
二、三种长得一模一样的抓取失败
股票页的行情是构建时抓的。抓不到时,undici 的 fetch 在 e.message 里只留下一句 fetch failed,真正的原因藏在 e.cause 链里。把整条链打出来,才看清几次失败根本不是一回事:
| 现象 | 真实原因 |
|---|---|
UND_ERR_SOCKET other side closed |
被限流。实测连续 171 秒不通,任何重试策略都跨不过去 |
HTTP 502 |
对方 WAF 挡了 GitHub runner 的整个出口 IP 段。同一个接口,我本地 200,runner 上恒 502 |
HTTP 200,body 是 [] |
接口静默死掉了。返回成功,就是没有数据 |
第三种最阴。它不报错、不超时,也不会触发重试,页面只是安静地变空。当时日志只在请求抛错时打印,所以这次什么都没留下。
限流连续 171 秒不恢复,再增加两三次重试也没意义。最后改成一次批量请求(ulist.np),绕开了被限流的分页接口。
三、抓不到数据不能让整站挂掉
行情抓取失败,博客、关于页和照片时间线没必要跟着下线。于是我把降级拆开:指数拿到了、板块没拿到,就只降板块那一块。
这里我又亲手造了一个 bug。降级时虽然沿用了上一次的数据,提示语里的 03:36:52 却取自这次构建。页面看着很认真,时间是假的。
后来给两块数据分别存了 indicesAt 和 breadthAt,页面显示哪一块,就读哪一块的时间,不再共用一个含糊的「更新于」。
还有一个更隐蔽的:占位数据被当成了历史。CI 每次都是全新 checkout,仓库里那份 source: 'sample' 的占位文件被「沿用上一次」的逻辑当成了真数据,于是这个降级永远自愈不了。
四、图片:防盗链、SSRF、和「支持」的两种含义
为了让编辑器能按书名检索封面,我先后撞上了防盗链、SSRF 和 HEIC 解码问题。
豆瓣的图片有防盗链,不带 Referer 直接返回 418。浏览器不能伪造跨站 Referer,只能让服务端取回来再转一手。
但 ?u=<任意 URL> 这样的转发接口就是一个 SSRF 洞。别人可以借它访问 127.0.0.1、内网地址或云厂商的元数据端点,这些地方浏览器够不到,服务器却够得到。最后加了域名白名单,并按 hostname 精确匹配。若用 includes('doubanio.com') 判断,img9.doubanio.com.evil.com 也会被放行。
另一个误判来自 sharp。它的 format 里有 heif,我便以为能解 HEIC,拿真文件一试却报 Support for this compression format has not been built in。预编译包认识容器格式,却没带 HEVC 解码插件。iPhone 默认可能拍出这种文件,编辑器现在会提示用户关闭相机里的高效率格式,不再把编解码器报错原样甩出来。
五、时间:构建机在 UTC
照片的拍摄时间存在 EXIF 里,但 EXIF 时间本身不带时区,偏移量另存在 OffsetTimeOriginal。手机通常会写,相机未必会写。不读这个字段,一张在日本拍的照片会整整偏一小时。
另一个问题更常见:GitHub runner 跑在 UTC。
new Date('2025-01-01T00:00:00+08:00').getFullYear()
// 本地(东八区)→ 2025
// runner(UTC) → 2024
年度归档页因此会把元旦那条记录扔进上一年。后来凡是从 Date 里取年月日或格式化日期,我都显式指定时区,不再靠机器环境。这个 bug 在我的电脑上怎么跑都复现不出来。
六、序列化必须是精确的逆运算
内容存成 Markdown,编辑器要读进来,改完再写回去。写入和读取必须互为逆运算,读取时也要按顺序单遍扫描。
我这里出过一次典型事故:写的时候把换行转义成 \n 两个字符、引号转义成 \";读的时候只还原了 \"。结果是:
- 多行文本每编辑保存一次,就多长出一层反斜杠
tags: ['a', 'b']被整个当成一个字符串,标签悄悄消失,没有报错
后来把解析挪到服务端,和写入放进同一个模块,再用往返测试锁住结果:写出来再读回来,必须和原文一样。这类 bug 每次只坏一点点,单看一次保存很难发现。
同一类问题还出现在两个地方:
- Markdown 会把单个换行并成空格。诗、清单和引文的断行得靠
white-space: pre-wrap保住。 reference()会在构建期检查引用。但如果删掉一篇被引用的长文,错误要到下次构建才冒出来,那时人早就离开编辑器了。现在删除会当场被拒绝,并点名是哪条内容仍在引用它。
七、删东西会留下坏账
编辑器加删除功能时,删文件很简单,清理关联资源才麻烦:
- 收藏条目的封面图和 md 在同一个目录,只删 md 就攒下一张没人引用的孤儿图
- 换封面不删旧图也一样,而且它们往往长得一模一样,事后根本分不清哪张还在用(真攒出过三张)
- 一条照片动态是一个目录,得整个删
还有一次是我自己挖的坑。我写了 readdirSync(dir).filter(f => f.endsWith('.jpg') && !f.startsWith('example')) 这样的「反向白名单」,一口气删掉三张真实封面。它只保护我事先知道的文件,剩下的全被当成垃圾。幸好 git 还能找回来。现在清理脚本只删本次明确创建的路径。
八、「看起来没问题」是最贵的
这一节的几个问题都没有报错,输出甚至看起来挺正常。
先是类名撞车。全局 CSS 里已经有一个 .spark,用在首页那排股票迷你柱上,带着 display: flex 和固定的 height: 44px。Astro 的作用域样式只是给选择器加属性,我没覆盖的规则照样从全局继承。结果新页面里的每一条都被压成 44px,多行文字直接叠到下一条上。页面返回 200,测试也是绿的。
截图也骗过我。页面的 .reveal 要等 IntersectionObserver 加类才显形,可 fullPage 截图不会让首屏外的元素真正进入视口,它们一直停在 opacity: 0。我看着截图以为布局塌了,量完才发现 scrollWidth 根本没溢出,白改了一轮。
还有一条新断言,被我放在目标元素数量为 0 的页面上。它一直是绿的,也一直什么都没测。现在写完断言,我会先把它放回有 bug 的版本,看看是不是真的会红。
测试内容也不能写死。「中文能搜到」原本要求必须命中 /blog/weekend/,那是脚手架自带的示例文章。文章一删,测试就红,搜索本身却好好的。后来查询词改成从站上现有内容里取,才不再跟着示例文章一起坏。
九、中文衬线字体:从 1.7MB 到 258KB,再到 150KB
站上的标题用衬线体。最初一次访问要下载 1808KB,其中字体独占 1692KB。英文站的一套 Latin 字体只有几十 KB,中文字体却要装下成千上万个字;而当时真正会在标题、引用和卷首里显示的字,不到八百个。
子集化不能简单地扫全站中文,要按实际使用衬线字体的位置收字:
// Markdown:标题、引用和 frontmatter title
/^#{1,3} /, /^> /, /^title: /
// Astro:h1-h3、blockquote,以及使用衬线字体的少量 prop
第一轮子集把 1.7MB 压到了约 258KB。后来又在当时的字表上比较了两个静态字重与一个变量字体:
| 方案 | 体积 |
|---|---|
| 两个静态子集(400 + 700) | 333KB |
| 一个变量字体(400–700 轴) | 150KB |
const buf = await subsetFont(await fs.readFile(base), text, {
targetFormat: 'woff2',
variationAxes: { wght: { min: 400, max: 700 } },
});
变量字体只保存一套轮廓和插值数据,两个静态字重会重复保存同一批字形。字体定义也能合成一个区间:
@font-face {
font-family: PaperSerif;
src: url(/fonts/paper-serif.woff2) format('woff2');
font-weight: 400 700;
font-display: swap;
}
中间还猜错过一次。我以为 OTF/CFF 的轮廓点更少,压成 woff2 后应该明显小于 TTF。实测是 OTF 每字 440B、TTF 每字 419B,只差 5%,方向还反了。这条猜想测完就扔了。
麻烦的是漏字不会报错。页面仍返回 200,控制台也很安静,只有某个字悄悄回退到系统字体。覆盖测试第一次就抓到了「月」:它不在模板里,是 cstYearMonth() 运行时拼出来的。于是字表除了 Markdown 和 Astro,还得收 TypeScript 字符串与常用量词。
150KB 只是当时内容规模下的一次测量。站点增加标题和引用,字体当然会继续长。这轮合并后的字表有 928 个字符,文件约 321KB,仍比 v1.0 仓库里的约 350KB 小一点,因为运维文档的标题不再塞进公开字体。
现在每次更新字表,我都会检查计算后的字体变量里有没有 PaperSerif、浏览器是否真的请求了 woff2,以及全部衬线文本是否被覆盖。文件变小只是第一步,漏字才是最容易溜过去的那个问题。
十、一个只在 CI 上出现的 bug
有一次本地完整流水线和视觉快照全绿,Actions 却倒在 Visual snapshots。我先怀疑 CI 的字体或抗锯齿不同,回头看命令才发现那一步用的是 --capture-only,只截图,根本不比较像素。
日志只有一句:
[visual] preview 没起来
Error: Process completed with exit code 1.
一张截图都没生成,浏览器还没启动,问题出在 preview。对比仓库里的两份启动代码,只差一个主机名:
// 冒烟测试:通过
const BASE = at(`http://localhost:${PORT}`);
// 视觉快照:CI 失败
const BASE = at(`http://127.0.0.1:${PORT}`);
astro preview 监听 localhost,最终绑定 IPv4 还是 IPv6 取决于机器怎么解析。我的电脑上 localhost 和 127.0.0.1 指向同一路径。CI 如果先把 localhost 解析为 ::1,写死 IPv4 就永远连不上。
主机名只是表面问题。视觉脚本还复制了一份较弱的 preview 起停逻辑:子进程输出被丢掉,提前退出没人检查,等待时间也和冒烟测试不同。最后把两处合并到 scripts/preview-server.mjs,主机名只写一次,同时保留输出并预检端口。子进程提前退出时,脚本会立刻打印真正的错误。
这次之后,我不再按步骤名称猜错误,也不再复制第二份 preview 启动代码。子进程输出照样保留,毕竟 CI 出问题时,手上通常只有那份日志。
十一、从 Pages 迁到 ECS,也把「提交」和「发布」分开
GitHub Pages 让我很快把网站挂了起来,也把项目路径、构建时行情抓取和「每次推送都部署」绑在了一起。ICP备案完成后,我把站迁到阿里云 ECS。HTTP 跳转到 HTTPS,证书交给 Let’s Encrypt 续期。
第一版生产配置很直接:博客 Nginx 容器占着 80/443。后来 ai.doyouhang.live 也要用 HTTPS,我把它的反代配置叠进了同一个容器。这个办法上线快,代价是两个仓库从此绑在一起。博客做一次完整发版,哪怕静态页面毫无问题,只要忘了带上私有配置,就会把 AI 的入口一起拆掉。
v1 收尾时还是把这层拆了。独立 Edge 容器负责 80/443、证书和域名路由;博客只是 Docker 网络里的 blog-web:80,AI 则继续走宿主机上的鉴权网关。以后博客发版只换静态后端,Edge 的容器 ID 不该变。验证也不能只看博客首页,还要顺手确认 AI 健康检查仍是 200、没带密钥的 API 仍是 401。
正式切流那晚,我先用旧镜像起了一个不占主机端口的临时后端,再把 80/443 从旧博客容器交给 Edge。Edge 健康后才换上 v1 的静态镜像。前后两次记录的 Edge ID 没变,博客、AI 和证书续期也都通过;旧目录和旧镜像没有删,真出问题时还能退回去。
收尾时还撞上一个容易误判的细节。GitHub runner 记录的镜像 ID 是归档里的 config digest,ECS 使用 containerd snapshotter,导入后 docker image inspect 显示的是另一种本机身份。两个字符串不一样,不等于镜像被改了。我最后核对的是归档 SHA-256、config digest、全部 RootFS 层和容器里的提交号。它们一一对应,才继续切流。
另一次检查把旧 Bearer token 带进了诊断输出。这个没法靠「日志应该没人看」糊弄过去。我当场换了 key,收紧了服务端配置权限,再分别确认旧值返回 401、新值能完成一次真实推理。凭据轮换是否完成,得看旧凭据确实失效。
正文仍在 Git 里,Astro 还是在构建期生成全部页面。服务器没有数据库,也没有为了「上云」硬加一套动态系统。博客和 Edge 都以只读方式运行,8080、18080 不对公网开放;上一份验收过的镜像留在同一台 ECS 上,需要时只回滚博客后端。
发版方式也跟着改了。普通 main 提交只跑轻量 CI,不再自动更新生产。一个版本可以在 prepare 阶段改上好几轮,等我明确同意后再冻结 SemVer 候选、开始全面复核。门禁通过后生成不可变镜像和 SHA-256 清单,最后才是人工部署、验收和发布 Release。
迁移方案、搭建记录、运维手册和版本说明继续留在仓库里,供以后复现和排障,不再各占一篇博客。这篇建站记录只留下背景、取舍和踩坑过程,以后也只更新这一篇。
最后
搬到服务器以后,我最怕的是把一个静态博客养成小型基础设施项目。好在内容层没变:没有数据库要照看,构建失败时线上继续停在上一版本。该拆开的入口拆开,该留在静态文件里的东西也没往服务端搬。
回头看,这些坑大多出现在构建时,或者我以为构建已经对了的时候。标签会静默消失,接口会返回空数组,布局能叠在一起,字体也能悄悄漏字;甚至还有一条从没测到目标元素的绿色断言。
以后我不会每修一个问题就拆一篇文章。等它能放回这条建站主线,也确实有点东西可讲,再补到这里。
到现在最管用的办法仍然很笨:别只看「好像没问题」,动手量一下。