大数跨境

nginx sub_filter:替换了 HTML,也改变了缓存行为

nginx sub_filter:替换了 HTML,也改变了缓存行为 运维开发与AI实战
2026-10-13
3
导读:从一次旧页面排查出发,解释 sub_filter 如何影响响应头,以及为什么 SPA 入口需要显式缓存策略。

一次页面排查中,我们遇到这样的现象:浏览器打开的是旧 HTML,里面引用的带哈希 JavaScript 文件已经不在当前部署中。重启浏览器后,页面恢复正常。

检查响应时,又发现一个容易忽略的差异:静态资源有 ETag 和 Last-Modified,HTML 却没有,连 Cache-Control 也没有。

为什么同一台 nginx 会返回不同的响应头?线索藏在 sub_filter 中。但要理解缓存风险,还需要分清两件事:响应有没有验证器,以及缓存能否直接复用它。

sub_filter 做了什么

sub_filter 用来替换响应正文中的字符串。它属于 ngx_http_sub_module,模块源码文件名则是 ngx_http_sub_filter_module.c。它做的是忽略大小写的字符串匹配,不解析 HTML,也不是正则替换。nginx 官方模块文档

例如,同一份前端构建需要服务多个站点,分享卡片的图片地址又需要包含站点域名。构建时可以写入占位符:

<meta property="og:image" content="__SITE_ORIGIN__/share.png">
<meta name="twitter:image" content="__SITE_ORIGIN__/share.png">

nginx 在发送 HTML 前替换它:

location / {
    sub_filter '__SITE_ORIGIN__' 'https://$site_host';
    sub_filter_once off;
    try_files $uri $uri.html /index.html;
}

这里的 $site_host 是示例变量,需要另行定义。用于公开站点时,应通过允许的域名列表选择值,未知域名使用固定回退值。仅检查字符串符合 DNS 格式,不能证明域名可信;直接使用任意请求 Host,可能把攻击者控制的地址写入分享标签。

sub_filter_once 默认是 on,每条规则只替换一次。占位符出现在两个标签中,因此这里使用 off。默认处理类型就是 text/html;通常无需重复配置。模块也不是默认编译启用的模块,部署前可用 nginx -V 检查构建参数。

为什么 HTML 的响应头变了

响应正文可能经过替换,原始静态文件的长度和验证信息便不一定适合最终响应。以 nginx 官方当前源码为依据,对于进入替换流程的主请求,响应头过滤逻辑会清除 Content-Length;默认还会清除 Last-Modified 和 ETag。nginx 官方源码

有两个细节需要注意。

首先,清理发生在响应头处理阶段。对于进入该过滤流程的主请求,nginx 不会先等正文扫描完,再判断是否真的命中了占位符。因此,“只有实际替换成功才删除响应头”并不准确。

其次,sub_filter_last_modified on 的行为不是简单恢复所有原始信息。官方文档说明它保留原响应的 Last-Modified;源码进一步显示,它会把已有的强 ETag 转成弱 ETag,例如从 "abc" 变成 W/"abc",并不会生成新的、基于替换后正文的 ETag。Content-Length 仍然会被清除。

弱 ETag 不保证响应逐字节一致。更不能因为它变弱,就认为动态替换结果已经有了正确的版本标识。如果替换值改变而底层文件不变,文件验证器可能无法反映这个变化。

另外,sub_filter 不会主动删除 Cache-Control。HTML 没有缓存策略,需要继续检查 add_header、继承关系及其他配置,不能全部归因于正文替换。

没有 Last-Modified,不等于不会缓存

HTTP 缓存有两个不同的问题。

新鲜度决定缓存能在多长时间内直接复用响应。Cache-Control: max-age=600 表示有效期为十分钟。

验证器帮助缓存询问服务器:存下来的版本是否仍然有效?常见验证器是 ETag 和 Last-Modified。验证通过时,服务器可以返回 304 Not Modified,省去正文传输。

没有显式新鲜度时,符合条件的响应可以使用启发式缓存。RFC 9111 提到,缓存可以参考 Last-Modified;文件距上次修改时间的 10% 是一个典型比例,但标准没有规定所有缓存必须使用这个算法。RFC 9111:HTTP Caching,§4.2.2

所以,一个十天前修改的文件,可能被某种缓存算法视为可以直接复用一天。这只是算法示例,不能当作所有浏览器的固定行为。

同样,移除 Last-Modified 不能保证浏览器或代理每次都请求服务器。 它减少了一个常见的估算依据,却没有建立明确的缓存约束。不同缓存实现仍可能采用不同策略。

把这项副作用当作 HTML 缓存策略,风险在于:以后移除正文替换、修改验证器设置,页面的缓存行为可能随之改变,而配置里没有一句话说明原本的意图。

SPA 为什么尤其容易受影响

单页应用(SPA)的入口 HTML 通常引用带内容哈希的资源:

