gzsw-ui 生产白屏排查:CDN 将 SPA 兜底 HTML 缓存为 JS

gzsw-ui 生产白屏排查:CDN 将 SPA 兜底 HTML 缓存为 JS

记录时间:2026-08-20(初稿 08-19;08-20 补充 chart 1.0.2 根治落地)
环境:生产 https://prod.example.cn/gzsw(信创 ARM64 集群,pod 内 nginx:1.31-topsec);测试 http://test.example.cn/gzsw(nginx:1.29.3-alpine);生产入口经 CDN(openresty / x-ccdn 头)

一、问题现象

  • 12 号之后 main 分支新构建的镜像(如 main-20260818-210main-20260819-223)部署到生产后,首页白屏,页面只有 index.html 的加载动画且一直停着(Vue 应用未渲染出来)。
  • 浏览器 Network 里所有资源请求都是 200,但懒加载 chunk(如 js/index.BcUAM3fl.jsjs/local-app-store.BZXPGnlw.js)响应头是 content-type: text/html,被浏览器按 MIME 拦截,控制台报:
    • 已拦截加载自".../gzsw/js/xxx.js"的模块,它使用了不允许的 MIME 类型("text/html")
    • Failed to load module script: Expected a JavaScript-or-Wasm module script...
  • 回退到 main-20260812-196 镜像后恢复正常;测试环境部署新镜像一直正常
  • 同一份配置(chart gzeport-vue 1.0.1、subPath 模式、values.yaml 基本一致),测试可以、生产不行。

二、排查过程

2.1 代码差异审查(排除了代码)

12 号后 main 共 6 个提交(8-13 登录相关 4 个 + 8-19 uat 配置 + console.log)。下载生产在跑的 0812 入口 JS 与本地 prod-xc 构建的入口 JS 逐行 diff,差异只有三处:

差异 影响
新增 /mid 中间页路由 只影响登录异常跳转场景
api/auth 三个接口加 showErrorMessage: false 仅登录流程
压缩后变量名重排

无任何启动期破坏性代码。结论:不是 12 号后的代码逻辑引起的

2.2 构建产物验证(排除了镜像内容)

  • 本地用 vite build --mode prod-xc 构建当前 main,产物哈希与 Jenkins #210 完全一致(index.mnOe8uA4.js 等)。
  • 拿到 #223 的 dist-main-20260819-223.tar.gz 解包验证:index.html 引用的 8 个文件全在;入口 JS 引用的 60 个懒加载 chunk 零缺失
  • docker A/B:把 chart 的 nginx 配置分别挂到 #223 镜像和 0812 镜像上,curl /gzsw/js/index.BcUAM3fl.js 两者都返回 200 application/javascript。结论:镜像内容完整、配置组合健康

2.3 部署与转发环节验证(排除了 pod / 配置 / 前端 nginx)

  • 生产 configmap(<命名空间>/gzeport-gzsw-ui-config)内容 = chart subPath 模式配置,与测试环境一致。
  • 生产请求路径透明性:curl https://prod.example.cn/gzsw/js/index.CK_Y4w73.js(旧 chunk)→ 200 JS;curl .../index.BcUAM3fl.js(新 chunk)→ 200 text/html —— 后者是 0812 pod 对不存在的文件做 SPA 兜底(try_files ... /gzsw/index.html)的正常行为,说明前端 nginx / ingress 没有改写路径,响应原样到达浏览器。
  • 两个镜像 nginx 版本一致(nginx -v 均 1.31.1),排除 nginx 版本差异。

2.4 走过的弯路(试错记录)

  • 尝试 1(无效):怀疑 TopSec 防篡改模块拦截新文件——实际静态文件打进镜像后运行时无改动,防护只针对运行期篡改,与本次无关。
  • 尝试 2(无效):怀疑滚动发布新旧 pod 混跑(maxUnavailable: 0 策略)——混跑窗口确实存在且会短暂白屏,但无法解释”切换完成、pod 全就绪后仍持续白屏”。
  • 尝试 3(无效):怀疑 /mid 中间页在 errorName 未命中映射表时白屏——该问题真实存在(LoginErrorMessageKeyMapAccountUnauthorizedException 等错误码),但只影响登录异常跳转场景,不是首页首访白屏的根因(见注意事项)。

2.5 关键突破:浏览器请求详情暴露 CDN 缓存

/gzsw/js/local-app-store.BZXPGnlw.js 的浏览器请求详情显示:

content-type: text/html; charset=utf-8        ← JS 请求返回了 HTML
etag: W/"6a7bef51-93e"                         ← 这是 index.html 的 etag!
last-modified: Wed, 12 Aug 2026 03:58:09       ← 0812 的 index.html
via: CDN-EDGE[0,TCP_HIT,0]  ← CDN 缓存命中
age: 90                                        ← 响应已缓存 90 秒
x-ccdn-cachettl: 3600                          ← 该路径缓存 TTL = 1 小时

