我们把图片迁移到了 CDN,源站却依然在提供它们
我们的文档满是截图。每一个门户功能,都在明暗两种模式下、以两种宽度、跨十种语言各截一遍,加起来就超过了一百兆字节的图片。这些图片全都放在应用自己的静态打包里,从源站提供,而对于一百兆字节永不改变的图片来说,这里是个错误的位置。于是我们把它们迁移到 Cloudflare R2,并在前面放了一个 CDN,每个环境一个:生产环境用 cdn.authagonal.io,我们的开发站点则用一个对应的 CDN 主机。
应用在运行时通过查看自己被提供时所在的主机名,来挑选正确的 CDN 主机。如果页面在 authagonal.io 上,图片就来自 cdn.authagonal.io。简单,无需构建期配置,一条图片引用就能在两个环境里都正确解析。我们上线了它,看着浏览器干净利落地从 CDN 拉取每一张图片,然后再去看源站的流量。源站仍然在提供这些图片。每一张,都在首次加载时,和之前一模一样。
爬虫看到的页面,不是浏览器构建出来的页面
要解释原因,我得先解释我们的营销页面到底是什么。它们是一个单页应用,但单页应用在 JavaScript 运行之前只是一个空壳,而空壳对搜索引擎不利,对第一个有意义的内容出现所需的时间也不利。所以在构建期我们做预渲染:我们启动站点,在无头浏览器里打开每个页面,让它完整渲染,再把得到的 HTML 保存下来。我们最先提供的就是这份预渲染好的 HTML。它里面已经有了内容,爬虫看到的是一个真实的页面,用户立刻看到文字和图片,然后 JavaScript 接管。
问题就藏在这段描述里。预渲染是在 127.0.0.1 上运行站点的,那是构建机上的一个回环地址。所以当应用问「我在哪个主机名上,好让我挑一个 CDN」时,预渲染期间的答案是「一个本地 IP 地址」,它既不匹配 authagonal.io,也不匹配开发主机。运行时的主机推导,也就是那个聪明的部分,悄悄退回到它唯一剩下的选择:相对于源站的路径。而正是这个源站路径被冻结进了预渲染好的 HTML。
于是每一位访客拿到的 HTML 里都烤进了源站的图片 URL,浏览器便做了那件显而易见的事:在首次绘制时,从源站把它们取了下来。片刻之后,JavaScript 重新渲染页面,重新推导主机名,这次拿到了真实的那个,并把图片换成了 CDN。每张图片两次取回,第一次恰好打在我们正想减轻负担的那台服务器上,而且完全看不见,除非你正盯着源站的日志,因为页面看起来、用起来都完美无缺。
那个让情况恶化了十一分钟的修复
第一个修复是最诱人的那个。如果问题在于一个错误的 URL 被冻结进了预渲染好的 HTML,那就在预渲染期间根本不输出任何 URL。让快照里的图片源留空,等 JavaScript 运行起来、知道了真实主机名之后,再由它填上正确的 CDN URL。没有错误的 URL,也就没有对源站的取回。
它上线了,而且是错的,大约十一分钟后我们就把它换掉了。把预渲染好的 HTML 里的图片源留空,等于毁掉了我们做预渲染的全部理由。读取静态页面的爬虫现在看不到任何图片,而浏览器在 JavaScript 跑完之前也没有任何东西可展示,那恰恰是我们构建预渲染 HTML 想要避开的那条慢路径。我们是通过把图片从那个在 JavaScript 之前唯一存在的页面版本里拿掉,来消除双重取回的。那是拿一个流量问题去换一个搜索与首次绘制的问题,是一笔更糟的交易。
那十一分钟的教训是:预渲染好的 HTML 必须带着一个真实、可用的图片引用。这个 bug 从来都不是因为有一个 URL 存在,而是因为存在的是错误的那个 URL,把它留空只是拿一个缺陷去换另一个缺陷。我们真正需要的,是让正确的 URL 在预渲染时就能知道,而它做不到,因为在预渲染时谁也不知道会由哪个环境来提供这个页面。
在尽可能晚的那一刻再决定
正确的 URL 取决于请求的主机,而请求的主机在有请求之前是不得而知的。预渲染只发生一次,在构建期,产出一份在两个环境里都会被提供的文件。所以 URL 里那部分依环境而定的内容,无法在构建期决定。它必须按每个请求来决定,而整个技术栈里唯一能看到每个请求及其主机的,是位于边缘的 Web 服务器。
所以预渲染不再写出一个 URL,也不再写出一个空值。它写出一个占位符,一个字面的哨兵字符串 https://cdn.authagonal.io,摆在 CDN 主机原本的位置上。离开构建的预渲染 HTML 写的是 https://cdn.authagonal.io/screenshots/whatever.webp,它既不是一个源站 URL,也不是一个空的源。它是一个中间开了个洞的 URL。
nginx 在输出的途中把这个洞填上。它把请求的主机映射到正确的 CDN 基址,把非生产的主机映射到它自己的开发 CDN,把 authagonal.io 映射到 https://cdn.authagonal.io,把其他一切都映射到空,好让 URL 退回成相对于源站的形式,作为一个安全的本地回退。然后,在 HTML 向客户端流式传输的过程中,一条指令就把哨兵重写成那个值:
map $host $asset_cdn_base {
default "";
"~*example\.dev$" "https://cdn.example.dev";
"~*authagonal\.io$" "https://cdn.authagonal.io";
}
sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;
静态 HTML 现在离开源站时,就已经带着真实的 CDN URL 了,对提出请求的那个主机来说都是正确的。浏览器在首次绘制时直接从 CDN 取回。没有需要撤销的错误首次取回,因为并不存在错误的 URL,也从来不曾有过一个空的。爬虫看到的是一个带着真实图片链接的完整页面。一份文件服务两个环境,而两者都不需要单独的构建。
响应里哪些部分被重写,这里有一点小小的巧妙。重写被限定在 HTML 上,这是 nginx 的默认行为。这很重要,因为同一个哨兵字符串也出现在 JavaScript 打包里,作为主机推导代码的回退分支。在客户端上那个分支是死代码,因为浏览器有一个真实的主机名,永远不会输出哨兵,所以我们特意不希望 nginx 去碰那个打包。把重写留在它仅限 HTML 的默认值上,就免费得到了这一点。顺带一提,我们还学到:把默认值显式写出来,比干脆不写它更糟,因为一条多余的声明会让 nginx 就重复项发出警告。
为什么不干脆在启动时把文件重写一次
显而易见的反对意见是:对一个只有两种可能的值来说,这是每个请求都要做的一大堆活儿。为什么不在容器启动时把 HTML 重写一次,把正确的主机烤进去,然后提供一份不带任何过滤器的静态文件呢?
因为容器无法写入它自己。这个静态站点在 nginx 里以一个非 root 用户运行,根文件系统只读,每一项 Linux 权能都被剥离,只有一个小小的、可写的暂存目录,供 nginx 自己的临时文件使用。这是一种刻意的加固姿态:一个对外提供不可信流量的 Web 服务器,不应该能够修改它所提供的文件,好让一个找到落脚点的攻击者,找不到任何可以重写的东西。在启动时重写 HTML,恰恰需要我们特意拿掉的那种写权限。这个只读文件系统不是一个要绕过去的障碍,它是一个我们想要的属性,它干净利落地排除了启动时重写这条路。按每个请求做的重写根本不碰任何文件,这正是它为什么能与一台不拥有任何可更改之物的服务器相容。
我们也不想在构建期把 CDN 主机烤进去,从而产出两份不同的图片打包、每个环境一份,因为那会把我们刚刚摆脱掉的、依环境而定的构建又重新引入回来。主机推导的意义,就在于为两个环境提供同一份产物。哨兵守住了这个承诺:一次构建,一份文件,环境在边缘、在尽可能晚的那一刻才被解析。
预渲染会冻结环境相关的值,所以要在边缘解析它们
预渲染冻结的是一个瞬间。你的应用凡是通过查看自身环境来决定的任何东西,都会在预渲染时,针对构建的环境被决定下来,而那是一个回环地址,不是任何人真实的主机。所以一个在运行时正确的值,在被拍成快照的那一刻就变错了,而且它是无声地失败的,因为预渲染好的页面照样能渲染出来,只不过指向了错误的地方。
通用的修复办法,是在预渲染期间根本不去解析任何依环境而定的值。输出一个占位符,让它原封不动地穿过静态产物,再在真正能看到环境的那一层去解析它,而对一个按请求而定的值来说,那一层就是边缘。这是延迟绑定,只不过被用在了 HTML 文件里的一个字符串上:让这个洞在每一个不知道答案的阶段都保持敞开,再在那个唯一知道答案的阶段把它填上。
如果你更希望自己身份提供方的文档和资源本来就已经正确地从边缘提供,那正是 Authagonal 已经替你争论清楚的众多小事之一,好让你把这场争论留给你自己的产品。