<!-- 演示文件名,不对应实际项目 -->
<script src="/assets/main.oldhash.js"></script>

发布新版本后,新 HTML 引用新文件名。如果浏览器仍使用旧 HTML,而服务器已删除旧资源,资源请求就可能失败。

沿下图蓝色入口向下读:问题发生在旧 HTML 与新部署之间。红色终点强调的是旧资源已删除这个条件,而不是“缓存存在就必然白屏”。

旧 HTML 仍引用旧资源时,删除旧文件会让版本错配变成加载失败。

缓存策略之外,部署也应保留一段时间的旧哈希资源。已经打开的页面可能稍后才加载旧版本的动态模块,入口 HTML 的 no-cache 无法解决这一类请求。

把 HTML 的缓存策略写清楚

对于需要及时获取新版本的公共 SPA 入口,可以显式设置:

# 示意配置:将路径替换为实际部署路径。
location / {
    # 文件需事先存在,并包含本项目的安全响应头配置。
    include /etc/nginx/snippets/security-headers.conf;
    add_header Cache-Control "no-cache" always;

    sub_filter '__SITE_ORIGIN__' 'https://$site_host';
    sub_filter_once off;
    try_files $uri $uri.html /index.html;
}

no-cache 允许存储响应,但要求缓存复用前成功验证。它与禁止存储的 no-store 不同。没有可用验证器时,通常需要重新获取完整 HTML;对于较小的入口文件,这往往是可以接受的成本。RFC 9111:no-cache

这里重新引入安全响应头有明确原因:在默认继承规则下,只要当前 location 定义了自己的 add_header,就不再继承上层的 add_header。添加缓存头时,可能意外丢失 CSP 等安全响应头。nginx 1.29.3 起支持 add_header_inherit merge 来调整此行为,但使用前要确认实际版本和现有配置,避免重复添加响应头。nginx 官方响应头文档

带内容哈希的资源可以采用长缓存,前提是同一路径的文件内容保持不变。例如,在独立的资源 location 中设置 public, max-age=31536000, immutable。缺失资源应返回真实的 404,避免回退成 HTML 后被长期缓存;长期策略也应只用于成功的资源响应。

如果页面包含个人信息或依赖登录身份,需要另行设计 private 或 no-store 策略,不能直接套用公共入口的设置。

配置后怎么检查

先在实际容器或服务器中检查版本、编译参数与配置:

nginx -v
nginx -V
nginx -t

再使用 GET 检查最终响应。下面的域名和路由均为示例:

curl -sS -D headers.txt -o page.html https://example.com/app

确认 HTML 返回 Cache-Control: no-cache,安全响应头仍然存在,正文中的占位符已经替换。还要分别检查 /index.html、实际 SPA 深层路由和哈希资源,因为它们可能匹配不同的 location。

经过 CDN 时,应分别观察源站和公开入口。新加的响应头不会自动召回所有已被视为新鲜的旧缓存,已有缓存需要结合平台能力处理。

替换失败时,也要检查压缩。sub_filter 不会自行解压 gzip 正文。代理上游可按需要使用 proxy_set_header Accept-Encoding ""; 请求未压缩内容;启用 gzip_static 时,应确认 HTML 没有绕过替换流程直接使用预压缩文件。最终应同时检查响应头和实际正文,不能仅凭配置文本判断成功。

如果配置由 shell heredoc 生成,还要区分两层变量:nginx 变量通常需要写成 \site_host,让它保留到运行时;需要 shell 展开的变量则不转义。这取决于 heredoc 是否使用带引号的结束标记,不能机械地给所有 `` 加反斜杠。

这次现象能说明什么

重启浏览器后恢复,是客户端状态可能参与故障的线索。它不能单独证明旧 HTML 来自内存缓存,也不能排除 Service Worker、页面恢复、代理或其他因素。排查仍需要查看故障时的 Network 记录、响应来源和资源请求。

因此,我不会把这次旧页面直接归因于 sub_filter。这次排查更明确的收获是:正文替换和缓存策略应分别表达。sub_filter 负责修改内容,Cache-Control 负责规定响应如何被复用,部署策略负责保证旧页面仍能找到它引用的资源。

参考资料

  • nginx:ngx_http_sub_module 指令说明

  • nginx:sub_filter 模块官方源码

  • nginx:add_header 与响应头继承规则

  • RFC 9111:HTTP Caching

【声明】内容源于网络
0
0
运维开发与AI实战
DevSecOps工程师,分享AI, Web3, Claude code开发的经验与心得。希望能帮大家解决技术难题,提升开发效率!自身从与大家的沟通中获得进步,欢迎留言交流,一起成长!
内容 2548
粉丝 0
运维开发与AI实战 DevSecOps工程师,分享AI, Web3, Claude code开发的经验与心得。希望能帮大家解决技术难题,提升开发效率!自身从与大家的沟通中获得进步,欢迎留言交流,一起成长!
总阅读71.3k
粉丝0
内容2.5k