随后做 A/B 回源对比:

# A. 不带参数请求(可能命中 CDN 缓存)
curl -sk -D - -o /dev/null "https://prod.example.cn/gzsw/js/local-app-store.BZXPGnlw.js" \
  | grep -iE "^(HTTP|content-type|etag|last-modified|age|via|x-ccdn)"
# 结果:200 text/html + TCP_HIT + age: 2944(缓存 49 分钟)

# B. 带随机参数强制回源
curl -sk -D - -o /dev/null "https://prod.example.cn/gzsw/js/local-app-store.BZXPGnlw.js?r=$(date +%s)" \
  | grep -iE "^(HTTP|content-type|etag|last-modified|age|via|x-ccdn)"
# 结果:TCP_MISS 回源;当时源站是 0812,回源也是 text/html(pod 里没有该文件,SPA 兜底,属正常)

结论:CDN 对 /gzsw 下 .js/.css 有 1 小时缓存(x-ccdn-cachettl: 3600),会把 SPA 兜底返回的 HTML 一并缓存。”CDN 缓存未开启”的说法与实际响应头不符。

三、根因分析

前置背景:CDN 缓存是发版前几天才自行开启的,此前生产一直默认直回源。这是”以前从没出过、偏偏这次出”的关键:直回源时代,即使混跑窗口内请求打到旧 pod 拿到兜底 HTML,浏览器下次请求也会重新回源拿到正确文件;开了 CDN 缓存后,兜底 HTML 被缓存 1 小时,问题才被固化。

完整机制:

  1. 生产发布采用 maxUnavailable: 0 滚动策略,切换期间新旧 pod 混跑(旧 pod 在服务中直到新 pod 全部就绪);
  2. 混合窗口期内,新 chunk(hash 文件名,如 BZXPGnlw.js)第一次被浏览器请求,恰好打到 0812 旧 pod
  3. 旧 pod 没有该文件,nginx try_files SPA 兜底返回 index.html200 text/html);
  4. CDN 把这条 text/html 响应按 .js URL 缓存了 1 小时(无视源站 no-store,x-ccdn-cachettl: 3600 是 CDN 自己的规则);
  5. 之后即使 pod 全部切到新镜像(回源真实 JS),CDN 仍持续吐出缓存的 HTML,浏览器 MIME 拦截 → 懒加载 chunk 全部失败 → 首页白屏、加载动画卡住;
  6. 回滚到 0812 后,旧 chunk 的 URL 从未被兜底毒化过,页面恢复正常;测试环境无 CDN(直连集群 IP),所以永远正常。

一句话:CDN 缓存了 SPA 兜底 HTML,把它当成了 JS 的响应。镜像、代码、配置、pod 均无问题。

根因不是滚动策略maxUnavailable: 0 的窗口期混跑(新 chunk 请求命中旧 pod、兜底返回 HTML)只是”错误 HTML 的产生渠道”,CDN 缓存才是”把它按 URL 固化 1 小时”的元凶。混跑在 CDN 缓存开启前就一直存在,只是直回源时代浏览器下次请求会重新拿到正确文件,从未出过事故。

四、解决方案

4.1 立即恢复

  • 联系 CDN 值班清理 /gzsw 目录(js/css)的缓存,或等待 1 小时 TTL 过期;
  • 清理后 curl -sk -o /dev/null -w "%{http_code} %{content_type}" https://prod.example.cn/gzsw/js/local-app-store.BZXPGnlw.js 应返回 200 application/javascript

4.2 后续发布流程(避免再犯)

chart 1.0.2 根治后不再挂维护页。滚动升级就是要让流量在新旧 pod 间平滑切换,挂维护页等于直接断流,违背了滚动发布的本意。滚动窗口内缺失的新 chunk 命中旧 pod 只会返回 404,不会像旧版那样被兜底成 HTML 进 CDN 缓存,刷新即恢复:

  1. 切换镜像 tag(ArgoCD / values.yaml);
  2. kubectl rollout status deployment/gzeport-gzsw-ui -n <命名空间> --timeout=10m 等滚动完成,确认旧 ReplicaSet 副本归 0;
  3. 清一次 CDN 缓存(兜底:清掉窗口期可能缓存的 404,确保新 chunk 首次回源拿到真实 JS);
  4. 验证:curl -sk -o /dev/null -w "%{http_code} %{content_type}" https://prod.example.cn/gzsw/js/<新chunk文件名>.js 应返回 200 application/javascript

4.3 根治:调整 gzeport-vue chart(1.0.2 已落地)

根治思路从”事后清缓存”改成源头不产出错误 HTML:调整 vue helm chart,让静态资源缺失时返回 404(不再被 SPA 兜底),同时新增配置,让前端 vue 能动态控制静态资源是否走 CDN 缓存、缓存多久。

