故障档案

每一层都对——用户看到的还是旧版

改样式后部署完成但用户端看不到,排查发现五层缓存全链失效,最隐蔽的陷阱是 Docker bind mount 的 inode 绑定。

改了页面样式。构建完成了。原子部署也完成了。反复确认四次。每一次都验证过了。

第一次:看产物目录。

ls -la dist/
grep 'src="' dist/index.html | head -1
# 输出: src="/assets/Markdown-a1b2c3d4.js"(新 hash)

新的。

第二次:从宿主机 curl,看容器返回的。

curl -sk --resolve <domain>:443:127.0.0.1 https://<domain>/ | grep 'src='

还是老 hash。矛盾。进容器确认文件在不在。

docker exec cpa-caddy ls -la /opt/cpa/repo/apps/web/dist/

在。文件确实是新的。

第三次确认:Caddy 配置有没有缓存。

检查了。没启用。file_server 每次都直接 stat。那问题不在 Caddy 的内存。

第四次:验证浏览器。

curl -s https://<domain>/ | grep 'src='

用户端拿到的仍是老 hash 指向。

三个观察结果,互相矛盾:文件系统新了,容器内的文件也是新的,Caddy 没有缓存,但用户端还是老样式。不能同时都是对的。这意味着问题不在任何一个环节本身,而在环节之间的链接

五层缓存链路

问题的根源是缓存在多个环节堆积。前端静态资源从产生到用户浏览器要经过五层:

浏览器磁盘缓存 ← CDN 边缘节点 ← 反向代理(容器内) ← bind mount ← 产物目录

部署过程只触及最内层的产物目录,上面四层完全无感知。

Docker bind mount 的 inode 陷阱

原子部署的标准做法:

mv dist dist.OLD-$(date +%s)        # 原 dist 改名,inode 100
mv dist.NEW dist                     # 新 dist 改名为 dist,inode 200

改名后,宿主机的 /opt/cpa/repo/apps/web/dist 现在指向 inode 200。但容器内的 bind mount 有个细节:它在启动时绑定了inode 编号,不是路径。

容器启动时,宿主机的 dist 指向 inode 100。bind mount 说:"我要挂载 inode 100"。容器内的 /opt/cpa/repo/apps/web/dist 永远指向那个 inode。

部署后,宿主机的路径改了,指向 inode 200。但容器内仍然看着 inode 100。这就是为什么 docker exec 看到的文件是新的(内核在路径后面找文件),但容器运行的 Caddy 进程看不到(它的 bind mount 被钉死在了旧 inode)。

docker exec 为什么能看到新文件呢?因为 docker exec 实际上是在宿主机侧做的文件系统查询,走的是宿主机最新的路径指向。容器内的 Caddy 进程走的是它挂载时定下来的 inode。

根因:三个矛盾现象的同一原因

这个故障的隐蔽之处在于,三个表面上没有关联的观察结果其实由同一个原因产生:

  1. 文件系统看得是新的:宿主机的产物目录已经更新,inode 200 的新文件确实存在。
  2. 容器内看到的是旧的:因为 bind mount 在启动时绑定了 inode 编号,容器内 /opt/cpa/repo/apps/web/dist 永远指向 inode 100,即便宿主机的路径已经指向 inode 200。
  3. 入口 HTML 缓存了旧 hash:这是最隐蔽的一层。假设容器确实能看到新文件,问题仍然可能出现——如果浏览器或上游 CDN 缓存了旧的 index.html,拿到的 HTML 里仍然指向旧的 app-old-hash.js,那么即便所有新资源都已上线,浏览器也会去请求旧文件。而 Vite 生成的文件名带内容 hash,理论上 hash 变了就是新 URL,应该不会命中旧缓存。但前提是获取 index.html 的请求本身不被缓存。

实际排查中后两个问题叠加了。宿主机部署了新文件,但容器看不到(inode 问题);即便容器能看到,用户端也可能先从 CDN 拿到缓存的旧 HTML,指向旧的资源 hash,然后再次请求这些旧资源。

