一次 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、进程状态无关。

修复与验证

现场处置

  1. 先备份 问题文章的完整 API JSON(保留现场,可回溯)
  2. 用 Extension API 执行软删除 (进回收站,不是物理删除)
  3. 双层验证 :数据层确认公开列表里已无该文章;行为层确认各 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 条防复发规则:

  1. 发布管线禁止静默降级——转换失败必须报错,不能"原样提交"。这正是坏文章能静默上线的原因:转换函数在依赖库缺失时直接返回了 Markdown 原文,而验收只检查"标题是否存在",字面 Markdown 文本里当然含标题,所以没能拦住。
  2. 验收盲区:字面 Markdown——验收脚本原先只看标题是否渲染,导致字面 Markdown 的正文全项通过。现在增加了正文容器内的链接语法检测(先剔除脚本、样式、代码块防误报)。
  3. 转换库陷阱:Markdown 的 &#42;&#42;加粗&#42;&#42; 语法,当关闭符紧跟中文字符时不会被转换。需要把标点移到加粗范围外面,例如写成 &#42;&#42;标签&#42;&#42;:正文。

落地的资产

频道配置修正        博客频道的 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」升级到「七项硬性字段加公开页渲染检查」。

本文的发布本身就是对修复后链路的一次真实端到端测试。如果你能正常读到这篇文章,说明链路已经恢复。