事项 状态 说明
静态资源缺失返回 404 ✅ chart 1.0.2 已落地 内置 nginx 模板(首页/子路径两种形态)统一为静态资源 location 加 try_files $uri =404,缺失文件直接 404,不再被 SPA 兜底成 200 text/html——CDN 缓存链里从此不可能出现”HTML 冒充 JS”
前端动态配置静态资源缓存 ✅ chart 1.0.2 已落地 新增 staticCacheExpires(nginx expires 语法,默认 7d):通过 values.yaml 动态控制静态资源缓存时长,前端项目自主决定走 CDN 长缓存(hash 文件名天然安全)还是短缓存/不输出
CDN 规则 转 WAF 厂家 禁止缓存 .js/.css 路径下 content-type: text/html 的响应(或对该路径关闭缓存、尊重源站 no-store)——CDN 缓存是近期才开启的,开启前直回源从未出过此问题;CDN/WAF 是外部厂家服务,内部技术部管不到缓存规则,需走厂家工单
发布 SOP 运维兜底 前端每次发版后清 CDN 缓存
滚动策略 ⚠️ 不是本次根因 maxUnavailable: 0 窗口期混跑只是触发条件(见 2.4 尝试 2);但 maxUnavailable: 0 对前端发版确实不友好(窗口期必混跑),建议 maxSurge: 100%, maxUnavailable: 0 或接受短暂空窗的 maxUnavailable: 3
代码清理 开发 main 上 52dfb30(”wip: 注释代码提交”)注释了 TypeIt/WangEditor 全局注册,是调试中间态,正式发布前清理

chart 1.0.2 内置模板关键片段(两种形态一致:deployRoot 模式在 location / 内,子路径模式在 location ^~ /子路径/ 内):

# 静态资源:缺失返回 404,禁止 SPA 兜底 ← 根治关键
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    try_files $uri =404;                     # 缺失直接 404
    expires 7d;                              # staticCacheExpires 控制(默认 7d)
    add_header Cache-Control "public, no-transform";
}
# values.yaml(子路径配置节,与 deployRoot/subPath 同属"内置模板参数")
staticCacheExpires: 7d   # 或 30d / 1h / off / 置空;置空则不输出 expires 指令

为什么 404 比兜底 HTML 安全:混跑窗口内新 chunk 请求命中旧 pod,旧 pod 返回 404 而非 HTML——404 不会触发浏览器 MIME 拦截,也不会被 CDN 当 JS 缓存 1 小时;窗口期首次加载可能瞬时失败,刷新即命中新 pod 的真实 JS。404 根治的是”CDN 固化错误 HTML 持续 1 小时”的顽固事故;滚动窗口内的瞬时 404 可接受,无需再挂维护页(发布流程见 4.2)。

五、验证

清缓存后实测:

[root@host ~]# curl -sk -D - -o /dev/null "https://prod.example.cn/gzsw/js/local-app-store.BZXPGnlw.js" \
  | grep -iE "^(HTTP|content-type|etag|last-modified|age|via|x-ccdn)"
HTTP/2 200
content-type: application/javascript; charset=utf-8   ← 正确 MIME
etag: "6a856815-dc91"                                   ← #223 构建的真实 JS
last-modified: Wed, 19 Aug 2026 08:23:49 GMT            ← #223 构建时间
via: ...TCP_HIT...                                      ← 缓存的是正确内容

页面恢复正常。缓存机制本身无问题(hash 文件名天然适合长缓存),问题只出在”把兜底 HTML 也缓存了”。

六、注意事项

  • CDN 是否缓存,看响应头而不是印象x-ccdn-cachettl / age / via 里的 TCP_HIT 是缓存命中的铁证。
  • 回源返回 text/html 不等于镜像坏了:只要当前 pod 里没有该文件,SPA 兜底返回 index.html 是正常行为;判断镜像健康要用”文件存在 + 正确 MIME”两个条件(docker 挂载 chart 配置 A/B 最干净)。
  • 发布窗口期先看 rollout 状态和缓存,再决定是否回滚maxUnavailable: 0 下新旧 pod 混跑窗口约 3~6 分钟,窗口内白屏是预期现象;持续白屏优先怀疑 CDN 缓存毒化,而不是镜像。
  • staticCacheExpires 只在内置模板生效:用 nginx.configOverride 整体替换 default.conf 后该字段失效,自定义配置里要自行保证静态资源缺失返回 404(try_files $uri =404)并处理好缓存头,否则 CDN 缓存事故会换个形态复发。
  • 次要发现(另开单给开发)/mid 中间页在 errorName 未命中 LoginErrorMessageKeyMap(枚举 4 个值,映射表只收 3 个,缺 AccountUnauthorizedException)或缺失时渲染纯白页——建议 mid/index.vue 加兜底:映射不到时 router.replace('/') 回首页,任何输入都不白屏。

七、参考资料

  • nginx try_files 与 SPA 兜底行为:https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files
  • Vite 产物 hash 文件名机制(content hash,发版后文件名变化):https://vitejs.dev/guide/build
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