上线不是终点:主题没写错,麻烦都在它脚下
上一篇的结尾我把话说得太漂亮了。单篇 TTFB 从 0.721 秒降到 0.317 秒,性能分 93,缓存命中,字体 preload,一切都在预期里。收尾时我写了一句“还有一大块免费的速度没有领”,然后关电脑睡觉。
后面的两周证明,难的部分根本不在这儿。
系列:paddy-newsprint 主题开发记 · 续篇
前置阅读:做了一个报纸风格的 WordPress Markdown 主题 · 换装之后:75 到 93,三条军规的生产环境成绩单
主题分类:实践复盘
阅读时间:约 14 分钟
我把这段时间真正让我半夜爬起来、或者事后拍大腿的问题数了一遍。它们的共同点很清楚:没有一个出在主题代码里。主题是我自己写的,每个函数为什么在那儿我都知道;难缠的是它脚下那台机器。内核会升级,CDN 的回源段会变,缓存规则会影响你怎么改一段文案,攻击流量会挑你最虚的那一刻来。这些东西不会出现在 git diff 里,但它们决定站点是活着还是死了。
这一篇讲的就是这堆事:换主题之后怎么运维、怎么测,以及当一个“看起来像主题 bug”的现象出现时,怎么把它定位到真正的地方。
上线那一晚,全绿
9 月 13 日晚上激活新主题,全量刷新缓存,然后逐页过:首页、单篇、归档、搜索、404、关于页,再加上含公式、含图表、含代码块的实页,还有 feed、站点地图、llms.txt、登录页。浏览器开发者工具确认字体加载成功、没有 404 资源、没有外部请求。全部通过。
当晚就撞上第一个跟主题无关的问题。上传主题包的时候,站上一个对象存储同步插件被上传钩子唤醒了。它的配置还是空的,直接 fatal。
这是整段经历里的第一次提醒:你改的是主题,但被牵连的东西不在你的改动范围里。
先把“换主题”当成一次变更管理
激活,是整个流程里最没有技术含量的一步。真正花时间的是前后四件事。
换前体检。 先把主题的缓存行为过一遍:有没有外部请求,资源是不是按需加载,版本号用什么做参数。结论决定后面重点核对什么。我这套主题的版本号用的是文件修改时间,文件一动 URL 就变,天然破缓存;但正文模板里嵌着几枚第三方徽章图,那算是“零外部请求”这条军规的破口。
换前基线。 同页面、同工具、同网络,采三到五天,表头必须写清采集时段和网络环境,否则数字之间没有可比性。判定标准我定的是正负 30% 以内算正常。
回滚条件先写死。 关键模板 5xx 或白屏、样式字体批量 404、误封激增、三十分钟内定位不了——任何一条命中就回滚,不临场讨论。
换后监测。 前三天每天采一次同参数快照,重点盯 404 计数。主题换掉之后资源和路径全变了,404 是这类变更里最容易放大的一条风险线。
两个测速方法上的坑必须单独说,它们决定你拿到的数字是真是假。
第一个:源站视角在外面根本量不到。我试过直连源站地址,回环地址也一样,全被边界防护挡成 403。要拿 PHP 的真实渲染耗时,只能到服务器本机侧量,而那是另一台机器、另一条链路。这意味着“边缘视角”和“源站视角”天生是两套数据、两个采集条件,不能混在一张表里比。
第二个:验证缓存不能用 curl -I。HEAD 请求不会被 CDN 缓存,永远是 MISS,会把一次漂亮的命中误判成“缓存失效”。必须用 GET 拉完整响应,同一个 URL 连打两次,看第二次的 Age 有没有递增。
这两个结论后来固化成了两个小脚本:一个负责多页面计时、输出逐轮和中位数的表格,一个负责二连 GET 的缓存判定。脚本本身没什么技术含量,真正值钱的是上面那两条反面教训。
防火墙没起来,而且它不打算告诉你
换装完成后的第一个维护窗口,我给服务器做了一次内核升级。重启之后例行检查,其中一项亮红灯:防火墙。
它的状态长这样。
- 服务是 enabled
- 配置文件里开关写着 yes
- 当前状态:inactive
- 本次启动的服务日志:零条目
- 失败服务列表:空的
翻译过来只有一句话:它从没启动过。而且它不在任何“失败”统计里,因为这次开机的启动事务里根本没有它这一项,job 被删掉了。
一条命令定性:
journalctl -b -t systemd --grep 'ordering cycle'
输出是两行:
network-pre.target: Found ordering cycle on <防火墙服务>.service/start
network-pre.target: Job <防火墙服务>.service/start deleted to break ordering cycle ...
(服务名做了替换,不点具体是哪个防火墙。)
查这个环必须加 -t systemd。不加的话,grep 会匹配到你刚刚执行的那条命令本身,得到一个假阳性,然后你会以为自己找到了问题。
根因在开机依赖图上:有一个定制服务同时声明了“在网络服务之后启动”和“在系统初始化的最早期之前启动”。这两个条件在时间上不可能同时满足,于是每次开机必然成环,systemd 每次都要丢掉一个 job 来解环,丢谁不固定。这次的倒霉蛋是防火墙,别的时候是别人。
flowchart LR
S[那个定制服务] -->|声明一 要在网络就绪之后启动| N[网络就绪目标]
N -->|声明二 又要早于网络就绪启动| S
S -.->|两条声明不可能同时成立| C[systemd 每次开机丢一个启动任务来解环]
C -.->|丢谁不固定| F[这次丢的是防火墙]这件事同时解释了一个长期困惑:为什么过去几次“重启后验证通过”的结论时而成立时而翻车。因为环一直在,中招对象是随机的,不查真的不知道。修复是删掉那条自相矛盾的排序声明,之后连续几次重启 grep 都是零输出。留了一个提醒给自己:那个服务包一旦更新,这行要重新核。
然后是这两周里最贵的一课,跟技术无关。
当晚我因为轮换登录凭据把自己锁在了门外,最后用救援模式回滚了几个小时前的快照。回滚很顺利,站点立刻恢复。但快照回滚不是撤销键,是时间机器:它撤销的是那个时间点之后的一切,包括已经做完并且验证过的正确变更,也包括我刚取证完的日志。排序环的跨启动证据就这样没了,这个问题此后只剩两个监测点,每次重启后 grep 一次,以及例行检查里看一眼防火墙状态。
新纪律只有一条:每个维护窗口开工前先拍快照,快照 ID 记进运维账本;真要回滚,目标永远是账本里最近那一条 ID,不靠记忆。
白名单里少了的第 196 条
第二件事更安静。
CDN 的回源节点段每周会变,服务器上有个脚本定时去拉最新清单,写进防火墙的白名单集合,只放这些段回源。某天收到回源节点变更通知,我顺手核了一下拉取是否正常。
清单是 196 条,集合里只有 195 条。缺的正好是排序后的最后一条。
三层原因叠在一起:
第一层,写清单的代码是 '\n'.join(sorted(set(...))),结尾不带换行符。
第二层,加载循环 while read -r cidr; do ... done < "$ACL" 会跳过没有换行符的末行。
第三层,日志打印的是“加载完之后集合的条数”,所以它每次都报一个自洽的数字,195,看起来毫无异常。
flowchart TD
A[写入端 join 之后不补换行] --> B[清单末行没有换行符]
B --> C[加载循环 while read 跳过没有换行的末行]
C --> D[集合比清单少一条]
D --> E[日志只报集合条数 数字自洽]后果是每周静默漏一条。如果某天有一条新节点正好排在末位,它的回源会被防火墙丢掉,用户看到的就是 502。这次核查了丢包日志,漏掉的那条当时并没有在服务,所以它属于潜在缺陷,而不是已经发生过的故障。
诊断过程里还有两个跟“数数”有关的坑。
wc -l 不统计没有换行符的末行。我用它数清单,先后得出两个错误结论:先算出 195,又把 diff 出来的 196 当成“多出一条”。
更讽刺的是第二个。我自己写的检测循环也用了 while read,于是复现了完全相同的跳过,第一轮结论是“缺失 0 条”。检测方法必须独立于被检测代码的假设,否则同错相消,你什么都看不见。
修复是三处一起上,做双保险:
# 写入端补换行
open(path, 'w').write('\n'.join(sorted(set(data))) + '\n')
# 读取端加守卫,兼容无换行末行
while read -r cidr || [ -n "$cidr" ]; do
ipset add cdn_origins "$cidr"
done < "$ACL"
# 日志加对比数字:集合条数 vs 清单条数,两个数不一致立刻暴露
第二层问题比这个缺陷本身更值得记。这个脚本配了一个自检哨兵,专门检测“有没有来源在白名单之外的请求”,理论上正好能抓住上面这个漏项。但它的定时任务在两周前的一次快照回滚里丢了,没人发现。原因是哨兵只写“有异常”的行,不写汇总行,于是“根本没运行”和“运行了一切正常”在日志里长得一模一样。修复之后的输出变成两行,扫一眼就知道跑没跑:
cdn_origins entries: 196 (清单条目: 196)
cdn-origins-check: 检查 N 个来源IP, MISS 0 个
阈值是量出来的,不是拍出来的
九月上旬连着四起慢洪水,外加一个大攻击日。快攻那天的记录是 6080 次 403 加 4533 次 429,说明高水位限速工作正常。但那四起慢洪水的强度全落在限速窗口以下,一个 429 都没触发;而 503 全部透传,封禁链路完全没有信号可抓。
先把地板量出来。正常时段全站每小时三位数的请求量;我自己在后台编辑的峰值大约每分钟 2 个请求。而 PHP 被打到回 503 的那个门槛低得离谱:有一次某个来源用半个多小时、极慢的节奏把源站打穿了,几乎每一个请求都换回一个 503。具体数字我不在这里写,写了等于把一份现成的施压配方递出去,只能说这台机器不是按“能扛压力”设计的。
结论是,这台小机器的防护链是照着“高水位快攻”配的,慢洪水正好从缺口里走进去。
接下来是最难堪的部分,我第一轮统计算错了,而且连错三次。
排查用的日志分析脚本按时间窗过滤,比较的是时分秒字符串,没有比较日期。跨天之后,前一天的行会被累进“最近 N 分钟”:
- 第一次算出“10 分钟 4.2 万请求”,实际是每分钟 470;
- 第二次得出“慢洪水仍在活跃”,实际那些行是两天前的;
- 第三次报告“上千个 429 没有被封禁”,实际是多天累加加上旧快照。
三条全错,而且都指向同一个方向,差点让我去改一套本来就正常工作的封禁逻辑。铁律:时间窗统计必须带日期守卫,或者干脆用 tail -N 截窗口。
修正后的建议是三层,动作收敛到同一条封禁链路:
| 层 | 条件 | 阈值 | 防什么 |
|---|---|---|---|
| 登录口 | 登录 POST,每 IP | 按实测地板标定 | 登录爆破 |
| 突发 | 非静态路径,每 IP | 同上,窗口更短 | 并发循环 |
| 总量 | 非静态路径,每 IP | 同上,窗口更长 | 慢洪水 |
这三层的具体数值我故意不写。阈值本身就是防护的一部分,公开它等于告诉对方卡在哪个数下面最安全,而这一节前面刚讲过慢洪水是怎么从缺口里钻进去的。三层怎么切、每层防什么,比具体填多少更值得参考。
静态扩展名(图片、字体、CSS、JS、视频)整档豁免在外。改完必须自测:临时把阈值调到 2 次/10 秒,自己连刷三下,确认 429 触发、封禁链路收到了,然后立刻改回。还有一条铁律,503 永远不能作为封禁信号,它是上游故障的透传,不是攻击证据。
阈值放宽之后又踩了一次,这次伤的是自己。媒体库打开网格时会并发拉几十张缩略图,而登录 cookie 会让缓存策略把带登录态的请求全部放回源站,一瞬间超过阈值,触发了人机验证。验证粘滞 60 分钟,表现就是“媒体库卡死”。处置是把阈值放宽一个量级,并且在重媒体操作之前临时加白名单。
这里有个真实的教训:阈值调紧的时候,第一个被挡住的是站长自己。
把页面搬到边缘,比调阈值更管用
四起洪水里三起是 GET 页面洪水。也就是说,只要页面 HTML 在边缘节点就命中,PHP 根本不参与,慢洪水就失去了杀伤力。而当时只有首页 HTML 进缓存,文章页每次真实回源,这条攻击路径一直开着。
把文章页也放进 600 秒缓存之后,单篇 TTFB 从 0.317 秒继续降到 0.1 秒以内,真正落到源站上的动态请求降了一个量级,慢洪水的性价比也就没了。
缓存规则上的坑,都是实测撞出来的:
- 路径条件是精确匹配。写
/feed命中不了/feed/,两个都要列。 - 站点地图清单要对着真实地址核一遍。我那份里有一堆从来没被访问过的死条目,是另一个插件留下的命名习惯。
- “静态扩展名”这一档里包含 txt,所以
/llms.txt走的是静态缓存。但源站如果返回Cache-Control: private, no-store,边缘就不会缓存它。这个行为正好在 0.10.13 上撞了一次:新版本把/llms-full.txt改成默认关闭,关闭状态下它返回 403 加 no-store,我用两次 GET 验证,Age 一直不出现,确认没有被缓存,所以在设置页打开它之后是立刻生效的,不需要手动清任何东西。 - 静态文件可能被“启发式”缓存过。有一次更新说明页之后仍然看到旧版本,最后只能按 URL 单独刷新。此后更新静态资源一律优先换文件名做版本化,不指望缓存规则。
689 个文件搬走之后
上一篇结尾我说了要做图床迁移,把所有上传文件搬到对象存储。做完之后有几个决定值得写下来。
桶保持私有读写。 未授权直连桶的默认域名会直接 403,这本身就是一层防直连盗链,比桶级防盗链省事,而且桶级防盗链会误伤边缘回源,因为回源请求不带访客的 Referer。
URL 改写是运行时的,不是写进数据库的。 插件在渲染时通过一组过滤器把附件 URL 换成加速域名。这个机制决定了三件事:不用改数据库,文件传上去 URL 就自动变;回滚就是停用插件,前提是本地文件别删;改写是无差别的,没上传成功的附件也会被改写成加速域名,于是上传缺口会直接表现为裂图。所以完整性校验不是可选项。
迁移当天踩的坑里,最致命的是一个空字段。
| 坑 | 现象 | 处置 |
|---|---|---|
| 自定义域名没填 | 改写指向桶的默认域名,私有桶下匿名访问 403,全站裂图 | 在插件设置页填上加速域名 |
| 后台“首次同步”报超时 | 界面提示失败 | UI 报错不等于后端停了,两次“失败”之后文件其实已经全部传完;要确定性可以走插件自带的命令行上传 |
| 上传目录里的 .php 请求返回 566 | 以为坏了 | 预期行为,边缘和 WAF 会拦掉上传目录里的 PHP |
完整性校验的方法很土但有效:把本地上传目录的文件全量列出来,按加速域名逐个请求,200 算已上传,566 算被安全策略拦下的正常项,404 就是缺口。结果是本地 690 个文件、689 个可用,唯一“缺”的那个是上传目录里的一个 PHP 缓存文件,本来就该被拦。之后又扫了文章、首页、列表页和说明页,零裂图。
最小权限这块翻了两次车。
一是策略模拟器。它默认把资源字段填成 *,然后告诉你“全部拒绝”,看起来像权限根本没配上。测单桶策略必须把要验证的具体资源填进去。
二是资源名的写法。对象存储的资源标识有好几种拼写方式,带不带账号 ID、桶级还是对象级,服务端实际用哪种评估并不确定。配完之后策略模拟器说“允许,匹配 N 个策略”,真实请求却返回 Access Denied.,而且不带动作名,看不出原因。最后的处置是把 8 种组合全部列进资源字段,全部锁同一个桶,范围没有扩大,确认生效之后再逐个删。
还有一条验证纪律:收窄权限之后要逐个打开所有设置页的标签页,不能只测上传主链路。附属功能依赖不同的云服务,漏测会让人以为“刚才那步把系统改坏了”。顺带一提,这个插件的错误处理会吞异常,界面不提示,错误只进服务端日志,排查上传失败的正确入口是日志。
一个通配符把图床全站拦掉了
这是最贵的一次。
现象:图床域名上的所有请求全部被拦。博客正文里引用的、没有 Referer 的(图片代理服务端代取)、裸域名、完整 URL,六种变体无一幸免。主题说明页里 17 处图片引用、公开仓库 README 里的截图、主题安装包的下载链接,全断。
定位第一步不是看代码,是认“是谁拦的”。这一层前后站了好几道防线,每道都有自己的签名。
WAF 的攻击拦截返回 4xx,响应体里带它自己静态资源的路径特征;人机验证和自定义规则是同一段里的另外两个编号;边缘平台的自定义规则拦截用的是 5xx 段的一个自有编号(我这边实测是 567),响应头里带平台标识和一个日志 UUID。
flowchart LR
A[页面异常] --> B{状态码落在哪一段}
B -->|4xx + 特征响应体| C[站点侧防护层]
B -->|5xx 自有编号 + 平台响应头| D[边缘平台自定义规则]
B -->|503 透传| E[源站过载或故障]
C --> F[查该层规则与日志]
D --> F
E --> G[查源站负载与慢请求]先用编号判断是哪一层拦的,再去看那一层的规则,比在几套日志里乱翻快得多。这次是边缘平台的自定义规则,根因很蠢:运算符那一栏选的是“Referer 通配符不匹配”,字段里填的却是正则表达式。通配符引擎的语义里,* 是任意字符、? 是单字符,其余字符按字面匹配,于是正则里的 ^、(、[、\. 这些字符永远不可能出现在真实 Referer 中,条件永不成立,所有请求都落进“不匹配则拦截”的分支。
三条纪律我全犯了。
字段的模式,通配符还是正则,必须和填写的表达式语法一致,粘贴之前先看运算符那一栏。自定义规则变更一律先开观察模式,从日志确认匹配行为之后再切拦截。白名单型防盗链必须允许空 Referer,否则图片代理、RSS 阅读器、聊天预览、下载工具全部误伤,而空 Referer 又可以被 referrerpolicy=no-referrer 绕过,所以 Referer 防盗链的实际防护力有限,大流量场景该靠限速。
最后这条规则被删掉了。那个域名现在靠边缘侧的自适应限速和流量层防护,都不依赖 Referer,也没有误伤。
另一个小坑:提取页面里的 URL 做批量测试时,HTML 的行尾符会把多余的字符带进 URL,curl 返回 000,看起来像站点全挂。请求前先清一遍。
说明页和隐私政策也是攻击面
主题要公开发布,随之而来的是一批“由我本人主动撰写、而且必须长期公开”的文档:主题说明页、隐私政策、公开仓库的 README。这类材料在攻击者眼里很好用,因为它是你自愿写清楚的。
我给自己定的判定标准是一句话:一条信息如果能让陌生人缩短“判断技术选型、匹配已知漏洞、定向试探”这条路,就不该出现在对外文档里;反过来,用户做“要不要用这个站”这个决定所需要的知情信息,必须写。
落到做法上是三类分开处置。安全防护类只按类别写,绝不写产品名。基础设施类也按类别写,主机、CDN、对象存储、缓存和数据库这些东西,写了只有细节没有收益。接触用户提交内容的第三方必须点名,用户的评论和邮箱流向哪家,他有权利知道。
还有一个容易忽略的泄露面是版本记录。写“停用了某某插件”“改用某某统计”,等于把组件变更史公开了,和正文是同一级别的信息。
这次踩的坑是“停用”两个字。WordPress 插件列表里那个“停用”是操作链接,不是状态标签。显示“停用”意味着该插件正在启用。我按字面读,把两个启用中的插件当成了已停用的,结果是隐私政策漏披露了一条真实的数据流出:评论正文和用户名会传给第三方内容审核服务。连带影响是,评论过滤类插件通常还提供拦截日志,启用它就意味着新增一类留存数据,未通过的评论正文、提交 IP 和 UA、命中原因都算,隐私政策的“收集”和“保留期限”两节都要跟着改。核状态要认插件名下方的状态标签或者列表页的筛选计数。
发布前我会跑一个关键词自查脚本,词表就是上面那三条规则。用的时候有一个提示:命中不等于有错,比如“安全组件”会命中“安全组”这一条,得逐条人工判断;而且它只做关键词,不做语义判断,写得太细但没命中敏感词的情况依然存在。
哪些事我没做,哪些结论站不住
先说没做的一件事。盘点防护链的时候我发现,所有动作都做在边界层:CDN、WAF、防火墙、封禁。边界之内,主机侧我只保留了触发式应急这条路,没有增加常驻组件。我调研了一个云厂商提供的主机侧排查工具,结论是不装。它需要常驻一个客户端,而我的原则是尽量不增加常驻组件;更要紧的是它的“隔离”动作对 WordPress 站点太危险,插件和主题里的混淆代码本来就容易误报,点一下隔离可能就是站点故障。它的定位是触发式应急工具,真出事再装,来得及。
再说测试方法上的一处坦白。PageSpeed Insights 的单次数字不能信。同一个配置、同一个页面,间隔四分钟跑两次,一次 64 分一次 94 分,差了 30 分。此后所有第三方数据一律三次取中位数,单次数字不驱动任何决策。
这一轮没证明什么,也值得写清楚。测速基线依赖采集时段和网络,表头必须记采集条件,我把正负 30% 以内视为正常,不做过度归因。脚本只覆盖 HTTP 这一侧,机器自身的资源监控不在这一轮的范围里。缓存和权限的验证都是“我在本地和服务端看到的结果”,不等于陌生访客在每个边缘节点上的体验。至于“重媒体操作前临时加白名单”,它是一次性的手工动作,忘了就是误伤,算不上真正的解法。
这一轮真正改变的不是某个阈值,是三件事。
每次变更前先拍快照,快照 ID 记进账本。回滚是时间机器,它会把没记下来的东西一起抹掉。
所有定时任务都要有汇总行。区分“没跑”和“跑了没问题”,比任何指标都重要。
出现异常时,先问“这是谁拦的、谁在漏”,再问“是不是我写错了”。这两周里所有真正难缠的问题,答案都是前者。
收尾
主题现在是 0.10.13。这一版的主要变化是新增了“外观 → llms.txt 设置”页:/llms.txt 和 /llms-full.txt 两个端点可以独立开关,内容来源支持全自动、半手工和全手工三种,全量导出的篇数、全文还是摘要也能配。有一个行为变化要专门提醒:/llms-full.txt 这一版默认是关的,升级之后需要去设置页手动打开;/llms.txt 默认仍然开着,并且不再推荐那个已经关闭的地址了。
主题源码和安装包都在 GitHub:PaddySun/paddy-newsprint,Release 见 v0.10.13,镜像下载:https://cos.paddysun.top/paddy-newsprint/rel/paddy-newsprint-0.10.13.zip。
上一篇我说“还有一大块免费的速度没有领”,现在领了。下一篇想写这套越堆越厚的防护链本身,它已经复杂到需要一张自己的地图,而复杂本身就是新的风险。
发表回复