跳过正文

反复掉线一分钟的 WordPress 站:CDN 其实从未缓存过 HTML

·4953 字·10 分钟
目录

一个服务端渲染的 WordPress 站每隔几天就出现一次约 1 分钟不可用:访客和监控探针同时超时或 504,随后自行恢复,发生频率与外部抓取器、扫描器的活动正相关。本文记录这次排查。适用环境:WordPress + Hestia 控制面板(nginx→Apache 反向代理栈)+ php-fpm 8.3 + 前置 Bunny CDN,2026-09 实测。结论先行:根因不是网络、不是重启,而是这条链路上没有任何一层 HTML 缓存——CDN 的 Smart Cache 设计上永不缓存 text/html,源站又对每个页面发 no-cache,于是每次匿名访问都是完整回源跑一遍 PHP。

一、症状与第一手数据
#

监控服务在最近 5 天报告了 3 起约 1 分钟的掉线(表 1)。每次都没有明确的错误码:多数探测点是超时,个别探到源站 504;站点没有重启、没有 OOM、没有网络变更,掉线是"自己好的"。频率上与外部请求活动强相关。

掉线时刻(UTC) 源站现场 触发者
09-14 10:53 25 s 内 250 个请求(峰值 18/s)扫不存在的主题 CSS;每个命中都返回 46.6 KB 的主题 404 页(= 一次完整 PHP 渲染) 伪造官方优化器 UA 的扫描器
09-17 02:09 探针收到 10 条 503(约 3 KB 错误页);插件目录与升级备份目录落盘时间吻合,伴随更新回环自检与 wp-cron 并发 WordPress 插件自动更新(维护模式)
09-17 18:02 100 s 内 357 个请求、单秒 61 个到达;同一篇文章被重复抓 88 次;源站出现 2 条 504 与 Apache AH01075 ... (polling)(FCGI 派发超时,即请求已排队超过 30 s) 一个家宽 IP 的抓取器(UA 自报)

判据来自监控探针的请求计数:探针在源站日志里的基数是每分钟 3–4 次,掉线分钟翻到 9–12 次——监控判定 down 后会对每个探测地点重试,请求量放大就是掉线指纹。用这一条从源站日志反推出全部掉线时刻,与监控 CSV 完全对齐,还为历史补齐了一串同型记录(每隔 2–4 天一次的整点后 :09 分钟 503 风暴)。

三个现场都指向同一个事实:php-fpm 池被突发请求打满。16 个 worker 的池子被 61 并发、250 个 404、或插件更新风暴任一撑爆,与站点共用同一池的监控探针立即跟着失败。

但"被打满"只是最后一环,真正要回答的是:为什么区区几十个并发就能打满整站? 对响应头做一次"判缓存真伪"的检查,答案立刻浮出来。

# 改造前,公网连打 5 次匿名首页/文章,每次都一样
HTTP/2 200
content-type: text/html; charset=UTF-8
cache-control: no-cache
cdn-cache: MISS
cdn-cachedat: 09/17/2026 23:43:10   ← 等于请求当刻
cdn-requestpullsuccess: True
cdn-requestpullcode: 200

cdn-cache: MISS + cdn-cachedat 等于请求当刻 + 每次都 cdn-requestpullsuccess: True,这是一个"每次都现拉"的指纹。对照验证:同一批请求同步出现在源站 Apache 访问日志里——CDN 每收到一次页面请求,就回源运行一整次 PHP。

同一时刻的静态资源是完全不同的待遇:

请求类型 改造前 cdn-cache 源站行为
首页 / 文章(text/html) 全部 MISS 每次都回源,完整 PHP 渲染
css / js / 图片 HIT 边缘直接返回,源站零请求

所以准确的说法不是"CDN 没工作"——静态资源它是干活的;是 HTML 流量从没被缓存过,一直是原样转给源站的

二、根因
#

把链路一层层看过去,结论是:三层都没有 HTML 缓存

改造前实际状态
WordPress 页面缓存 WP_CACHE=truewp-content/advanced-cache.php 不存在——旧缓存插件卸载后常量空转,页面缓存根本没生效
源站 nginx 反向代理用的是默认模板,vhost 里没有 fastcgi_cache,microcache 未按域开启
CDN 边缘 Smart Cache 开着;Bunny 文档明确:text/htmlapplication/jsonapplication/xml 三种 MIME 永不缓存,无论扩展名

第三层的机制值得展开。Bunny 的 Smart Cache 默认只缓存白名单里的静态扩展名(图片/字体/文档/代码),把 HTML 这类当作"动态内容"无条件放行回源,其设计理由是"防止把敏感的个性化内容误缓存给别人"。文档给出的绕过方式只有一条:写 Edge Rule,用 Override Cache Time 动作覆盖。

