本文是对 I won free load testing 的整理与翻译。
内容结构概览
- 文章背景:几篇文章突然爆火后,作者的网站被人拿 botnet 做 DDoS,标题里的"免费压测"就是反讽。
- 主站攻击规模 :主站 72 小时内收到约 3400 万请求,分三波打到首页
/。 - 自然流量对比:真正爆火文章大约 13 万访问,而攻击流量大几个数量级。
- 攻击特征:请求路径集中在首页,约三分之二请求使用同一个 Linux Chrome User-Agent。
- 来源分布:流量来自多个国家、运营商、云服务商和 Tor 出口,说明算力和带宽已经足够便宜。
- 视频平台也被打:另一个跑在 Fly.io 上的视频平台被集中请求一个 4K@60、50 分钟视频文件,峰值约 3.1GB/s。
- 攻击是否成功:主站确实有几小时无法访问,出现大量 499、503、403、524、522、429 等状态码。
- 次要目标没成功:作者没有被收钱,Hetzner 固定月费,Cloudflare 不按这类攻击收费;Fly.io 是他工作的地方。
- 单点故障:主站 origin 是单台 Hetzner 独服,仍运行作者自写 Rust 服务端。
- Cloudflare 缓存误解:Cloudflare 默认不缓存 HTML,只按文件扩展名缓存;HTML 动态页面会回源。
- 为什么 HTML 没缓存:作者的网站对登录用户、推荐内容、搜索、随机模块等有动态行为。
- DDoS 防护为什么没完全生效:攻击流量在很大程度上低于 Cloudflare 自动检测阈值。
- origin 真正的问题 :
ss | wc -l看到约 35 万连接,origin 没有及时用 429/503/拒绝连接向边缘信号自己撑不住。 - 性能瓶颈:CPU 打满,perf 显示 SQLite、Liquid 模板、HTML rewriting/truncation、malloc、mutex 等都在消耗时间。
- 架构取舍:作者优先优化内容创作便利性,不是极致静态站性能;Liquid/SQL 像他的 Python/C 组合。
- 第一步修复:in-flight 请求限制 :用 Tower
GlobalConcurrencyLimitLayer限制并发请求,但单独这样不够。 - 更好的措施:load shedding:超过最大可服务量时应快速失败,而不是让请求堆积拖垮机器。
- 第二步修复:连接限制 :自定义 Tower
ServiceFactory,用PollSemaphore控制连接数量。 - 连接限制的副作用:连接槽被 Cloudflare idle 连接占满,于是还需要 idle read/write timeout。
- 只允许 Cloudflare IP:自定义 acceptor,定期拉取 Cloudflare IP 段,只接受 Cloudflare 和本地连接。
- 自定义 acceptor 技术栈 :
hyper::server::accept::from_stream、async-stream、tokio_io_timeout、arc-swap、ipnet。 - User-Agent 封禁:作者也写了一个 Tower Layer 封禁攻击使用的固定 UA,但事后忘记关,导致误伤合法 Linux Chrome 用户。
- 补可观测性:主站接入 Sentry、OpenTelemetry、Honeycomb、tracing,把每个 HTTP 请求做成 span。
- 可观测性带来的发现 :SQLite 连接池太小、阻塞 SQLite 跑在 async context、没有 prepared statement cache、没有索引、
truncate_html过慢。 - 真正有效的优化:整页缓存 :用
moka给未登录用户缓存渲染后的模板。 - 性能提升:缓存前本地压测约 88 RPS、P95 约 860ms;缓存后约 34K RPS、P95 约 2.4ms。
- 安全细节:必须正确区分登录用户和匿名用户,不能把带凭证渲染出来的页面放入公共缓存。
- 视频平台为什么没改:它从一开始就有更好的观测和 SSD cache,多区域部署,攻击流量基本由缓存承载。
- 事后误伤:DDoS 的次生伤害之一是防御措施误伤正常用户,尤其是粗暴封 AS、国家、Tor、User-Agent。
- 更新:文章发布后攻击又恢复;作者发现 256/512 连接限制太低,Cloudflare 本身约有 275 个 PoP,于是把连接和 in-flight 上限提高到 2048,站点恢复。
这篇文章的标题叫 I won free load testing,直译是"我赢得了一次免费压测"。
当然,这是反讽。
事情的起因很简单:作者有几篇文章突然在多个网站上爆火。然后某个地方、某些人,大概觉得"让我看看这个嘴欠的聪明人能扛多少流量",于是开始对他的网站做 DDoS。
作者没有把这件事写成愤怒控诉,也没有把它包装成安全恐慌。相反,他把它当成一次非常真实、非常昂贵但又没花钱的线上压测。
这篇文章最有价值的地方,不是讲"攻击者怎么打",而是讲一个个人站点在真实攻击下到底哪里会坏:
text
Cloudflare 默认不会缓存 HTML。
边缘 DDoS 防护不是万能。
origin 必须懂得拒绝请求。
只有限制并发不够,还要 load shedding。
连接数限制可能被 idle 连接占满。
动态页面如果没有整页缓存,很容易被首页请求打穿。
可观测性不是装饰,它能告诉你真正的瓶颈在哪里。
临时防御措施很容易误伤正常用户。
这篇文章也很适合后端工程师看。因为它不是抽象谈高可用,而是从一次真实事故出发,展示一个 Rust/Hyper/Tower/Warp 网站如何一步步补上限流、限连接、超时、Cloudflare IP allowlist、User-Agent blocking、tracing、Honeycomb、Sentry 和整页缓存。
标题虽然轻松,内容其实很硬。
一、主站攻击:72 小时 3400 万请求
作者的主站,也就是 fasterthanlime 的博客,在 72 小时内收到了约 3400 万请求,分成三波高峰。
这个数字有多大?
文章里提到,真正因为文章爆火带来的自然流量大约是 13 万 hits。正常流量有一个很典型的长尾:高峰之后慢慢下降,从几千请求逐渐降回几百。
而攻击流量不一样。它是尖刺状的,一波接一波。主攻击主要打一个路径:
text
/
也就是首页。
这说明攻击并不复杂。它不是模拟真实用户完整浏览,也不是请求文章、图片、字体、CSS、JS 等资源,而是集中打首页。更有意思的是,大约三分之二的请求使用同一个 User-Agent:
text
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
这是当时 Linux 64-bit Chrome 的 User-Agent。
如果是完整 headless browser,正常情况下还会请求图片、字体、样式、脚本等静态资源。但这次流量主要打 HTML 首页,所以看起来更像某种 HTTP 客户端或脚本化请求。
简单归简单,分布却很广。
流量来自多个国家、多个运营商、多个云服务商和 Tor 出口。中国、印度、巴西、美国、印尼、菲律宾、香港、英国、泰国等都有大量请求。AS 列表里能看到运营商,也能看到 DigitalOcean、Contabo、Heficed、Rackdog 等云或 VPS 服务商。
作者的评论很现实:全球算力和带宽越来越便宜,这对正常开发者是好事,但对坏人也是好事。云服务商的 ToS 当然会写"不要拿我们去 DDoS 别人",但现实是,至少短时间内,还是有人能用这些资源打别人。
二、第二个目标:视频平台被打到 3.1GB/s
主站之外,作者刚发布不久的视频平台也遭到攻击。这个平台是一个单独服务,跑在 Fly.io 上。
攻击者盯上的不是普通页面,而是一个视频资源:一个 4K@60、50 分钟视频的大文件。Honeycomb 里可以看到,异常请求几乎全部打到同一个路径,也就是这个视频文件。
Fly.io 的流量图显示峰值达到约 3.1GB/s。
这个攻击比主站攻击分布更集中。作者发现它主要来自一批 Vultr IP。于是他直接封掉整个 AS,攻击就停了。
这个细节很重要。很多时候,防御不是优雅的。它可能就是"先把整个来源段封掉,让系统恢复"。但这也会引出后面要讲的次生伤害:粗暴封禁会误伤合法用户。
三、攻击是否成功
作者把攻击目标分成几层。
第一层目标是最直接的:
text
让服务不可访问。
这个目标确实达成了。4 月 30 日星期六,主站有几个小时无法访问。作者统计 HTTP 状态码时,能看到大量异常:
text
499 Client Closed Request
503 Service Unavailable
403 Forbidden
524 Origin Timeout
522 Origin Connection time-out
429 Too Many Requests
520 Origin Error
521 Origin Down
也有几百万 200 OK,但在攻击期间,很多读者确实访问不了文章。
第二层目标是让目标花钱。
这次没有成功。作者的主站跑在 Hetzner 独服上,是固定月费,不按带宽计费。Cloudflare 也没有因为这类攻击向他收费。至于 Fly.io,作者当时就在 Fly.io 工作,所以这个点更没起作用。
第三层目标是让服务商赶人。
也没有成功。作者没有被 Cloudflare 或 Hetzner 踢掉。相反,这次事件让他和一些朋友、Cloudflare 工程师、读者重新联系上,大家围观、讨论、做笔记。
作者说,这种攻击既有娱乐性,也有信息价值。它迫使他修了一些网站上早就该修的问题,也让别人思考如何更好防御类似情况。
这也是标题"免费压测"的含义:攻击者想造成麻烦,结果也确实造成了;但从工程角度看,它暴露了系统真实瓶颈。
四、为什么网站会挂:单点 origin
主站的根本问题是:origin 是单点。
当时作者的博客背后是一台 Hetzner 独服,运行着他自己写的一些 Rust 代码。它前面有 Cloudflare,但只要请求没有在 Cloudflare 边缘命中缓存,最终还是会回到这台 origin。
在更早以前,作者曾经用静态站点生成器,比如 nanoc、Hugo,把生成结果直接部署到 S3 bucket,再放到 Cloudflare 后面。
如果还用那套架构,这次主站大概率不会挂。因为静态文件非常适合 CDN 缓存,origin 压力会小很多。
但那套架构也有另一个问题:如果流量都从 S3 出去,可能会收到很大的 AWS 账单。作者不想和 AWS 朋友只聊 billing。
所以他后来改成了自己写服务端,支持更多动态功能。这个取舍平时非常舒服,但在 DDoS 下暴露了问题。
五、Cloudflare 默认不会缓存 HTML
很多人会以为:
text
我站点前面有 Cloudflare,所以它会缓存页面。
作者一开始也有点意外。但 Cloudflare 文档明确说明:默认情况下,Cloudflare 按文件扩展名缓存,不按 MIME type 缓存。HTML 默认不缓存。robots.txt 例外。
也就是说,图片、CSS、JS 等静态资源会被缓存,但 HTML 页面不会默认缓存。
作者的网站确实已经给静态资源设置了 cache-control,但没有给 HTML 设置。因为过去两年,即使文章多次爆火,HTML 生成也从来不是大问题。单篇文章的 P95 大约 30ms,非常够用。
攻击打的是首页。首页不是一个简单静态页面。它会跑多个 SQL 查询,取最新视频、文章、系列;还会通过 Liquid 模板和自定义过滤器生成页面;为了展示摘要,还会对 HTML body 做截断和重写。
作者尝试给 HTML 加:
http
cache-control: public, max-age=120
但 Cloudflare 仍然把它当 dynamic,每个请求都回源。
Cloudflare 可以用 Page Rules 做 "cache everything",但这有副作用。作者的网站对登录用户有不同内容,比如赞助者可以提前看一些文章,还有用户设置、随机推荐、搜索等动态内容。如果简单按 URL 缓存 HTML,登录用户可能看到错误内容,甚至可能泄漏不该缓存的页面。
Cloudflare 高级计划有 "Bypass Cache on Cookie" 之类功能,但这也不是 DDoS 防御的银弹。攻击者完全可以带一个格式正确的 cookie,让边缘认为应该绕过缓存。Cloudflare edge 并不知道这个 session cookie 是否真的有效。
所以作者总结:在不升级到更高计划的情况下,他不能用 Cloudflare 以不破坏站点的方式缓存 HTML。而且就算能,对攻击也未必有用。
六、Cloudflare DDoS 防护为什么没完全挡住
作者确实期待 Cloudflare 的 DDoS 防护能发挥作用。
它也不是完全没发挥作用。Cloudflare 挡掉或挑战了一部分流量。作者开启 "I'm Under Attack" 模式后,拦截量更明显。
但问题是:它挡得不够多,不足以让 origin 恢复给正常用户服务。
后来作者联系到 Cloudflare 工程师,对方告诉他一个有意思的点:攻击者大多数时候刚好低于 Cloudflare 的检测阈值。
这就很微妙。
从总请求量看,攻击很大,尖峰也明显。但从 Cloudflare 的自动检测角度看,可能还没有达到"明显 DDoS"的阈值。也就是说,这种攻击如果绕过了自动防线,origin 自己就必须足够健康,至少能优雅失败。
而作者的 origin 没做到。
七、35 万连接:origin 没有表达"我撑不住"
攻击期间,作者在 origin 上跑:
bash
sudo ss | wc -l
结果大约是:
text
352950
也就是约 35 万连接。
这是非常不正常的。Cloudflare 到一台 origin 之间不应该有这么多并发连接。
问题不是 Cloudflare 不懂回退,而是 origin 没有给出正确信号。一个撑不住的 origin 应该返回:
text
429 Too Many Requests
503 Service Unavailable
直接拒绝连接
或者用其他方式快速失败
它不应该无限接受连接,然后让它们挂着不动。那对 Cloudflare、origin、正常用户都不好。
作者承认:这部分他之前没有做好。它能跑两年,是因为正常流量从来没有逼到这个边界。
这句话很真实:Rust 也不能防止你"先凑合,后面再补"。语言能帮你写更安全的代码,但不能替你设计容量保护策略。
八、CPU 打满:瓶颈不在内存
攻击时,origin 的内存使用还好,几乎没超过 5%。但 8 个 CPU 核心都接近打满,而且作者甚至在本机 curl 都等不到一个 200 OK。
用 perf top 看,热点分布在:
text
SQLite btree 相关函数
malloc
pthread_mutex_lock
lol_html HTML rewrite
UTF-8 decode
syscall
这说明瓶颈不是某个简单网络 read/write,而是网站生成页面时做了不少工作。
作者的网站虽然是 Rust 服务端,但不是所有东西都写成极致性能 Rust。它大量依赖 Liquid 模板和 SQL 查询。作者把这比作自己的 Python/C 组合:
text
Liquid / SQL 提供快速迭代和内容创作便利性。
Rust 负责文件监听、hash、数据库管理、HTTP stack、markdown 渲染、自定义过滤器等基础能力。
这套架构对作者非常有用。它让他能快速原型、快速改模板、不用每次改内容结构都重新编译 Rust。过去两年,即使文章多次走红,也完全够用。
但首页是相对重的。它要查最新文章、最新视频、系列信息,还要对 HTML 摘要进行截断。攻击刚好打首页,因此成本被放大。
九、第一步修复:限制 in-flight requests
攻击之后,作者开始修 origin。
第一步是限制最大 in-flight requests。
主站使用 Warp 作为 HTTP 框架,视频平台使用 Axum。它们底层都基于 Hyper,因此可以使用 Tower layers,类似中间件。
原来的代码大致是:
rust
let svc = warp::service(all_routes.with(access_log));
let make_svc = hyper::service::make_service_fn(|_: _| {
async move {
Ok::<_, Infallible>(svc.clone())
}
});
let server = hyper::Server::bind(&addr).serve(make_svc);
server.await?;
加上全局并发限制后:
rust
use tower::{limit::GlobalConcurrencyLimitLayer, ServiceBuilder};
let svc = warp::service(all_routes.with(access_log));
let limit = GlobalConcurrencyLimitLayer::new(512);
let make_svc = hyper::service::make_service_fn(|_: _| {
let svc = ServiceBuilder::new()
.layer(limit.clone())
.service(svc.clone());
async move {
Ok::<_, Infallible>(svc)
}
});
这听起来很合理:最多 512 个请求同时处理。
但作者部署在两波攻击之间后,发现它完全没帮上忙。
原因也很简单:在攻击中,请求量异常高。限制 in-flight 只会让请求堆积,资源仍然被占用,CPU 仍然打满。真正应该做的是 load shedding。
十、Load shedding:超过能力就快速失败
Load shedding 可以理解成"超过最大可服务量时,直接丢掉或快速失败"。
这听起来像坏事。用户来了你不服务,还报错?
但在高负载或攻击下,这反而是正确行为。
如果你已经知道系统最多优雅处理 512 个 in-flight requests,那么第 513 个请求继续排队不一定有意义。它可能等很久,拖垮内存和 CPU,还让所有请求一起变慢。
更好的做法是:超过上限的请求立刻失败。这样边缘层,比如 Cloudflare,可以看到 429 或 503,然后选择重试、回退、挑战用户、降低请求速率。至少系统不会被无限排队拖死。
作者说,他当时应该加 Tower 的 load shed layer。它只是多一行代码。但攻击期间他没想到。
这也是事故复盘的一个常见结论:很多防线事后看起来都简单,但事故当下人会慌,会先做最直观但不一定最有效的事。
十一、第二步修复:限制并发连接
接下来,作者限制 concurrent connections。
Cloudflare 有很多 PoP,但 35 万连接显然离谱。于是他写了一个自定义 ServiceFactory,用 tokio::sync::Semaphore 和 tokio_util::sync::PollSemaphore 控制连接数量。
这里的结构很 Tower:
text
ServiceFactory 接收 &AddrStream
返回一个真正处理请求的 Service
也就是说,它是一个"产生 service 的 service"。
简化理解:
rust
pub struct ServiceFactory<S> {
pub inner: S,
pub semaphore: PollSemaphore,
pub permit: Option<OwnedSemaphorePermit>,
}
在 poll_ready 里获取 permit:
rust
if self.permit.is_none() {
self.permit = Some(futures::ready!(
self.semaphore.poll_acquire(cx)
).unwrap());
}
在 call 里把 permit 交给真正的 PermitService:
rust
let permit = self.permit.take().expect("poll_ready first");
ready(Ok(PermitService {
inner: self.inner.clone(),
_permit: permit,
}))
PermitService 持有 _permit。只要连接对应的 service 还活着,permit 就不会释放。连接结束,service drop,permit 自动释放。
这段代码啰嗦,但很可复用。作者也吐槽说,这东西大概早就在某个 crate 里有了,只是他又自己写了一遍。
这也是 Tower 的味道:抽象有点绕,但一旦写出来,就能包在任何 Hyper/Tower service 外面。
十二、连接限制的新问题:idle 连接占满槽位
连接限制部署后不久,网站又看起来不可用了。但攻击还没恢复。
原因是:可用连接槽被填满了,而且都是 idle 连接。Cloudflare edge nodes 再也无法建立新连接。
这就是限制连接数时常见的问题:
text
限制连接数只能防止无限增长。
但如果已有连接一直 idle,它们会占住所有槽位。
于是需要 idle timeout。
作者的解决方案是:任何连接在几秒内没有读或写,就 reset。
这同时给了他另一个机会:修掉一个更危险的洞。
他的 origin 当时监听在:
text
0.0.0.0:80
也就是说,如果有人猜到源站 IP,就可以绕过 Cloudflare 直接打 origin。虽然这次没有发生,但完全可能发生。
正确做法是:origin 只接受 Cloudflare IP 段来的连接,以及本地连接。
十三、自定义 acceptor:只接受 Cloudflare IP,并加 idle timeout
作者写了一个 custom acceptor,做两件事:
text
只允许 Cloudflare IP ranges 和 localhost。
给非本地连接加 read/write idle timeout。
技术上,它用到了:
text
hyper::server::accept::from_stream
async-stream
tokio_io_timeout
ipnet
arc-swap
reqwest
流程是:
- 启动时加载内置的 Cloudflare IPv4/IPv6 段和本地
127.0.0.1/8。 - 后台任务定期从 Cloudflare 官网拉最新 IP 段。
- 用
ArcSwap原子替换 IP set,避免Arc<Mutex<T>>或Arc<RwLock<T>>。 - accept 新连接时,检查远端 IP 是否在 allowlist 中。
- 不在 allowlist 中就跳过。
- 在 allowlist 中就给
TcpStream套上TimeoutReader和TimeoutWriter。 - 非本地连接设置 5 秒 idle timeout。
- 用
from_stream把 async stream 变成 Hyper acceptor。
这段代码不完美。作者也承认,更好的方法应该是防火墙规则:直接在内核层拒绝或丢弃不允许的连接。这样无效连接不会占 accept queue,也不会进入应用层。
但作为应用层快速修补,这段代码已经很有用。
使用方式也很简单:
rust
let acceptor = timeout_acceptor(addr);
let server = hyper::Server::builder(acceptor).serve(factory);
server.await?;
因为这个 acceptor 返回的连接类型不再是 Hyper 默认的 AddrStream,而是:
rust
Pin<Box<TimeoutWriter<TimeoutReader<TcpStream>>>>
所以原来的 ServiceFactory 类型约束也要改。从:
rust
impl<S> Service<&AddrStream> for ServiceFactory<S>
改成:
rust
impl<S, A> Service<&A> for ServiceFactory<S>
这样它就不关心具体 acceptor 的 IO 类型了。
这是一个很实际的 Rust/Tower 工程细节:泛型约束写得太具体,会在换一层抽象时爆掉。
十四、User-Agent 封禁:有效,但后来误伤
攻击请求大量使用同一个 Linux Chrome User-Agent。作者于是写了一个 Tower Layer,检查请求头里的 user-agent,如果命中 banned set,就直接返回 403。
逻辑很简单:
text
读取 User-Agent
如果在 banned HashSet 中:
返回 403 空 body
否则:
调用 inner service
作者后来也说,其实没必要手写完整 Tower Layer。用 ServiceExt::filter 之类工具几行就可以解决。
但更重要的是:这类临时防御措施很容易忘。
文章后面提到,攻击过后,有正常读者联系作者,说自己无法访问网站。他们使用的正好是最新版 Linux Chrome。作者一开始以为是 Cloudflare 还在拦截,尝试关闭 attack mode、allowlist、managed challenge,都没用。
最后才发现:不是 Cloudflare,是他自己的 origin 还在封那个 User-Agent。
更混乱的是,读者看到的是 Cloudflare 风格的 "Sorry, you have been blocked" 页面,即使问题实际上来自 origin 返回 403。站在第三方 edge 后面,错误来源有时会变得很不直观。
这就是 DDoS 的次生伤害:防御措施本身会误伤正常用户,而且攻击期间人会跑来跑去临时改配置,事后很容易忘记回滚。
十五、补可观测性:Sentry、Honeycomb、OpenTelemetry、tracing
攻击之后,作者把主站接入了 Sentry 和 Honeycomb。视频平台早就有这些,但主站之前没太需要细看。
他配置了:
text
Sentry:报告 panic 和 release/environment 信息
OpenTelemetry:跨服务 trace context
OTLP exporter:把 trace 发到 exporter
tracing_subscriber:本地日志和远端 tracing
Honeycomb:查看请求 spans 和性能分布
然后,他给 HTTP 请求加了一个 IncomingHttpSpanLayer。
每个请求都会创建一个 info-level span,记录:
text
http.method
http.url
http.status_code
http.user_agent
http.host
sec-ch-ua-mobile
sec-ch-ua-platform
request_id
user_id
响应结束后,再把 HTTP status code 记录回 span。
这比简单日志强很多。因为日志只是事件流,而 trace span 能展示"这个请求里每一步花了多少时间"。
在 Honeycomb 里,作者不仅能看哪些 RSS reader 在访问,还能看到首页请求真正花在哪里。
十六、可观测性发现了什么
Honeycomb trace 告诉作者:首页请求的耗时主要来自:
text
liquid.render
SQL 查询
page_markup
truncate_html
然后他立刻发现了很多可行动信息。
第一,SQLite 连接池只有 10,这是 r2d2-sqlite 的默认值。在高负载下不够。
怎么知道?因为 trace 里能看到请求在等待 checkout。
第二,SQLite 查询是阻塞代码,却跑在 async context 里。更合理的做法应该是 spawn_blocking。Rust 目前没有特别好的 lint 能自动抓出这种问题。
第三,prepared statement 没缓存。虽然这不是最大热点,但从 prepare 换到 prepare_cached 很容易。
第四,SQLite 数据库没有索引。作者开玩笑说,这是他给自己留的一个大免费优化项。
第五,truncate_html 比预想慢很多。
这个过滤器的作用是把文章 HTML 截断成首页摘要。但它会把整篇文章都喂给 HTML rewriter,即使只想保留前 120 个字符。这个 transform 可预测,完全可以缓存。
作者看到这些问题了吗?看到了。
他立刻都修了吗?没有。
十七、真正先修的是整页缓存
作者说,这些性能问题都让他很在意,但它们可以等。因为平时网站没被攻击时,延迟是"够用"的。
真正要修的是整页缓存。
他早就该做,但一直拖着。结果发现非常简单:直接接入 moka,一个 Rust 的 fast concurrent cache library,支持 async 和基于时间的过期。
在全局 ServerState 里加一个字段:
rust
rendered_templates_cache: Cache::builder()
.time_to_live(Duration::from_secs(5 * 60))
.time_to_idle(Duration::from_secs(60))
.build()
含义是:
text
TTL:最多缓存 5 分钟。
TTI:如果 1 分钟没有被访问,就过期。
然后在 serve_template 里构造 cache key:
rust
let cache_key = format!("{}?{}", self.path, self.raw_query.as_str());
再根据 cookie 判断用户是否登录:
text
有有效 credentials:不缓存。
没有 credentials:生产环境下尝试缓存。
未登录用户访问时,先查缓存。命中就直接返回 Bytes。没命中才跑 Liquid 渲染、插入 livereload、构造 response,并把 body 放入缓存。
这里有一个很重要的安全细节:必须正确识别有效 session cookie。带 credentials 的响应不能进入匿名缓存。作者提到早期版本曾经把带凭证渲染出来的页面放进缓存,这是一个严重 bug,后来修了。
Bytes clone 很便宜,因为只是增加引用计数,不会复制整个 body。缓存命中时,开销非常低。
十八、从 88 RPS 到 34K RPS
整页缓存的效果非常夸张。
缓存前,本地压测首页大概:
text
87.98 requests/sec
P95 约 860ms
缓存后,同样本地压测变成:
text
34073 requests/sec
P95 约 2.4ms
也就是最重页面的最大 RPS 从约 90 提升到 34K。
这提升不是来自微优化,也不是来自把 SQLite 改成更快的数据库,也不是来自把 Liquid 重写成 Rust 模板。它来自一个非常基本的架构事实:
text
匿名用户看到的动态页面,大多数时候可以短时间缓存。
如果 1 分钟内大家都请求同一个首页,就不应该每次都重新跑 SQL、Liquid、HTML rewrite。
这就是缓存最朴素也最强大的地方。
作者也指出:现在攻击者如果想打未缓存 endpoint,有两个选择:
text
逆向 cookie 签名并拿到 secret key。
成为订阅者,拿到合法 cookie。
前者不现实,而且 secret 可以换;后者会出现在 trace 里,很容易 block。
未登录页面仍然可以被打,但最大 RPS 从 90 变成 34K 后,攻击者要造成影响,就需要超过 Cloudflare 的检测阈值。到了那个级别,Cloudflare DDoS 防护更可能介入。
十九、视频平台为什么不用改
视频平台这边,作者没有改任何代码。
原因是它从一开始就按另一种模型设计:它真正担心的不是 downtime,而是巨额 AWS 账单。于是视频文件有 SSD cache,多个区域部署,攻击时被服务出去的请求 100% 都来自 SSD cache。
它还有 8 个独立实例,分布在多个区域,比如巴黎、东京、华盛顿、圣保罗等。即使一个区域压力大,整体也比单台 origin 更抗打。
这和主站形成对比:
text
主站:单 origin,动态 HTML,缺整页缓存,可观测性不足。
视频平台:多区域,SSD cache,可观测性较好,从一开始考虑过大流量。
不是 Rust 或某个框架决定成败,而是架构准备程度决定成败。
二十、事后副作用:防御会误伤正常人
DDoS 的一个次要目标是制造 collateral damage。
如果目标为了防御攻击,被迫封掉整个 AS、封 Tor、封某个国家、封某个 User-Agent,那么正常用户也会被影响。这会让目标丢失读者、客户、声誉或收入。
作者在攻击后就遇到了这种情况。正常读者因为使用 Linux Chrome,被他临时封的 User-Agent 误伤。读者看到 Cloudflare 风格的 block 页面,以为是 Cloudflare 还在拦,但实际是 origin 的规则忘了关。
作者说,这也是攻击的一部分效果。你被打时会到处跑、到处改配置、试图修好问题,然后就会变得 sloppy。临时措施忘记撤、误伤用户、错误归因,都很正常。
这段非常真实,也非常值得记住。防御不是只看能不能挡住攻击,还要看事后能不能恢复到正常策略。
二十一、文章发布后攻击又来了
有意思的是,文章刚发布几分钟后,攻击又恢复了。
这次来源换了一批 AS,包括 Alibaba、OVH、Azure、Hetzner、Linode、Telecom Argentina 等。
一开始网站立刻挂了,Cloudflare 返回 522 Origin Connection Time-out。
作者发现问题是连接限制太低。他之前设的 256 或 512 太保守了。Cloudflare 自己就有大约 275 个 PoP,每个 PoP 可能和 origin 建立几个连接。连接上限太低,就会挡住 Cloudflare 正常回源连接。
于是他把连接和 in-flight requests 上限都提高到 2048。立刻,522 下降,200 上升。随后 Cloudflare 也跟上,攻击流量出现 403 spike,然后攻击停止。网站恢复。
这段非常关键:防御参数不是越小越好。
如果上限太高,origin 被拖垮;如果上限太低,正常边缘节点也连不上。你必须理解中间层的行为,比如 Cloudflare 有多少 PoP、每个 PoP 可能保持多少连接、正常请求模式是什么。
二十二、这篇文章真正想讲什么
这篇文章表面上是一次 DDoS 复盘。更深层,它讲的是一个线上系统如何在真实压力下暴露隐藏债务。
平时网站能跑,并不代表它有容量保护。文章爆火能扛住,也不代表 DDoS 能扛住。Rust 服务端不会自动有 load shedding。Cloudflare 前面挡着,也不代表 HTML 会被缓存。动态页面生成 168ms 的 P95 平时可以接受,但在首页被疯狂请求时就会变成灾难。
作者没有把责任全部推给 Cloudflare,也没有把责任全部推给攻击者。他承认自己的 origin 没有正确表达"我撑不住",没有连接限制,没有 idle timeout,没有整页缓存,主站可观测性也不足。
这正是这篇文章好看的地方:它不是"我被打了,我很惨",而是"我被打了,正好让我看清系统哪里烂"。
二十三、对后端工程的启发
第一,CDN 不是魔法。
Cloudflare 默认不缓存 HTML。动态页面、登录态、cookie、个性化内容都会让缓存变复杂。不要以为"前面有 CDN"就等于 origin 安全。
第二,origin 必须快速失败。
当 origin 撑不住时,要返回 429、503,或者拒绝连接。不要无限接受连接然后拖着。边缘层需要信号才能回退。
第三,只限制 in-flight requests 不一定够。
如果超过上限的请求仍然排队,系统还是会被拖垮。需要 load shedding,让超出容量的请求快速失败。
第四,连接限制必须配合 idle timeout。
否则攻击者或边缘节点的 idle 连接会占满所有槽位,正常请求进不来。
第五,origin 不应该暴露给全世界。
如果站点必须通过 Cloudflare,origin 应该只允许 Cloudflare IP ranges 和本地访问。更好的方式是在防火墙层做,而不是应用层做。
第六,临时封禁措施要有过期和回滚。
封 User-Agent、封 AS、封国家都可能误伤。最好有明确注释、过期时间、监控和事后 checklist。
第七,可观测性要提前做。
攻击时再接 Honeycomb/Sentry/tracing 当然也有用,但最理想是早就有。Trace 能告诉你每个请求具体耗在哪里,比猜测强太多。
第八,缓存通常是最大杠杆。
整页缓存把最重页面从 90 RPS 提到 34K RPS。很多时候,最有效的优化不是把函数写快 20%,而是不要重复做同样的工作。
第九,不要把登录态页面放进匿名缓存。
缓存是性能工具,也是安全风险。必须清楚区分有凭证和无凭证请求,缓存 key 也要正确包含路径、query、影响输出的维度。
第十,真实压测和攻击会暴露完全不同的问题。
普通 load test 可能测出平均吞吐,但 DDoS 会测出队列、连接、边缘回源、缓存策略、误伤、监控、运营流程和人类临场反应。
二十四、总结
这篇文章是一次 DDoS 事后复盘。作者的几篇文章突然爆火后,有人开始攻击他的站点。主站在 72 小时内收到约 3400 万请求,主要打首页 /,大约三分之二请求使用同一个 Linux Chrome User-Agent。自然爆火文章大约只有 13 万 hits,所以攻击流量远远超过正常流量。攻击来源分布在多个国家、运营商、云服务商和 Tor 出口,说明便宜算力和带宽既帮助了正常开发者,也帮助了攻击者。
另一个目标是作者新上线的视频平台。攻击者集中请求一个很大的 4K@60、50 分钟视频文件,Fly.io 指标显示峰值约 3.1GB/s。这个攻击分布较窄,主要来自 Vultr IP,封掉整个 AS 后就停了。主站攻击则造成了几小时不可访问,出现大量 499、503、403、524、522、429 等状态码。攻击的首要目标"让服务不可用"达成了,但次要目标没有达成:作者没有被按带宽收费,也没有被服务商踢下线。
主站之所以会挂,根本原因是 origin 是单点:一台 Hetzner 独服,运行作者自己的 Rust 服务端。Cloudflare 在前面,但默认不会缓存 HTML,只会按文件扩展名缓存静态资源。作者的网站 HTML 对登录用户、推荐内容、搜索、随机模块等有动态行为,所以以前没有缓存 HTML。简单加 cache-control 也没让 Cloudflare 缓存;Page Rules 的 "cache everything" 又会破坏登录用户体验,甚至可能缓存不该缓存的内容。Cloudflare 的 DDoS 防护挡掉了一部分流量,但攻击大多数时候低于自动检测阈值,不足以让 origin 恢复正常服务。
origin 自己也没有正确表达"我撑不住"。攻击时 ss | wc -l 看到约 35 万连接。Cloudflare 期望 origin 在撑不住时返回 429、503,或者拒绝连接,而不是无限接受连接让它们挂着。作者承认这是自己没做好。Rust 不会自动给你容量保护。CPU 几乎打满,perf 显示热点在 SQLite、Liquid 模板、HTML rewriting/truncation、malloc、mutex 等地方。网站架构优先内容创作便利性:Liquid/SQL 像他的 Python/C,Rust 负责底层服务、markdown、过滤器、HTTP stack。平时足够快,但首页被狂打时,这些动态计算被无限放大。
作者开始修 origin。第一步用 Tower 的 GlobalConcurrencyLimitLayer 限制最大 in-flight requests,但这没有立即解决问题,因为多出来的请求仍然堆积。更好的做法是 load shedding:超过系统能优雅处理的上限时,直接快速失败,让 Cloudflare 这类边缘层看到信号并回退。第二步是限制并发连接,用 tokio::sync::Semaphore、tokio_util::sync::PollSemaphore 和自定义 Tower ServiceFactory 实现。连接 service 持有 OwnedSemaphorePermit,连接结束时 permit 自动释放。
连接限制又暴露新问题:连接槽会被 idle 连接占满。于是作者加了 idle read/write timeout。因为他引入了自定义 acceptor,也顺手修了另一个洞:origin 不应该监听 0.0.0.0:80 让全世界直连,而应该只接受 Cloudflare IP ranges 和本地连接。这个 acceptor 用 from_stream、async-stream、tokio_io_timeout、ipnet、arc-swap、reqwest,定期拉取 Cloudflare IP 段,accept 时检查来源 IP,并给连接套读写超时。作者也承认,更好的方式是在防火墙层做 allowlist。
他还写了一个 Tower Layer 封禁攻击使用的固定 User-Agent。但这后来误伤了正常读者:攻击结束后,使用 Linux Chrome 的读者无法访问网站。作者一开始以为是 Cloudflare 问题,后来才发现是自己 origin 里的 UA 封禁忘了撤。这个小插曲说明,DDoS 的次生伤害包括防御措施误伤合法用户,而人在事故中到处救火时很容易遗留临时规则。
之后,作者给主站接入 Sentry、OpenTelemetry、Honeycomb 和 tracing。每个 HTTP 请求都有自己的 span,记录 method、URL、status code、User-Agent、host、request_id、user_id 等。Honeycomb 的 trace 让他清楚看到首页请求耗时来自 Liquid 渲染、SQL 查询、page_markup 和 truncate_html。它还暴露出一堆优化机会:SQLite 连接池默认只有 10,高负载下等待 checkout;阻塞 SQLite 查询跑在 async context;没有 prepared statement cache;SQLite 数据库没有索引;truncate_html 会把整篇文章喂给 HTML rewriter,即使只想保留前 120 个字符。
但作者没有先修这些,而是直接做整页缓存。因为平时延迟够用,真正需要的是让匿名用户看到的动态页面短时间内不要重复渲染。他接入 moka,给未登录用户的渲染结果加缓存:TTL 5 分钟,TTI 1 分钟。serve_template 根据 path 和 query 构造 cache key,识别 cookie 是否包含有效 credentials;有登录态就不缓存,匿名生产环境请求才查缓存和写缓存。缓存 body 用 Bytes 保存,命中时 clone 只是增加引用计数,非常便宜。这里必须非常小心:带 credentials 渲染出来的页面不能放入匿名缓存,否则会有安全问题。
整页缓存的效果非常夸张。缓存前,本地压测约 88 requests/sec,P95 约 860ms;缓存后,约 34K requests/sec,P95 约 2.4ms。最重页面最大 RPS 从约 90 提升到 34K。这样攻击者如果继续打未登录页面,就必须超过 Cloudflare 的 DDoS 检测阈值才可能造成影响。视频平台则没有改,因为它从一开始就设计了 SSD cache 和多区域实例,攻击流量基本由缓存承载。
文章发布后,攻击很快又恢复。新的攻击来自 Alibaba、OVH、Azure、Hetzner、Linode 等 AS。一开始网站出现 522 Origin Connection Time-out。作者发现连接限制设得太低:Cloudflare 自己约有 275 个 PoP,每个 PoP 可能和 origin 建立多个连接。于是他把连接和 in-flight requests 上限提高到 2048,522 下降,200 上升,随后 Cloudflare 也开始拦截,攻击者退去。
整篇文章最后的价值在于:它展示了一个真实网站在攻击下暴露出来的工程债。Cloudflare 不是魔法,Rust 不是魔法,CDN 不是自动缓存所有东西。一个系统要抗压,必须有缓存、限流、连接控制、idle timeout、load shedding、origin allowlist、可观测性、回滚临时规则的流程,以及对登录态和缓存边界的清晰理解。DDoS 是非法攻击,但对被攻击者来说,它也可能是一场很真实的免费压测。真正重要的是,从压测里学到什么。