Mastodon 反向代理的激进缓存策略Aggressive caching for a Mastodon reverse proxy: what to cache, what to never cache, and why content negotiation will eventually betray you
为 Mastodon 实例设置反向代理缓存可以显著提升性能,但需要精细的规则来避免副作用。文章详细探讨了哪些 API 响应和静态资源可以被安全缓存,以及哪些必须保持动态。特别强调了 HTTP 内容协商在缓存策略中可能带来的陷阱和“背叛”。作者分享了针对 Fediverse 网络特性的 HAProxy 和 Nginx 缓存配置实践。这些策略能有效降低后端服务器的负载。
Stefano Marinelli
我之前写过关于在 snac 前面加一层缓存的文章,最近也写过关于在 FediMeteo 前面部署 HAProxy 层的文章。基本思路始终如一:反向代理应该吸收那些重复的、公开的、根本没必要到达应用服务器的工作。
这篇文章将同样的思路应用到了一个更为“喧闹”的邻居身上:一个 Mastodon 实例。该实例是 mastodon.bsd.cafe,代理采用运行在 FreeBSD 上的 nginx,下面的配置正是我目前在生产环境中运行的方案。
Mastodon 在各个方面都比 snac 更为沉重。它背后有 Puma 和 Sidekiq,具有更多的端点、更多的流处理、更多的联邦模式,以及一个让一切变得复杂的特定特征:它在相同的 URL 上提供多种资源表述。同一路径可能会向浏览器返回 HTML,向远程实例返回 ActivityPub JSON,有时还会向 API 客户端返回纯 JSON。如果代理仅仅将 URL 视为单一内容,迟早会把错误的内容返回给错误的客户端。
下文的大部分工作正是源于这一简单的观察。
如果我必须用一句话来总结整篇文章,那就是:
Mastodon 不仅仅是一个网站。它是一个网站、一个 API,同时也是一个 ActivityPub 服务器,所有这些都共享相同的 URL。
此配置中的其他所有内容——缓存键、变体、绕过规则、诊断头——都是围绕这一核心事实展开的。
朋友发的一条热门嘟文被转发了。二十个联邦实例在同一秒内请求同一个 ActivityPub 对象。浏览器同时获取同一 URL 的 HTML 版本。如果代理只看到“一个 URL”,它最终会背叛你:远程实例会收到 HTML,浏览器会收到 ActivityPub JSON,而你会花掉整个下午的时间,疑惑为什么你的时间线在三个不同的服务器上看起来全毁了。我曾度过那样一个下午。我不建议你也去体验一番。
在此之前的前提假设
在介绍具体指令之前,这套配置对实例有一些预设条件。如果其中任何一项与你的实际环境不符,这些指令依然有意义,但在调整应用之前,你必须阅读文末的注意事项。
第一个假设是 AUTHORIZED_FETCH(安全模式)被禁用。在关闭安全模式的情况下,所有缓存在代理层的 ActivityPub GET 响应都是公开的,并且无论请求方是谁,响应内容都是相同的。而在开启安全模式的情况下,Mastodon 可以根据发起请求的远程参与者合法地返回不同的响应体,此时在代理层盲目缓存这些响应,往好了说是浪费资源,往坏了说就会成为缓存投毒的攻击面。
这绝非危言耸听。CVE-2026-25540(已在 Mastodon 4.3.19、4.4.13 和 4.5.6 版本中修复)正是此类错误,只不过它发生在 Mastodon 自身的 Rails.cache 中:置顶帖子和精选标签的端点具有依赖于请求者的 ActivityPub 响应,但其缓存键却没有包含请求者信息。这个 CVE 虽然不直接适用于 nginx 缓存,但其背后的教训同样适用。除非将调用者作为缓存键的一部分,否则不要缓存依赖于调用者的内容。每当你抱着“以防万一”的心态想要缓存某个联邦端点时,请务必牢记这条规则。
第二个假设是 /system/ 或 /media_proxy/ 路径后面没有使用签名 URL 的存储后端。如果这些路径会重定向到生命周期很短的预签名 S3 或 SeaweedFS URL,那么我下面设置的 TTL 就太长了:nginx 会毫不犹豫地缓存一个指向已过期 URL 的重定向。
第三个假设是,联邦流量使用的是 HTTP Signatures,而不是 HTTP Authorization 请求头。Mastodon 使用 Signature 请求头对联邦 GET 请求进行签名。下文基于 Authorization 的跳过缓存规则只会捕获 API token,而不会捕获经过签名的联邦流量。如果你启用了 AUTHORIZED_FETCH,就必须为 $http_signature 添加一条明确的跳过规则。
我特意强调这些假设,是因为只有当这些假设成立时,接下来的配置在内部逻辑上才是自洽的。
mastodon.bsd.cafe 前面的代理(proxy)负责三项工作:
TLS 终结,对高开销端点(特别是联邦交互密集的集合和默认的公共路由)进行微缓存(microcaching),以及对不可变资源和用户媒体进行长期缓存。
这样做的目的并不是要取代 Mastodon 内部的 Rails 缓存。其真正用意是吸收那些突增的联邦流量和重复的资源获取请求,否则这些请求每一次都会直接冲击 Puma 和 Rails。
这个策略经过了刻意的分层设计:对带有指纹(fingerprinted)的资源设置极长的 TTL,对用户上传的媒体设置中等长度的 TTL,对遭受频繁访问的动态页面和联邦端点设置极短的微缓存,而对于任何私有的、需身份验证的、依赖特定行为者(actor)或不安全的内容,则设置明确的绕过规则。
每一个可缓存的层都针对内容协商(content negotiation)进行了正确的键值设置。这是最关键的部分。
缓存区域
所有的 Mastodon location 共享一个单独的缓存区域:
proxy_cache_path /var/cache/nginx/mastodon
levels=1:2
keys_zone=mastodon_cache:200m
max_size=20g
inactive=24h
use_temp_path=off;200M 的 keys zone 在内存(RAM)中保存了大约 160 万条条目的元数据。缓存主体(body)在磁盘上最大可增长至 20G。这两个数字是相互独立的:键存放在共享内存中,主体存放在文件系统中,而缓存键将它们联系起来。
inactive=24h 会将一天内未被请求的任何内容驱逐,即使还有可用空间。这是有意为之的。我不希望那些又长又冷的陈旧条目永远盘踞在缓存中。我希望工作集(working set)保持热度,而其余的部分则逐渐消退。
use_temp_path=off 这个配置虽小但很重要。默认情况下,nginx 会将缓存响应写入一个临时文件,然后将其重命名到目标位置。如果临时路径和缓存路径位于不同的文件系统,那么成本低廉的重命名操作就会变成真正的拷贝操作。设置 use_temp_path=off 会将临时文件直接放在缓存目录下,从而避开了这个陷阱。正是这种细节,通常直到某些东西慢得可疑时才会有人提及。
在此配置的所有 map 指令中,只有一个真正发挥了不可替代的作用。就是这个:
map $http_accept $mastodon_cache_variant {
default "default";
"~*application/activity\+json" "activitypub";
"~*application/ld\+json" "activitypub";
"~*application/json" "json";
"~*text/html" "html";
}Mastodon 会根据 Accept 请求头,为同一个 URL 提供不同的响应主体。像 /@user/123456789 这样的状态 URL,向浏览器返回的是渲染后的 HTML,而向另一个联邦实例返回的则是一个 ActivityPub 对象。如果你仅靠 URL 进行缓存,那么最先到达的请求将决定缓存内容,随后的请求就会收到错误的内容类型。结果就是实例之间开始联邦传输 HTML,浏览器开始下载 JSON,而这种故障极其隐蔽,足以浪费你几个小时的时间去排查。
这个 map 指令将 Accept 请求头规范化为四个桶(bucket)——activitypub、json、html 和 default——其结果会被合并到每一个进行内容协商的 location 的缓存键中:
proxy_cache_key "$scheme$host$request_uri|accept=$mastodon_cache_variant";将等效的 MIME 类型合并是有意为之的。application/activity+json 和 application/ld+json 都被映射到了 activitypub,因为将它们分到两个不同的缓存桶中会导致缓存碎片化,而这在运维上并没有任何实际收益。
我想明确说明一个细节:我没有把 $request_method 包含在缓存键中。出于缓存目的,nginx 默认已经将 HEAD 转换为 GET,这正是我期望的行为。对 /@user/123 发起的 HEAD 请求,应当与对同一 URL 发起的 GET 请求命中相同的缓存条目。把请求方法加进缓存键只会徒增隔离,毫无益处。
在部署过程中,我还会将选定的变体作为响应头暴露出来:
add_header X-Cache-Variant $mastodon_cache_variant always;这个响应头是为了在生产环境中验证行为。一旦配置验证无误,它可以被移除,但我倾向于保留它。一个正常工作的缓存应该是可见的。不可见的缓存可能是正确的,但也可能默默地在出错,而我宁愿能随时掌握情况。
这是第一个真正的陷阱,我想花点时间详细说明,因为我第一次配置类似环境时就在这里栽了跟头。
除了 proxy_cache_key 之外,nginx 还会遵循上游的 Vary 响应头。如果 Mastodon 发送了 Vary: Accept,或者更糟的 Vary: Accept, Cookie, ...,那么我精心标准化的变体键就会与 nginx 原生的 Vary 处理机制结合。结果就是,缓存可能依然会根据完整的、未标准化的 Accept 头进行分片——这完全违背了使用变体映射的初衷。
在较旧或未打补丁的 nginx 版本中,还存在另一种非常具体的故障模式。nginx 会将 Vary 的值存储在一个固定大小的缓存元数据字段中。在历史上,该字段的长度限制是 42 字节,这个长度短得出名,甚至让人不禁怀疑这是在致敬道格拉斯·亚当斯(Douglas Adams)。现代版本的 nginx 已将此限制提高到了 128 字节,这足以应对常见情况,但依然小得令人惊讶。如果你的上游发出了一个很长的 Vary 头,那么超出该限制的部分会被视为 Vary: *,这意味着响应根本不会被缓存。你唯一能看到的信号就是错误日志中的一条 critical 级别记录,除非你刻意去查找,否则根本不会注意到它。
这两种情况的运维教训是一样的:如果你依赖自己标准化的变体键,就不要想当然地认为上游的 Vary 是无害的。检查你的 nginx 版本,检查你的错误日志,并通过 X-Cache-Status 和 X-Cache-Variant 来验证缓存行为。
在那些将变体映射作为我所关注的缓存维度的 location 块中,我会明确承担起责任:
proxy_ignore_headers Vary;这会告诉 nginx 停止使用上游的 Vary 来保护我。只有当我自己的缓存键和请求标准化涵盖了所有重要的响应维度时,这样做才是没问题的。特别是,我要确保后端不会背着我在 Accept-Encoding 上做文章,从而生成压缩和未压缩的变体。避免这种情况最干脆的做法是,根本不将 Accept-Encoding 转发给后端,而是让前端 nginx 自行处理压缩:
proxy_set_header Accept-Encoding "";这类决定我倾向于明确表达。忽略 Vary 并不是什么魔法,而是一种责任,并且应当配合相应的替代规则一起使用。
与其构建一个庞大且复杂的布尔值来判断何时绕过缓存,我更倾向于将逻辑分解为多个小型的、正交的映射。当必须跳过缓存时,每个映射的值为 1,而最终的决定则是它们所有值的逻辑或(OR)。
map $request_method $skip_cache_method {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $skip_cache_auth {
default 1;
"" 0;
}
map $http_cookie $skip_cache_cookie {
default 1;
"" 0;
}
map $uri $skip_cache_uri {
default 0;
~^/auth 1;
~^/oauth 1;
~^/settings 1;
~^/admin 1;
~^/api/v1/custom_emojis$ 0;
~^/api/v1/instance$ 0;
~^/api/v2/instance$ 0;
~^/api/v1/trends/tags$ 0;
~^/api/oembed$ 0;
~^/api/ 1;
}其中的原因很简单。只有 GET 和 HEAD 请求是可缓存的;其他所有请求,包括 POST、DELETE、PUT 以及 ActivityPub 投递,都必须直接穿透。任何带有 Authorization 请求头的都是带有 token 的 API 调用,这些绝不是公开的。任何带有 cookie 的请求都可能是已登录用户的流量,缓存已登录页面会导致不同用户的个人时间线发生泄露。认证流程、设置、管理后台以及大多数 API 通过 URI 绕过缓存,而一小部分经过精心挑选且更新缓慢的公开 API 端点则被允许通过缓存。
我想强调一个重要的注意事项:Authorization 映射并不能捕获经过签名的联邦 GET 请求。Mastodon 联邦通信使用 HTTP Signatures,这意味着相关的请求头是 Signature。如果启用了 AUTHORIZED_FETCH,你必须添加一个并行的映射:
map $http_signature $skip_cache_signature {
default 1;
"" 0;
}然后将其包含在 proxy_cache_bypass 和 proxy_no_cache 中。请在启用安全模式之前执行此操作,而不是之后。
这些映射在每个可缓存的位置(location)中结合使用:
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;这两个指令都是必需的。proxy_cache_bypass 的意思是“不要为该请求读取缓存”。proxy_no_cache 的意思是“不要将该响应写入缓存”。如果没有 proxy_no_cache,已登录用户的响应仍然可能会污染匿名用户的缓存。如果没有 proxy_cache_bypass,本应直接发送到后端的请求可能依然会接收到缓存的匿名响应。我每次都会同时保留这两个指令。
大多数位置共享一个通用的代理基线配置。这里没有什么高深的东西,但如果缺少了其中的任何一行,其余的配置在悄无声息间就无法达到预期的效果。
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_set_header Accept-Encoding "";proxy_http_version 1.1 和 proxy_set_header Connection "" 对于上游的 keepalive 至关重要。如果没有它们,nginx 可能会在上游使用 HTTP/1.0 语义,并在每次请求时发送 Connection: close,这会使 upstream 块上的 keepalive 指令的作用大打折扣。
proxy_set_header Accept-Encoding "" 会使后端响应保持未压缩的状态,这样 nginx 就可以缓存单一的表示形式,并自行处理面向客户端的压缩。它还可以防止由 Vary: Accept-Encoding 引起的意外缓存碎片化,否则尽管有 variant map,这种情况依然会悄然发生。
这些设置并不令人兴奋,而且它们本就不该令人兴奋。基础设施中有趣的部分,并不总是那些应该标新立异的部分。
在我的配置中,Mastodon 的 server 块最终包含七种不同的请求配置方案。其中六个会进行缓存;另外一个明确不缓存,因为流式传输并不是一种可缓存的工作负载。
我没有将它们归并在一个带有巨大 if 块的 location / 下。我更倾向于将每个配置方案保留在各自独立的 location 中,即使它们之中有些看起来很相似。当生产环境出现问题时,我希望能够直接定位到某一个 location 并进行推理排查,而不需要把其余的配置全装在脑子里。
location ~ ^/(assets|packs|emoji)/ {
proxy_cache mastodon_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_valid 200 301 302 7d;
proxy_cache_valid 404 10m;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_pass http://$custom_upstream;
}这些路径是基于内容寻址的。Webpack 会使用哈希为文件名添加指纹,因此新的部署会发布新的 URL,而旧的 URL 依然有效。7 天的 TTL 是安全的,因为 /packs/js/common-abc123.js 在同一个 URL 下绝不会变成不同的内容。如果内容变了,它就会有一个新的 URL。
404 响应会获得短短 10 分钟的 TTL,以便在资源暂时缺失时能够快速恢复。
开启 proxy_cache_lock 是为了防止惊群效应(thundering herd)。当一个热门资源未被缓存且十个客户端同时请求它时,会有九个客户端等待第一个请求填充缓存,而不是所有十个客户端都去猛烈冲击后端。我非常喜欢这个指令。正是这种不起眼的小开关,悄无声息地消除了一整类问题。
proxy_cache_use_stale 结合 proxy_cache_background_update 构成了 stale-while-revalidate 模式。如果某个缓存条目已过期,但 Mastodon 响应缓慢或短暂宕机,nginx 可以提供过期的副本并进行异步刷新。对于静态资源来说,这几乎总是正确的权衡。在 URL 不变的情况下,资源实际上并没有发生改变,多缓存几个小时的过期数据不会造成什么不良影响。
location ~ ^/system/(accounts/avatars|media_attachments/files|custom_emojis/images)/ {
proxy_cache mastodon_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_valid 200 302 6h;
proxy_cache_valid 404 5m;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_pass http://$custom_upstream;
}头像、附件缩略图和自定义表情实际上也属于内容寻址,因为文件路径包含了一个 ID。但它们仍然可能被替换或删除,因此其 TTL 比静态资源更保守:为六小时而不是七天。
302 状态也会被缓存,因为 Mastodon 可能会重定向到另一个存储位置,而且这种重定向通常足够稳定,可以缓存数小时。
这也是关于签名 URL 的注意事项真正关键的地方。如果你曾在 /system/ 路径后配置使用签名 URL 的后端,那么此 TTL 必须短于签名 URL 的有效期,否则 nginx 最终会提供一个指向已失效 URL 的重定向。在 mastodon.bsd.cafe 上我没有使用签名 URL,所以六小时没问题。
location ~ ^/(users|ap/users)/[^/]+/statuses/[0-9]+/replies {
proxy_cache mastodon_cache;
proxy_cache_key "$scheme$host$request_uri|accept=$mastodon_cache_variant";
proxy_ignore_headers Vary;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_lock on;
proxy_cache_lock_timeout 1s;
proxy_cache_lock_age 5s;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_pass http://$custom_upstream;
}这是我调整得最谨慎的 location。当一条状态开始疯传时,数十个联邦实例会在同一秒内轮询 /replies 以构建它们的线索视图。同一个 URL 必须向浏览器提供 HTML 线索视图,并向远程实例提供 ActivityPub OrderedCollection,因此变体键(variant key)在这里至关重要。
30 秒的微缓存(microcache)可以吸收流量尖峰,而不会提供有明显影响的过期数据。在联邦线索中延迟 30 秒出现的回复对人类用户来说通常是不可察觉的,但它为后端带来的减压效果却是显而易见的。
锁设置可以使后端负载和延迟保持在一定范围内。proxy_cache_lock_timeout 1s 限制了排队的请求在锁后等待的时长。如果超时,它们将直接前往 upstream,但它们的响应不会被存储在缓存中,这可以防止失控的惊群效应(thundering herd)堵塞缓存填充路径。proxy_cache_lock_age 5s 防止某个缓慢的缓存填充请求永远垄断填充路径;如果持有锁的请求在 5 秒后仍未完成,nginx 可以允许另一个请求前往 upstream 进行重试。
目前,在我仍在验证部署情况时,我保持此 location 的 proxy_cache_use_stale 处于关闭状态。这是一种刻意的调试策略,而非永久选择。stale-while-revalidate 在生产环境中非常有用,但在上线阶段,当我还试图了解系统时,它可能会掩盖 upstream 的问题。一旦系统行为稳定,生产环境版本将是:
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;location ^~ /media_proxy/ {
proxy_cache mastodon_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 10m;
proxy_cache_valid 301 302 10m;
proxy_cache_valid 404 1m;
proxy_ignore_headers Cache-Control Expires Vary;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_pass http://$custom_upstream;
}Mastodon 的 /media_proxy/ 用于获取远程媒体,以便客户端不会将其 IP 地址泄露给远程服务器。无论 Accept 请求头为何,响应都是相同的,因此缓存键故意省略了变体。将媒体代理响应拆分到 html、json、activitypub 和 default 存储桶中只会浪费存储空间。
在这里配置 proxy_ignore_headers Cache-Control Expires Vary 是刻意为之的。Mastodon 可能会发出保守的缓存头,或者根本不发送,我希望无论后端返回什么,代理都能强制执行短暂的本地 10 分钟缓存策略。
Set-Cookie 不在忽略列表中。nginx 默认拒绝缓存带有 Set-Cookie 的响应,这一机制仍然适用,而这正是我想要的。这是一个安全网,我不想仅仅为了提高一点缓存命中率就禁用它。
^~ 前缀是一个非常实用的小细节。一旦这个 location 匹配成功,nginx 就会停止评估正则表达式 location。媒体代理的流量可能会很大,跳过后续的正则匹配是一个微小但免费的收益。
location ~ ^/(users|ap/users)/[^/]+/(followers|following) {
proxy_pass http://$custom_upstream;
}这是一个纯粹的代理,没有缓存。我想明确指出,这是一个经过考量的决定,而不是疏忽遗漏。
/users/<name>/followers 和 /users/<name>/following 接口存在大量分页,随着用户的关注和取关频繁变动,而且联邦网络爬虫的查询方式会导致缓存键随着页面和游标激增。预期的缓存命中率很低,提供过期社交图谱数据的风险不容忽视,而且缓存它们的成本——无论是存储开销还是心智负担——都不划算。
如果远程实例开始疯狂请求这些端点,正确的做法是使用 limit_req_zone 进行速率限制,而不是拿缓存来充当限流器。
默认 location:微缓存与无缓存的流式传输
location / {
proxy_cache mastodon_cache;
proxy_cache_key "$scheme$host$request_uri|accept=$mastodon_cache_variant";
proxy_ignore_headers Vary;
proxy_cache_valid 200 10s;
proxy_cache_valid 301 302 1m;
proxy_cache_valid 404 10s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_pass http://$custom_upstream;
}所有未被更具体的 location 匹配到的请求都会落入这里:个人资料、单条状态、关于页面、公共时间线,以及许多 ActivityPub 对象的抓取请求。
对于 200 响应,TTL 仅为 10 秒。这足以在一条热门嘟文被转发或从其他地方链接过来时,消除重复的请求洪峰,同时又不会让人类访客觉得页面内容过期。
坦白说,较短的 TTL 依然会消耗 CPU。对于持续有流量的 URL,10 秒的微缓存意味着后端每分钟会重新生成该条目 6 次。这比由 Rails 处理每一个请求要好得多,但也并非毫无开销。如果你的后端无法轻松应对这种情况,请调高 TTL,或者在这些动态路径上启用 stale-while-revalidate。
proxy_next_upstream 配合 proxy_next_upstream_tries 2 是故障转移的触发器。如果主节点返回 502、503、504 或超时,nginx 会在备用节点上进行重试。重试链最多限制为两次尝试,这样出现故障的 upstream 就不会无限期地挂起请求。
在 http 层级:
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}在 server 块中:
location /api/v1/streaming {
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://$custom_upstream;
}流式传输是基于 WebSocket 和 SSE 风格的端点。必须关闭缓冲(buffering),否则代理可能会在等待缓冲区填满的过程中暂留消息。Upgrade 和 Connection 请求头由 $connection_upgrade 变量驱动,该变量仅在客户端实际发送了 Upgrade 头时才为 upgrade。这样,发往同一路径的非 WebSocket 请求就不会导致其 Connection 头被篡改。
长达一小时的读取和发送超时设置,使得长连接流在静默期内也能保持打开状态。
这里没有缓存。流式传输不是一种可缓存的工作负载,试图将其变成可缓存的,这种想法听起来可能很聪明,但也就能维持三十秒的热度。
Upstream 与故障转移
upstream mastodonbsdcafe {
server 192.168.123.33 max_fails=3 fail_timeout=30s;
server 192.168.122.133 backup;
keepalive 64;
}主后端位于另一台 VPS 上;备用后端则位于反向代理旁边的 jail(隔离环境)中。连续失败三次后,主节点会被标记为宕机 30 秒。流量会切换到备用节点,然后在时间窗口过后 nginx 会再次尝试主节点。
keepalive 64 为每个 worker 进程维护最多 64 个到 upstream 的空闲 TCP 连接。在繁忙的实例上,这能节省实实在在的握手开销,但这前提是代理连接确实能保持打开状态。这就是为什么共享的代理设置中包含了 proxy_http_version 1.1 和 proxy_set_header Connection ""。如果没有这些配置,upstream 的 keepalive 效果将大打折扣。
我还使用了一个间接层:
map $remote_addr $custom_upstream {
default mastodonbsdcafe;
}如今,所有流量默认都指向主 upstream 组。设置这个 map 是为了在调试时能将特定的客户端 IP 固定到特定的 upstream,或者在测试主服务器时将管理员的连接路由到备用服务器。留着它没有任何开销,而且已经不止一次帮我省了时间。
我记录了什么以及为什么记录
log_format detailed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time '
'uct=$upstream_connect_time '
'uht=$upstream_header_time '
'urt=$upstream_response_time '
'us=$upstream_status '
'ua=$upstream_addr '
'cache=$upstream_cache_status '
'variant=$mastodon_cache_variant';
access_log /var/log/nginx/access.mastodon.bsd.cafe.log detailed;
add_header X-Cache-Status $upstream_cache_status always;
add_header X-Cache-Variant $mastodon_cache_variant always;这种日志格式是专门为缓存层定制的。对于每个请求,它会记录总请求时间、upstream 连接时间、upstream 响应头时间、upstream 响应时间、upstream 状态码、具体处理请求的后端服务器、缓存状态,以及选择了哪种内容协商(content-negotiation)变体。
缓存状态是 nginx 通过 $upstream_cache_status 暴露的值之一:HIT、MISS、BYPASS、EXPIRED、STALE、UPDATING 或 REVALIDATED。响应头也会向客户端暴露相同的信息,这使得使用 curl -I 或浏览器开发者工具验证行为变得非常容易。
always 限定符非常重要。如果没有它,nginx 只会将这些头信息添加到部分响应中,因此来自后端的 502 错误可能不会包含你最需要的诊断头信息。我希望它们出现在每个响应中,毫无例外。
还有一个让我觉得很舒服的运维小细节:自定义 502 页面。
error_page 502 /502.html;
location = /502.html {
root /usr/local/www/mastodon_errors;
internal;
}它不是缓存策略的一部分,但它能让后端的短暂故障显得不那么难看。而且我使用 444 状态码拦截了一些恶意的 User-Agent,这会直接关闭连接而不发送任何响应:
if ($http_user_agent ~* "bytespider") {
return 444;
}这并不是一套通用的反爬虫策略。它只是为我明知不需要的流量提供的一条低成本的拒绝路径。
我如何检查它是否真的有效
无法验证的配置就是我不信任的配置。这是我为这个代理保存在剪贴板中的一组简短命令。
第一个验证是变体分离。使用不同的 Accept 头向同一个 URL 发起三次请求,应该会产生三个独立的缓存条目:
for v in 'text/html' \
'application/activity+json' \
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; do
printf '%-75s -> ' "$v"
curl -s -o /dev/null -D - -H "Accept: $v" \
https://mastodon.bsd.cafe/@someuser/123456789 \
| awk '/^[Xx]-[Cc]ache/ { printf "%s ", $0 } END { print "" }'
done在第一轮测试中,每个变体都应该是 MISS。在第二轮测试中,每个变体都应该是 HIT,并且 X-Cache-Variant 会显示预期的存储桶(bucket)。
第二个验证是 cookies 和 Authorization 总是会触发 BYPASS:
curl -I -H 'Cookie: _mastodon_session=test' \
https://mastodon.bsd.cafe/@someuser
curl -I -H 'Authorization: Bearer fake' \
https://mastodon.bsd.cafe/api/v1/timelines/home两者都应该返回 X-Cache-Status: BYPASS。如果不返回,说明跳过缓存的规则是错误的,整个配置也就不安全。
如果你打算启用 AUTHORIZED_FETCH,第三个验证就是针对签名 GET 请求的。这是一个快速的综合检查,用于确认 nginx map 是否被正确触发:
curl -I -H 'Signature: fake' \
-H 'Accept: application/activity+json' \
https://mastodon.bsd.cafe/users/someuser如果你添加了 $skip_cache_signature,结果应该是 X-Cache-Status: BYPASS。
最后,日志本身会告诉我缓存在生产环境中的表现。缓存状态分布情况:
awk '{
for (i = 1; i <= NF; i++)
if ($i ~ /^cache=/) c[$i]++
}
END {
for (k in c) print k, c[k]
}' /var/log/nginx/access.mastodon.bsd.cafe.log一个健康的实例会显示 cache=HIT 和 cache=BYPASS 承担了大部分工作,而 cache=MISS 仅占冷启动路径和短 TTL 刷新。同样的技巧也适用于变体分布:
awk '{
for (i = 1; i <= NF; i++)
if ($i ~ /^variant=/) v[$i]++
}
END {
for (k in v) print k, v[k]
}' /var/log/nginx/access.mastodon.bsd.cafe.log这能让我了解流量的真实构成。一个重度联邦的实例会显示大量的 activitypub。一个有很多真人访问者的实例会显示更多的 html。在 mastodon.bsd.cafe 上,这种平衡会随着整个 Fediverse 每天发生的事情而发生变化。
值得坦诚说明的注意事项
我不喜欢把配置说得像变魔术一样,所以我想明确说明这种配置适用的条件。
较短的 TTL 会消耗 CPU。在持续有流量的 URL 上设置 10 秒的微缓存意味着每分钟会有 6 次后端重新生成。这比没有缓存要好得多,但也并非毫无开销。如果后端无法轻松应对,请提高 TTL 或在动态路径上启用 stale-while-revalidate。
动态的 stale-while-revalidate 功能强大,但容易掩盖问题。目前我在动态 location 上保持 proxy_cache_use_stale 关闭,因为我仍在验证其行为。在稳定的生产环境中,stale-while-revalidate 通常是正确的选择。但在新版本上线期间,它可能会悄无声息地掩盖上游错误,增加调试难度。你需要诚实地评估自己当前处于哪种模式。
AUTHORIZED_FETCH 改变了威胁模型。在禁用安全模式的情况下,只要缓存键能正确处理内容协商,公共的 ActivityPub GET 响应就可以作为公共内容安全地进行缓存。启用安全模式后,ActivityPub 响应可能会依赖于特定的 actor。此时,你必须要么绕过带签名的 GET 请求的缓存,要么将签名的 actor 包含在缓存键中。后者通常会破坏命中率,因此绕过缓存才是务实的解决方案。
变体映射(variant map)是一种折中方案。它涵盖了 application/activity+json、application/ld+json、application/json 和 text/html。其他所有内容都归入默认桶(bucket)。这是有意为之,但默认桶毕竟也是个桶。如果你发现有真正重要的特定客户端类型访问你的实例,请将其显式添加进去。
忽略 Vary 头意味着承担责任。proxy_ignore_headers Vary 并不是什么魔法;它只是告诉 nginx 停止根据上游的 Vary 头来保护你。只有当你自己的缓存键和请求规范化覆盖了 Vary 所保护的各个维度时,这样做才是安全的。对于此配置而言,这意味着要将 Accept 头规范化为一种变体,避免后端出现 Accept-Encoding 的差异,绝不缓存 cookies 或 authorization 头,并且在启用安全模式时绝不缓存带签名的 GET 请求。
关注者(Followers)和正在关注(following)列表故意不做缓存。它们重度依赖分页且频繁变化。对它们进行缓存会产生大量低价值且新鲜度存疑的缓存条目。如果远程实例对这些端点进行狂轰滥炸,请使用 limit_req_zone。不要试图把缓存改造成限流器来用。
签名 URL 重定向需要较短的 TTL。当重定向目标稳定时,缓存 302 状态码是很有用的。但当重定向指向生命周期短暂的签名 URL 时,这样做就非常危险。如果你的媒体存储返回预签名 URL(presigned URLs),那么你的 nginx 重定向 TTL 必须小于该 URL 的有效生存期。
Set-Cookie 必须保持其特殊地位。除非你绝对确定某个 location 不会产生特定于用户的响应,否则不要将 Set-Cookie 添加到 proxy_ignore_headers 中。nginx 默认拒绝缓存带有 Set-Cookie 的响应,这是一道安全网。请保留它。
优秀的配置是服务背后各项假设的书面表达。当假设发生变化时,配置也必须随之改变。
这套配置中没有任何一条单独的绝妙指令。其精髓在于组合使用:为不可变资源设置长 TTL,为媒体资源设置中等 TTL,为动态公共页面设置极短的 TTL;利用缓存锁(cache locking)防止惊群效应(thundering herd);对私有或依赖于 actor 的流量采取严格的绕过规则;使用规范化的内容协商键;以及提供充足的日志来证明系统的运行符合你的预期。
用一句话概括这一层带给我的好处:到达 Puma 和 Rails 的请求更少了。
那才是我关注的指标。Mastodon 并不慢,但它比较吃资源。实例规模越大,就越能从其前置层中获益,该层能默默分担掉那些无需交由应用本身处理的任务。要想安全地对 Mastodon 进行缓存,反向代理在处理每个请求时都必须牢记:同一个 URL 对于三个不同的客户端可能代表着三种不同的内容。只要做到这一点,即使是非常短暂的微缓存,也能在不改变实例用户可见行为的前提下,卸载掉惊人的负载。
需要完整排版与评论请前往来源站点阅读。