而源站这边,WordPress 生态里有插件在给每个页面发 Cache-Control: no-cache(卸载插件留下的持久化行为)。于是双重叠加:

  • Smart Cache 说:text/html 永不缓存;
  • 源站说:这个响应不许缓存;
  • 连"用 Edge Rule 强制缓存 N 分钟"也被第二条否决——Bunny 不为标了 no-cache 的响应建缓存。

结果就是:CDN 提供了一整套"带宽能力"(TLS/HTTP2、DDoS 吸收、静态资源缓存、图片优化),唯独对 HTML 是纯中转。这个站的每一次页面浏览,都等价于有人直接访问源站。

三、处置过程(含走过的弯路)
#

这一节比答案更值钱:Bunny 面板上的 HTML 缓存开关和 Edge Rule 强制缓存,我们实测都走不通,而且有一个开关比不开更糟。

弯路 1:面板的"静态 HTML"开关把全站变成 BYPASS
#

先试 Bunny 官方路径的第一条:开 Optimizer 的"静态 HTML"开关。开启的瞬间,面板自动在拉区生成两条 Edge Rule:一条 action=OverrideCacheTime 且参数为 0(即永不缓存)、带 cookie 触发器,另一条是 BypassPermaCache,两条的 URL 触发器都是全站通配符。

实测(开启后连打匿名页面、带 cookie 请求、静态资源):

edge try1   200 0.549s   cdn-cache: BYPASS   ← 不是 HIT,是显式旁路
edge try2   200 0.080s   cdn-cache: BYPASS
css try1    200 0.055s   cdn-cache: BYPASS

连静态资源也从 HIT 掉成 BYPASS——这个开关把它自动生成的规则当作唯一裁定,全站绕开缓存,比不开更糟。回退后边缘静态资源恢复 HIT。

弯路 2:Edge Rule 强制缓存时间仍然无效
#

第二条路按文档来:自己写 Edge Rule,按响应 content-type: text/html + status 200 覆盖缓存时间 600 s。先后试了两种动作(OverrideCacheTime 与 OverrideCacheTimePublic),再不断收窄触发器(全站 → 单篇文章 → 去掉可疑触发器),结果一致:

article try1   200 0.595s   cdn-cache: MISS    origin: EXPIRED
article try2   200 0.076s   cdn-cache: MISS    origin: HIT
article try3   200 0.078s   cdn-cache: MISS    origin: HIT

边缘依旧 MISS,只是源站层自己命中(说明这时已有一层 nginx 代理缓存在工作——它属于第二层,与边缘无关)。排除触发器问题后结论收敛:卡点是源站显式发的 Cache-Control: no-cache——Bunny 拒绝为这类响应建缓存,什么 Edge Rule 都覆盖不了。官方两条路径在我们这里失效的原因由此明确:它们的前提都是源站先把头发对。

弯路 3(侦查层面):防火墙日志里的大量 BLOCK 不是攻击
#

排查早期(另一个掉线窗口)宿主防火墙日志持续刷着数千条 BLOCK 记录,第一反应很像被扫描或攻击。逐一核对策略后确认:那是防火墙默认拒绝策略对容器 DNS 查询的常规拦截——软件默认行为,不是攻击信号。它确实造成容器内 DNS 不可用等一系列副作用(另案修复),但与掉线窗口无关,窗口内源站全程 200。排障时先分清"策略性拦截噪音"和"真正的 5xx/断流",否则会在默认行为上浪费整个调查阶段。

最终方案:两层缓存 + 发布即清除
#

教训汇总成一句话:要让 CDN 缓存 HTML,得先让源站对"值得缓存的请求"发出可缓存的头。落地四步:

  1. 源站头正确化(WordPress mu-plugin,在 send_headers 钩子里按最终优先级覆盖):
    • 匿名 + 无查询串 + 非后台/API/Feed 的 HTML:Cache-Control: public, max-age=0, s-maxage=600(边缘缓存 10 分钟,浏览器每次验证);
    • 登录/评论者/密码页 cookie、wp-admin、AJAX、REST、查询串、预览、404:Cache-Control: no-cache, no-store, must-revalidate, private
  2. 边缘层:关闭 Smart Cache(EnableSmartCache=false),让 Bunny 完全按源站头决定;只留一条 Edge Rule 做旁路——cookie 匹配 wordpress_logged_in/wordpress_sec/wp-postpass/comment_author 等,或路径匹配 /wp-admin/*/wp-login.php/wp-json/*/xmlrpc.php/wp-cron.php,一律缓存时间 0。
  3. 源站层:控制面板的反向代理模板切成 caching(nginx proxy_cache:200 缓存 5 分钟,301/302 与 404 缓存 10 分钟);cookie 与 POST 自动置 $no_cache=1 旁路;X-Update 请求头既绕过读取又写回新副本。
  4. 发布即清除bunnycdn 官方插件只有后台手动清除按钮、没有发布钩子,所以由同一个 mu-plugin 在发布/更新/评论事件里触发:CDN 全区 purge + 对受影响 URL 发带 X-Update 的回源请求刷新源站层。注意回源刷新必须请求干净 URL——nginx 缓存键包含查询串,带缓存破坏参数会写进另一个键,等于没刷新。

