FediMeteo、HAProxy 与高效利用 snac 线程的艺术FediMeteo, HAProxy, and the art of not wasting snac threads
作者分享了 FediMeteo 项目如何通过 HAProxy 优化 SNAC(Synchronous Network Access Control)线程使用,避免资源浪费。通过合理配置连接池与负载均衡策略,系统在数千并发请求下仍保持稳定运行。
Stefano Marinelli
当我第一次写关于 FediMeteo 的文章时,我从头讲起:这个想法几乎是在一次度假查天气时偶然诞生的;我回忆起祖父多年来一直是我个人的气象学家;我决定做一个小而实用的东西;然后惊讶地看到人们真的在使用它。原本只是一个个人实验,很快就变成了一个小型全球服务,至今仍在以同样的理念运行:FreeBSD、jails、简单脚本、snac、文本、表情符号,以及许多默默工作的小组件。
那篇文章主要讲的是项目的诞生与成长。而这一篇,则要聊同一个故事里不那么浪漫的一面——尽管我得承认,我也觉得其中有一种独特的美感:随着服务不断壮大,依然保持轻量。
FediMeteo 对外仍然刻意保持简单。只有一个主页、一些数字、一个国家列表,以及大量发布天气预报的 ActivityPub 账号。这些帖子由文字和表情符号组成。阅读页面不需要 JavaScript,没有臃肿的前端,每条预报都不附带不必要的媒体文件,主页也不会在每次访问时重新计算——只是展示相同的数字。这并非偶然,而是从一开始我就希望服务如此运作。
但随着使用人数增多,细节的重要性愈发凸显。当只有十个关注者时看似无害的请求,在拥有数千关注者、多个远程实例、爬虫、预览功能以及其他服务器频繁拉取相同公开对象的情况下,就可能变成重复请求。在 Fediverse(联邦宇宙)中,同一件事可能被不同地方多次合法地索取。后端并不在意原因,只负责处理请求。
而在 FediMeteo 中,后端正是 snac。
我之所以非常喜欢 snac,正是因为它的简洁、清晰和高效。它不是一个试图包罗万象的巨无霸应用,而是专注于做好一件事并做到出色。但这也意味着我希望尊重它的设计形态。我不愿将它的线程浪费在反向代理本可以安全处理的工作上。一个 snac 线程反复为同一张公开头像服务固然不是灾难,但仍是一种浪费。一个 snac 线程在同一分钟内多次回答同一个公开的 ActivityPub 对象,虽然确实做了“工作”,但往往是不必要的工作。
这正是我在 FediMeteo 前部署 HAProxy 并进行调优的原因。
这不是为了让配置看起来聪明,而是为了让 snac 保持安静。
这是同一理念的延续。
我曾在两篇之前的文章中探讨过 snac 与 nginx 的类似问题:《用 Nginx 反向代理提升 snac 性能》和《缓存 snac 代理的媒体内容》。两次的核心思想都是:反向代理应吸收重复的公共请求,避免消耗 snac 的资源。
这一点尤为重要,因为 snac 使用的线程数量有限。我喜欢这种限制。界限是健康的,它们促使我们理解服务真正做什么,也防止小程序假装成无限资源。但限制也让浪费变得可见——如果少数几个线程正忙于分发本可从缓存获取的文件,那么这些线程就无法用于更有价值的事情。
在 FediMeteo 的实现中有所不同,因为反向代理使用的是 HAProxy,但背后的逻辑是一致的。我有许多小型 snac 实例,每个都运行在独立的 FreeBSD(Bastille)jail 中,并且只有一个公共入口点,它负责路由、终止 TLS、压缩、缓存,并尽可能从后端移除重复性工作。
这在某种程度上是原始 FediMeteo 设计的自然延续。我在第一篇文章中提到,希望按照 Unix 哲学来管理一切:由多个小部件协同工作。这是同一拼图中的另一块。HAProxy 处理边缘任务,snac 处理 ActivityPub 相关事务,脚本生成天气预报,cron 定时执行更新,ZFS 提供快照支持,FreeBSD jail 则确保各国数据相互隔离。单独来看,每一部分都不算特别强大,但整个系统因每项职责清晰而变得令人愉悦。
为什么几乎没有媒体内容?
在讨论 HAProxy 之前,值得提及一项最重要的优化措施——它甚至不在代理配置中体现。
FediMeteo 的预报不使用任何媒体文件。
发布的内容不带图片,不生成天气卡片,不为每个城市绘制地图,也不添加装饰性横幅。预报仅以文本和表情符号呈现。这是一个刻意的选择。将天气信息放入图像并不会使其更有用,而且服务所使用的每一个媒体文件都会变成需要存储、分发、缓存、联合、过期、备份以及偶尔调试的对象。
文本和表情符号已经足够。它们易于访问、轻量、可在纯文本浏览器中阅读、适合时间线展示,即使对当地语言掌握不够熟练的人也能理解。这是 FediMeteo 最初的设计原则之一,同时也简化了基础设施。减少媒体意味着减少工作量、缓存条目、重复请求和意外情况。
有一个例外:头像。
所有 FediMeteo 账户都使用相同的头像,这也是有意为之。我本可以为每个国家或城市设置不同的头像,或者设计更丰富的视觉元素。在某些截图中可能会显得更好看。但从运维角度看,这样做会更糟糕。
采用统一头像后,反向代理可以缓存一个非常实用的对象。该对象公开可用、对所有用户相同、体积小、被频繁请求,因此几乎总是处于缓存热状态。HAProxy 可以直接提供该文件,而不必让每个 snac 实例返回同一个文件。由于头像会被远程实例、浏览器、个人资料预览以及各种联合相关请求频繁调用,这一简单决定大幅减少了无意义的后台流量。
所以这个头像不仅是一个视觉标识,更是架构的一部分。
我喜欢这类优化,因为它始于软件之前——始于决定不制造问题。
首页是静态的,因为它本就可以是静态的。
主页面遵循同样的逻辑。
它是一个从模板生成的静态 HTML 页面。每小时通过 cron 脚本更新一次数字和统计数据。脚本统计我想要展示的数据,重新生成页面,之后页面保持静态直至下次运行。
这并非因为我无法实现动态页面,而是我不需要。简洁即美德。
主页无需在每次访问时查询所有国家实例。它不需要为每个打开它的用户发起一次数据库请求,也不需要实时向 snac 询问任何信息。数字是有用的,但无需每秒更新。每小时更新一次就足够了,这也符合整个项目的精神:需要时才做工作,然后廉价地提供服务。
我看到太多小型服务因为最初实现方便而非恰当,最终变得臃肿。定时任务(cron job)和模板虽然不时尚,但对于像这样的页面来说,往往正是所需之物。
多国共用一个入口点
FediMeteo 由多个国家的实例组成。每个实例运行在自己的 jail 中,监听独立的内部地址和端口。但从外部看,它们都位于相同的域名结构下:
fedimeteo.com
www.fedimeteo.com
it.fedimeteo.com
uk.fedimeteo.com
jp.fedimeteo.com
us.fedimeteo.com
usa.fedimeteo.com
can.fedimeteo.com
canada.fedimeteo.com还有更多。
一开始,人们总忍不住在 HAProxy 前端写一堆 ACL。这很直观,也很明确,对于五个主机名来说完全没问题。但 FediMeteo 并没有停留在五个主机名上。随着国家和别名增多,一长串 ACL 会让前端变成名字列表,而不是代理行为的描述。
因此我将主机名到后端的反向映射移到了 map 文件中:
fedimeteo.com backend_fedimeteo
www.fedimeteo.com backend_fedimeteo
it.fedimeteo.com backend_it
uk.fedimeteo.com backend_uk
jp.fedimeteo.com backend_jp
us.fedimeteo.com backend_us
usa.fedimeteo.com backend_us
can.fedimeteo.com backend_ca
canada.fedimeteo.com backend_ca前端现在只需要一条规则:
use_backend %[req.hdr(host),field(1,:),lower,map(/usr/local/etc/fedimeteo.map,backend_fedimeteo)]它会读取 Host 头,如有端口则去除,转为小写,然后在 /usr/local/etc/fedimeteo.map 中查找。若无匹配项,则回退到主 FediMeteo 后端。
我喜欢这样设计,因为它让配置更诚实——前端包含策略,map 包含数据。添加一个国家只需在 map 中添加一条记录并定义一个后端,无需每次服务增长就让前端变得更复杂。
后端作为独立的小隔间
国家后端刻意保持简单:
backend backend_it
mode http
http-reuse safe
server srv1 10.0.0.2:8001 maxconn 30
backend backend_uk
mode http
http-reuse safe
server srv1 10.0.0.7:8001 maxconn 30
backend backend_jp
mode http
http-reuse safe
server srv1 10.0.0.32:8001 maxconn 30一个后端、一个 jail、一个 snac 实例。这与项目中其他部分遵循的组织原则完全一致。若需了解意大利的情况,我查看意大利的 jail;若需了解英国,则看英国的 jail。将来若要将某个国家迁移到其他位置,这种分离已经存在。
maxconn 30 的值并非魔法数字,而是一个上限。我希望每个小后端前面都有一个可见的流量限制。如果某处开始频繁冲击某个国家实例,我更希望压力体现在 HAProxy 层,而不是让 snac 内部出现无限并发的处理负担。
http-reuse safe 允许 HAProxy 在合适的情况下复用后端连接,这是对不必要工作的又一次微小优化。反复建立连接虽非世界末日,但避免它仍然更好,尤其是在许多小型服务共用同一代理的情况下。
前门
HTTPS 前端监听 IPv4 和 IPv6,同时支持 HTTP/2 和 HTTP/1.1:
frontend https_in
bind :::443 v4v6 ssl crt /usr/local/etc/certs/ alpn h2,http/1.1
mode http
option http-keep-aliveTLS 默认设置全局生效:
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets端口 80 仅重定向到 HTTPS,除非是 Let's Encrypt 的挑战请求:
acl letsencrypt-acl path_beg /.well-known/acme-challenge/
http-request redirect scheme https code 301 unless letsencrypt-acl
use_backend letsencrypt-backend if letsencrypt-acl在 HTTPS 前端我还设置了常规的转发头部:
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Proto https并添加了 HSTS:
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"这些都没什么特别,这很好。基础设施中最有趣的部分未必是那些“不同寻常”的部分。
两个缓存,因为请求类型不同
HAProxy 配置定义了两个缓存:
cache mediacache
total-max-size 128
max-object-size 10000000
max-age 3600
process-vary on
max-secondary-entries 12
cache jsoncache
total-max-size 16
max-object-size 1000000
max-age 60
process-vary on
max-secondary-entries 12我将媒体和 ActivityPub JSON 分开处理,因为它们不是同一种类型的流量。
媒体缓存更大,最大存活时间也更长。在 FediMeteo 中,这主要指共享头像和一些看起来静态的对象。由于几乎不主动上传媒体内容,因此重要的缓存对象会被频繁请求并始终保持“温热”状态。
JSON 缓存较小且生命周期短。它仅用于公共 ActivityPub GET 请求,并非为了永久存储联邦(federation)状态。60 秒的缓存足以合并短时间内大量重复的请求,而无需将 ActivityPub 响应当作不可变文件来处理。
这种区分很重要。缓存不是一次性决策,而是关于响应含义、谁可以查看、变化频率以及再次提供时会发生什么的多个小判断集合。
识别媒体
对于媒体,访问控制列表(ACL)基于文件扩展名:
acl is_media path_end -i .jpg .jpeg .png .gif .webp .svg .ico .mp4 .webm .mp3 .ogg .wav .flac .mov .avi .mkv .m4v然后我将结果存储在一个事务变量中:
http-request set-var(txn.is_media) bool(true) if is_media缓存查找很简单:
http-request cache-use mediacache if { var(txn.is_media) -m bool true }而在响应端:
http-response set-header Cache-Control "max-age=3600, public" if { var(txn.is_media) -m bool true }
http-response del-header Set-Cookie if { var(txn.is_media) -m bool true }
http-response del-header Vary if { var(txn.is_media) -m bool true }
http-response cache-store mediacache if { var(txn.is_media) -m bool true }Cache-Control 头明确表达了意图。Set-Cookie 被移除,因为公共媒体对象不应携带会话信息。Vary 也被移除,因为我不想因无害的头部差异导致同一张头像在缓存中分裂成多个条目。
只有在脱离上下文时才显得激进。在此服务中,配合此媒体策略,这是一个合理的选择。FediMeteo 不会通过这些路径提供私有媒体,主要是反复提供相同的公共头像。
出于同样原因,我在请求到达后端前清理它:
http-request del-header Authorization if { var(txn.is_media) -m bool true }
http-request del-header Cookie if { var(txn.is_media) -m bool true }我不会全局这样做。而是在确认请求是媒体类型后才执行此操作。作用域正是让这些规则安全的关键。
结果正是我想要的:共享头像成为一个近乎完美的缓存对象——小巧、公开、反复被请求,并由 HAProxy 而非 snac 提供服务。
ActivityPub JSON 微缓存
ActivityPub 侧从 Accept 头部开始:
acl is_ap_json req.hdr(Accept),lower -m sub application/activity+json
acl is_ap_ldjson req.hdr(Accept),lower -m sub application/ld+json
acl is_outbox path_end /outbox
acl is_get method GET
acl has_auth req.hdr(Authorization) -m found
acl has_cookie req.hdr(Cookie) -m found这部分很重要,因为 ActivityPub 使用内容协商。同一个路径可能对浏览器返回 HTML,对远程实例返回 JSON。如果代理假定一个 URL 始终是某一种格式,最终会错误地缓存错误的表示形式。
所以我只将有公共权限的 ActivityPub GET 请求标记为可缓存:
http-request set-var(txn.is_activitypub) bool(true) if is_get !is_outbox is_ap_json !has_auth !has_cookie
http-request set-var(txn.is_activitypub) bool(true) if is_get !is_outbox is_ap_ldjson !has_auth !has_cookie这里有几个重要决定。
必须是 GET 方法,因为我不会缓存变更状态的投递或其他修改操作。不能是 /outbox,因为 outbox 集合不是我要在此处缓存的数据流。不能有 Authorization 头部或 Cookie,因为已认证或用户特定的请求不应进入共享公共缓存。
然后可以使用并填充缓存:
http-request cache-use jsoncache if { var(txn.is_activitypub) -m bool true }
http-response set-header Cache-Control "max-age=60, public" if { var(txn.is_activitypub) -m bool true }
http-response cache-store jsoncache if { var(txn.is_activitypub) -m bool true }六十秒很短,但很有用。联邦网络常产生一批批相同的请求。一台远端服务器获取 Actor,另一台也获取同一 Actor,某个地方请求同一对象,某个地方重试。我不需要将这些响应缓存数小时,只需要让 HAProxy 在同一小波峰期间回答第二和第三个相同请求即可。
这是最实用的微缓存方式。它在不改变服务本质的前提下减少了重复工作。
静态媒体路径
静态路径也有专门规则:
acl is_short_path path_reg ^/[^/]+/s/
http-request cache-use mediacache if is_short_path这源于同一个观察:我因此使用 nginx 缓存 snac 的媒体文件。snac 使用静态媒体路径,而这些路径通常代表那种应由代理而非后端线程处理的、公开的重复性流量。我称它们为“short”(短),不是因为它们的实际长度,而是因为第一次看到时,我以为 's' 代表“short”(短)而不是“static”(静态)。这个名称就这么固定下来了。
在 FediMeteo 中,这一点不如在普通社交实例中那么核心,因为我故意只将媒体用于头像和基本静态对象。尽管如此,这条规则仍符合整体策略:让 HAProxy 处理可重复的边缘任务,而让 snac 将其线程用在真正需要的地方。
允许变化,但不能无限
两个缓存都具备:
process-vary on
max-secondary-entries 12我希望 HAProxy 处理 Vary,因为内容协商是真实存在的,尤其是在涉及 ActivityPub 时。但我也希望变化是有边界的——如果每个细微不同的请求头都会生成一个新的缓存条目,那缓存就会变成一种复杂却低效的失败方式。
对于媒体,我在存储响应前移除 Vary。共享头像无需根据 Accept 头变化。而对于 ActivityPub JSON,我会更谨慎,因为表示形式确实重要。
再次强调,重要的不是数字本身,而是做出明确且有限制的变化决策。
验证是否有效
在部署期间,我喜欢暴露一个非常简单的诊断头部:
http-response set-header X-Cache-Status HIT if !{ srv_id -m found }
http-response set-header X-Cache-Status MISS if { srv_id -m found }这是有意保持简单。如果 HAProxy 选择了后端服务器,我称之为 miss(未命中);如果没有选择后端服务器,说明响应来自缓存,我称之为 hit(命中)。它不是一个完整的可观测性系统,但足以回答我在更改缓存规则后通常要问的第一个问题。
这个请求是否到达了 snac?
测试可以非常简单:
curl -I https://it.fedimeteo.com/path/to/avatar.png
curl -I https://it.fedimeteo.com/path/to/avatar.png第二次请求应该是命中。
对于 ActivityPub JSON,测试必须使用正确的 Accept 头部:
curl -I \
-H 'Accept: application/activity+json' \
https://it.fedimeteo.com/some/activitypub/object我还想验证 cookie 和授权是否阻止了公共缓存:
curl -I \
-H 'Cookie: test=value' \
-H 'Accept: application/activity+json' \
https://it.fedimeteo.com/some/activitypub/object
curl -I \
-H 'Authorization: Bearer fake' \
-H 'Accept: application/activity+json' \
https://it.fedimeteo.com/some/activitypub/object一个有效的缓存应该是可见的。一个不可见的缓存可能是正确的,但也可能是在静默地出错。我宁愿知道真相。
压缩与运维路径
HAProxy 还处理 gzip 压缩:
filter compression
compression algo gzip
compression type text/css text/html text/javascript application/javascript text/plain text/xml application/json application/activity+json这让另一个常见的责任保持在边缘层。国家实例可以专注于 snac 和预报数据,而 HAProxy 负责处理面向客户端的 HTML、JSON 和 ActivityPub 响应的压缩。
还有一个本地的 Prometheus 导出器:
frontend prometheus
bind 127.0.0.1:8405
mode http
http-request use-service prometheus-exporter
no log我也保留了内部运维路径,例如统计信息和 Grafana,这些都在主机名映射之前处理。这些都是小细节,但顺序很重要。特殊路径应显式且靠前。主机名映射用于 FediMeteo 路由,而不是用于每一个我碰巧在后端同一代理下暴露的内部工具。
这在实践中带来了什么变化
这种配置的好处在于,它的各个部分都不算特别出人意料。
映射让主机名路由保持可控;后端定义让每个国家实例彼此隔离且资源受限;静态首页避免了每小时才更新一次的内容的动态处理负担;共享头像给了 HAProxy 一个非常热门的媒体对象,可以直接服务;媒体缓存把公共文件挡在 snac 之外;JSON 微缓存吸收短暂的 ActivityPub 请求高峰;头部清理防止无用的变化;连接复用避免不必要的后端连接开销。
但这一切其实只是在用更长的方式说一件事:
更少请求到达 snac。
这就是我在这里关心的指标。
不是因为 snac 慢。恰恰相反,FediMeteo 之所以能以当前形式存在,正是因为 snac 足够高效,使得这个项目可以在一个非常小的 VPS 上运行。但正因为整个架构本身就小而优雅,所以我不想在不必要的地方浪费资源。
这也与项目的整体设计一致:预报由脚本序列化生成,每六小时更新一次;首页每小时重新生成;各国数据分别存放在独立的 jail 中;快照和备份则由应用外部处理。没有一个组件试图包揽整个系统。
HAProxy 只是其中一小块,但它所处的位置恰到好处,能省去大量重复工作。
注意事项
此配置并非适用于所有 ActivityPub 服务的通用 HAProxy 方案。
它针对的是 FediMeteo 目前的形态:几乎不含媒体内容、使用同一张共享头像、首页为静态 HTML、公开天气预报、部署多个小型 snac 实例,且当没有 Cookie 或授权头时,ActivityPub 流量可从短时公共缓存中受益。
若某天我决定在预报中加入媒体文件,则需重新审视媒体缓存规则;若每个城市或国家使用不同的头像,缓存仍会生效,但将失去“一张共享且始终热载的头像”这一优势;若 ActivityPub 响应变得依赖具体 actor,则必须重新考虑对公开 JSON 进行缓存的可行性;若某个国家的流量模式与其他国家显著不同,或许应为其设置独立的限流策略。
正因如此,我不喜欢把配置当作“魔法”来展示——好的配置,本质上是服务背后假设条件的文字表达。一旦这些假设发生变化,配置也必须随之调整。
总结
FediMeteo 最初只是一个小小的想法,如今却超出了我的预期,但我依然希望它在恰当的意义上保持“小”。小不等于脆弱,而是意味着清晰易懂:每一部分都有存在的理由,不必要的开销会在成为问题之前就已被消除。
HAProxy 层正是践行了这一理念:它终止 TLS、通过映射路由主机名、复用后端连接、从缓存提供共享头像、对公开的 ActivityPub JSON 进行微缓存、避开已认证或基于 Cookie 的流量,并给我一个简洁的诊断头部,让我随时了解运行状态。
这里没有哪个指令特别出众,有的只是将基础设施与现实需求相匹配的常规工作。
FediMeteo 以文本和表情符号发布天气预报,首页是每小时更新的静态 HTML。账户共用同一张头像,既够用又利于缓存优化。每个国家都运行在自己的 FreeBSD jail 中的独立 snac 实例上。HAProxy 静默地站在它们前面,除非必要,绝不打扰。
我喜欢这种基础设施。
不是因为它看不见,而是因为当它运转良好时,几乎无需多言。
需要完整排版与评论请前往来源站点阅读。