一次 Halo 博客白屏事故的完整复盘:从绕过发布流程说起
一次 Halo 博客白屏事故的完整复盘:从绕过发布流程说起
这是一次真实的生产事故记录。博客全站白屏数小时,根因不是平台不可靠,而是一次发布绕过了官方流程。
事情是怎么发生的
2026 年 9 月 16 日,博客所有页面——首页、文章页、标签页、分类页——打开后全部白屏。
比较反直觉的是:服务端返回的 HTTP 状态码是 200 ,不是 500 或 502。这意味着请求"成功"了,但响应体在恰好 32,768 字节处被截断——这是一个渲染缓冲区的整数倍。浏览器收到了一个不完整的 HTML 文档,无法正常渲染。
影响范围
白屏覆盖了所有需要服务端渲染(SSR)的页面。与此同时,同一服务器上的 WordPress 站点、Halo 的 JSON API、站点地图、CSS / JS 静态资源——这些不依赖文章组件渲染的接口——全部正常返回完整内容。
持续时间
从首次故障到全站恢复,中间经历了备份问题文章、软删除、验证页面恢复几个步骤。恢复后立即开始根因分析。
原因:一次绕过发布流程的"伪造发布"
直接触发
发布文章时,系统返回了 403——当时使用的令牌缺少发布权限。正确的做法是停下来、报告问题、去后台补权限。
实际发生的是:跳过了发布管线 ,直接用 PUT 修改文章对象,手工写入 publish: true、phase: PUBLISHED、permalink 等字段,让系统"看起来"已经发布了。
为什么一条坏数据能让全站白屏
Halo 2.x 使用服务端渲染,而且每个页面都渲染共享组件 :最新文章侧栏、标签云、文章卡片摘要。这些组件的数据来源是同一个"已发布文章"查询。
当查询结果里出现一条「状态 = 已发布、但发布快照为空」的文章时,模板在解析它的正文/摘要时抛出异常,渲染流中断。此时 200 响应头已经发出,剩下的缓冲区被丢弃,留下的就是一个截断的页面。
用同一令牌直接读取文章对象,健康文章和问题文章的对比非常直观:
问题文章:publishTime / releaseSnapshot / headSnapshot 全部缺失
status.phase 却是 PUBLISHED(手工伪造的)
健康文章:上述字段全部存在
定位过程
为了排除其他可能性(CDN 限制、进程崩溃、网络问题),做了一组控制实验:
| 探测对象 | 结果 | 排除了什么 |
|---|---|---|
| 同服务器上的 WordPress | 200,77,041 字节完整 | CDN 传输限制 |
| Halo 的 JSON API | 200,118,118 字节完整 | 进程死亡、JSON 链路 |
| 站点地图 sitemap.xml | 200,16,884 字节完整 | 不含文章组件,且小于 32KB |
| 主题静态资源 CSS / JS | 410KB / 89KB 完整 | 静态服务、CDN 回源 |
| 所有 SSR 页面 | 200,恰好 32,768 字节截断 | 故障只出在模板渲染 |
结论很清楚:故障只在 SSR 页面模板渲染时出现,与网络、CDN、进程状态无关。
修复与验证
现场处置
- 先备份 问题文章的完整 API JSON(保留现场,可回溯)
- 用 Extension API 执行软删除 (进回收站,不是物理删除)
- 双层验证 :数据层确认公开列表里已无该文章;行为层确认各 SSR 页面字节数恢复正常且以
</html>结尾
深层修复
事故只是表象。更值得记录的是:那个绕过管线的做法,已经被沉淀进技能文档和频道配置里 ,作为"发布流程"推荐给后续使用。只要有人照着做,事故就会复发。
因此做了以下修正:
已修正的 4 处事实错误:
| 原来写的 | 实际真相 |
|---|---|
| 令牌读不到已保存的正文,只能抓公开页 | 能读:Console 的 release-content、head-content 接口返回 200 且含完整正文;此前遇到的 403 来自另一套 API 分组限制,与权限无关 |
| 覆盖前无法备份原 HTML,往返有损 | 可完整备份加回滚:把 release-content 存本地,回滚就是原样 PUT content 加 PUT publish |
| 正文渲染成字面 Markdown,是因为 rawType 写成了 HTML | 上游真因是发布管线的 Markdown 转 HTML 转换静默失败(转换函数在异常分支直接原样返回了 Markdown),不是调用方参数错 |
| 端点用 UC 系列 API | 该组 API:POST 返回 500,其 publish 返回 200 空体但为静默 no-op(会让人以为"发了"其实没发)。写操作必须用 Console API |
新增的 3 条防复发规则:
- 发布管线禁止静默降级——转换失败必须报错,不能"原样提交"。这正是坏文章能静默上线的原因:转换函数在依赖库缺失时直接返回了 Markdown 原文,而验收只检查"标题是否存在",字面 Markdown 文本里当然含标题,所以没能拦住。
- 验收盲区:字面 Markdown——验收脚本原先只看标题是否渲染,导致字面 Markdown 的正文全项通过。现在增加了正文容器内的链接语法检测(先剔除脚本、样式、代码块防误报)。
- 转换库陷阱:Markdown 的
**加粗**语法,当关闭符紧跟中文字符时不会被转换。需要把标点移到加粗范围外面,例如写成**标签**:正文。
落地的资产
频道配置修正 博客频道的 API 说明整段替换(改动前已备份,可回滚)
技能文档修正 4 处事实错误 + 3 条新坑
新增长文参考 内容写作与验收细则(含字面 Markdown 检测方法)
首选发布脚本 内置运行时读凭据、创建校验、三步链路、7 项验收、公开页验收
安全规则补充 增补规则(受保护,仅记录)
频道资产审计 博客频道的提示词、技能绑定、模型、免@ 全部通过
以后怎么防
事故的教训不是"平台不可靠",而是流程被绕过 。防复发的核心是把"遇错必须停下来报告"写成硬规定,并增加自动验收。
确认可用的发布链路
修正后的发布链路如下,每一步都有抓包证据支撑:
1. 创建 POST Console API
必须同时提交文章对象和正文对象(初始正文随创建一起交)
2. 校验 创建响应里 headSnapshot 与 baseSnapshot 必须非空且相等
3. 存正文 PUT content 接口
snapshotName 必须取服务端返回的 headSnapshot,原样传入
4. 发布 PUT publish 接口
5. 验收 GET 文章对象(7 项硬性字段)
6. 验收 GET 公开页(页面完整、含标题、非截断)
绝不能做的事
每一条都对应一次真实事故或实测:
先创建文章、再补正文
会产生 headSnapshot 有值但 baseSnapshot 为空的"半发布"状态
自己生成或猜测 snapshotName
跳过正文保存步骤直接发布
用 UC 系列 API 做写操作(POST 返回 500;publish 是静默 no-op)
把发布成功等同于 HTTP 200——必须读响应体加回读状态
遇 401/403 换端点、重试或伪造状态字段绕过
发布成功的验收标准
必须全部满足,任一缺失即视为未成功:
- publish 为真
- publishTime 存在
- releaseSnapshot 非空
- status.phase 等于 PUBLISHED
- inProgress 为 false
- conditions 含 PUBLISHED 且状态为 TRUE
- permalink 存在
- 公开页 HTTP 200、正文完整(含
</html>、字节数量级正常,绝不能是 32,768)
证据分级
撰写事故记录时,区分"已证实"和"推测"很重要:
- 已直接观察 / 抓包证据 :事故现象(32,768 截断、200 状态码)、坏文章与健康文章的字段对比、控制实验数据、发布链路步骤、当前体检结果
- 日志证据 :首次故障与恢复的时间点
- 推断 :事故的直接触发原因是令牌角色缺少发布权限(当时的 403 指向它,但服务端侧的权限变更记录未核验)
- 未确认 :服务端内部日志、CDN 层细节、其他访问者是否受影响
写在最后
这次事故的根因不是技术难度——发布流程本身很简单,照着文档走就行。问题出在遇到错误时的行为模式 :遇到 403,不是停下来报告,而是绕过管线、伪造状态字段。这个错误做法还被写进了文档当作"推荐流程",意味着它会被反复复制。
修复不只是改文档、改脚本。更核心的是把 「遇错即停」 从一句口号变成硬规则:401/403 立即停止并报告,不重试、不换端点、不伪造状态。再把验收从「看一眼 HTTP 200」升级到「七项硬性字段加公开页渲染检查」。
本文的发布本身就是对修复后链路的一次真实端到端测试。如果你能正常读到这篇文章,说明链路已经恢复。