另有一处与缓存无关的独立优化:给不存在的静态资源直接返 404。扫描器打的 /wp-content/themes/xxx/style.css 原本会被 try_files → fallback 交给 WordPress,渲染出 46.6 KB 的主题 404 页(又是一次完整 PHP 引导)。改为在 nginx 层对 /wp-content//wp-includes/ 下带静态扩展名且文件不存在的请求直接返回 404(2.9 KB)。实现要点:必须用 location ^~ 前缀写法——普通 location ~ 会被控制面板 vhost 内嵌的正则位置抢先,写了不生效(同样是实测踩坑)。

四、验证
#

最终结构与实测数字(公网路径,全部经过 CDN):

请求类型 走到哪 耗时
匿名页面(已缓存) CDN 边缘命中 约 55 ms,源站零请求
匿名页面(新页 / 清除后首次) 源站 nginx 缓存命中 50–80 ms,仍不进 PHP
登录 / 评论者、后台、API、POST、查询串 直穿到 PHP 正常渲染(设计如此)

改造前同路径匿名页回源约 0.5 s;改造后匿名命中只要零头,而且源站请求量归零

洪峰复现(关键验收):拿 09-17 的真实洪峰形态(60 个请求、并发 20、混打真实页面)重放:

  • 全部 200,平均 67 ms、最大 95 ms,没有一个超过 1 s;
  • 源站 Apache 日志只多了 9 条(绝大多数请求没碰 PHP);
  • 源站负载从 0.13 升到 0.22,探针请求全程 50–70 ms 正常。

对照改造前同类洪峰:504 + 30 秒级排队。那个"1 分钟掉线"的机制被从根上拿掉。

发布即清除验证:手动执行一次 purge 路径后,边缘立即转 MISS,下一次请求自动回温 HIT;源站缓存条目由 X-Update 请求刷新而非等过期。

怎么判断没解决(反向验收):匿名页 cdn-cache 仍是 MISS,且源站访问日志与访客请求同步增长——两者出现任何一个,说明 HTML 又回到"每次现拉"状态(常见诱因:源站头又被某个插件改回去、Smart Cache 被重新打开、旁路规则误伤匿名流量)。

五、留给后来者的清单
#

  1. 先判缓存真伪,再谈缓存配置。三个响应头一次到位:
    • cdn-cache: MISScdn-cachedat 等于请求当刻 → 每次都回源,没吃到边缘缓存;
    • 同一批请求同步出现在源站日志 → 每次都跑了完整渲染;
    • 对照静态资源是否 HIT → 区分"CDN 坏了"与"HTML 没缓存"。
  2. 查"链路上有没有一层 HTML 缓存",三个看着有其实没有WP_CACHE=trueadvanced-cache.php 不存在(常量空转);控制面板 microcache 需按域开启、vhost 没有对应指令等于没开;CDN 拉区若延续源站 no-cache 头,边缘也不会缓存 HTML。
  3. Bunny HTML 缓存配方(本文实测可跑通):Smart Cache 关闭 → 源站 mu-plugin 只对"匿名 + 无查询串"的 HTML 发 public, max-age=0, s-maxage=600,其余一律 no-cache, private → 一条 Edge Rule 做 cookie/后台路径旁路 → 边缘与源站各配一层缓存 → 发布/评论时双层清除(全区 purge + X-Update 干净 URL 回源)。
  4. 旁路清单别漏:登录/评论者/密码页 cookie、wp-adminwp-jsonxmlrpc.phpwp-cron.php、带查询串、POST、预览、404。漏掉任何一个,轻则缓存错页面,重则把登录后的页面发给别人。
  5. 用 API 改边缘规则注意部分更新语义:未提交的字段会被重置为默认值,改完拉全量配置核对漂移。回滚路径各一步:删 mu-plugin 文件、模板切回 default、删旁路规则、Smart Cache 重新打开。

六、碎碎念
#

把这次调查串起来的其实是最后一条因果链:这套架构里,16 个 worker 的 php-fpm 动态处理池就是整站吞吐上限。CDN 不缓存 HTML,“读一个页面"和"跑一次完整渲染"就变成同一件事;池子被打满(抓取器、插件自动更新、扫描器 404),新请求全部排队直至超时——而监控探针探的就是贴着你首页的 PHP 渲染,和访客挤在同一池子里。池满那一刻,监控和访客看到完全相同的"1 分钟黑洞”。

之前把池子从 8 扩到 16、把图片交给 CDN 优化,都只是把黑洞往后推了推。真正让它消失的,是承认 CDN 的 HTML 缓存坑、把源站头发对、再把"页面浏览"从 PHP 渲染里剥离出来。给博客做缓存的价值也在这里:它不只是面板上的命中率数字,更是让"监控探针"和"真实访客"两种流量不再挤同一个池子。以后若再见同型症状,先看两个响应头,再决定要不要碰防火墙。