修复方案与选择

Docker bind mount 的 inode 绑定问题有两个解决方向:

方案 A:重启容器(推荐)
重新启动 Caddy 容器会重新挂载卷,绑定最新的 inode。单次重启耗时少于 2 秒,操作简单,无需改动部署脚本。重启后验证容器返回的 hash 是否已更新为最新值。

方案 B:改用覆盖式同步
rsync --delete dist.NEW/ dist/ 直接覆盖文件,而不是整个目录改名。这样 dist 目录本身的 inode 保持不变,容器内的 bind mount 始终指向同一个 inode,但目录内的文件被替换。

方案 A 更简洁,决定采纳。但这只解决了第三层(bind mount)的问题。

还需处理第二层和第一层的缓存。CDN 边缘节点不一定完全遵守 origin 返回的 Cache-Control: must-revalidate, no-cache 头,不同地区节点的失效时间不同。必须主动向 CDN 服务商提交清缓存请求。

# 部署完成后,手动提交 purge 请求,涉及四个 URL
# https://<domain>/
# https://<domain>/index.html
# https://<admin-domain>/
# https://<admin-domain>/index.html

CDN 侧清缓存一般 5-10 分钟生效全网。对于浏览器磁盘缓存,用户拿到新的 index.html 后,浏览器会发现资源 URL(带新 hash)和本地缓存不符,自动重新请求,所以无需单独处理——只要确保上游返回的是新 HTML。

防回归

这个故障暴露了两个流程漏洞:

1. 部署验证不完整
部署脚本完成后没有自动验证生效。从现象看是"改样式不生效",实际排查才发现是系统问题,前面四次盲改毫无意义。

新增验证步骤:部署后必须从外网 curl 验证 hash,而不是从宿主机或容器内部检查。

2. 缓存链路的文档缺失
五层缓存各有各的失效条件和刷新手段,没有集中文档时,新同学很容易漏掉某一层。

后续在部署 SOP 中增加这份 checklist:

# 1. 执行部署(rsync + atomic mv)
... rsync apps/web/dist ... && mv dist.NEW dist ...

# 2. 重启容器(修复 inode 绑定)
sudo docker restart cpa-caddy
sleep 3

# 3. 验证容器返回新 hash
curl -sk --resolve <domain>:443:127.0.0.1 https://<domain>/ | grep 'src=' | head -1
# 应匹配 dist/index.html 里的 hash

# 4. 清 CDN 缓存(手动或 API)
# 进入 CDN 控制台,提交 purge 请求:
# <domain>/
# <domain>/index.html
# <admin-domain>/
# <admin-domain>/index.html

# 5. 等待 5-10 分钟后,从外网再次验证
curl -s https://<domain>/ | grep 'src='
# 应该是最新 hash

目前 release-connector.sh 只处理后端服务和链配置,前端改动不频繁,单独拉出一个 release-web.sh 脚本会更清晰,包含上述步骤。

隐蔽点总结

五层缓存中,最容易被忽视的是:即便下层的资源文件都更新了,上层缓存的入口 HTML 如果不更新,用户浏览器拿到的仍是指向旧资源 hash 的 HTML。因为 HTML 本身通常没有被标记为 immutable,且如果被 CDN 或浏览器缓存,就会导致"文件都对,但浏览器看不到新版本"的假象。只有 HTML 这一个引入点的 hash 改变了,整个依赖树才会指向新的资源。

这也解释了为什么"产物目录看着没问题,容器内也看着没问题,但用户端就是看不到"——问题不在文件是否存在,而在从启动到用户的整条链路中,任何一层缓存都可能吞掉更新信号。Docker bind mount 的 inode 绑定恰好是最隐蔽的那一层,因为容器内的 stat 调用看起来成功了,但返回的是旧 inode 的元数据。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。