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-210、main-20260819-223)部署到生产后,首页白屏,页面只有 index.html 的加载动画且一直停着(Vue 应用未渲染出来)。 - 浏览器 Network 里所有资源请求都是 200,但懒加载 chunk(如
js/index.BcUAM3fl.js、js/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 未命中映射表时白屏——该问题真实存在(LoginErrorMessageKeyMap缺AccountUnauthorizedException等错误码),但只影响登录异常跳转场景,不是首页首访白屏的根因(见注意事项)。
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 小时,问题才被固化。
完整机制:
- 生产发布采用
maxUnavailable: 0滚动策略,切换期间新旧 pod 混跑(旧 pod 在服务中直到新 pod 全部就绪); - 混合窗口期内,新 chunk(hash 文件名,如
BZXPGnlw.js)第一次被浏览器请求,恰好打到 0812 旧 pod; - 旧 pod 没有该文件,nginx
try_filesSPA 兜底返回index.html(200 text/html); - CDN 把这条 text/html 响应按 .js URL 缓存了 1 小时(无视源站 no-store,
x-ccdn-cachettl: 3600是 CDN 自己的规则); - 之后即使 pod 全部切到新镜像(回源真实 JS),CDN 仍持续吐出缓存的 HTML,浏览器 MIME 拦截 → 懒加载 chunk 全部失败 → 首页白屏、加载动画卡住;
- 回滚到 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 缓存,刷新即恢复:
- 切换镜像 tag(ArgoCD / values.yaml);
kubectl rollout status deployment/gzeport-gzsw-ui -n <命名空间> --timeout=10m等滚动完成,确认旧 ReplicaSet 副本归 0;- 清一次 CDN 缓存(兜底:清掉窗口期可能缓存的 404,确保新 chunk 首次回源拿到真实 JS);
- 验证:
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