人们真正搞懂 DDoS 缓解是怎么回事,通常要经历两个时刻。第一个时刻很平静,发生在下单付款的时候:你读到功能列表上写着"含 DDoS 防护",不假思索地以为这句话涵盖了一切。第二个时刻是在凌晨三点,网站打不开,监控图表以一种毫无道理的方式变得不对劲,而那份写进套餐里的防护——准确地说,也是按设计地——什么都没做。
这两个时刻说的其实是同一个产品和同一个事实:主机商会过滤以纯粹流量形式抵达的攻击,因为它拥有这些数据包所经过的那条管道,而你没有;它没办法过滤看起来像普通请求的攻击,因为站在网络的角度看,它们本来就是普通请求。这条界线——主机商能替你扛住的洪水,和你必须自己扛住的洪水,两者之间的界线——就是这篇指南的全部主题。接下来的内容,都是在帮你判断自己站在哪一侧,以及每一侧各自该怎么办。
同一个名字,两种截然不同的攻击
"DDoS"这一个词,涵盖了两个几乎毫无共同点的问题,唯一相同的只是结果。它们要在不同的地方、由不同的人、用不同的工具来阻止,而把两者混为一谈,正是那么多防护投入落错层面的原因。
| 流量型——三层和四层 | 应用层——七层 | |
|---|---|---|
| 会出现什么 | SYN 洪水、经开放 DNS、NTP 或 memcached 反射器发起的 UDP 放大攻击、ACK 洪水、纯粹的垃圾数据包 | 看起来正常的 HTTP 请求:GET 洪水、POST 洪水、Slowloris、用于绕过缓存的查询字符串 |
| 计量单位 | Gbps,以及每秒数百万个数据包 | 每秒请求数——往往只有几千 |
| 打垮你需要多少带宽 | 巨大。这是一场容量比拼 | 几乎不需要。只要接口足够昂贵,一台笔记本电脑就能做到 |
| 必须在哪里被拦下 | 在上游,由你的主机商拦下。等数据包抵达你的端口时,伤害已经造成 | 在你的服务器上,由你自己拦下,或者在你控制的前置代理上拦下 |
| 在机器上表现为 | 接口被打满,数据包计数高得离谱,CPU 却可能是空闲的 | 带宽并不起眼,但每个 worker 都在忙,负载在攀升,数据库队列在变长 |
| 由谁来解决 | 主机商的流量清洗,自动完成,通常在几秒钟内 | 你自己的配置——限速、缓存、连接数上限 |
把最后两行再读一遍,因为真正实用的要点就在这里。如果接口已经被打满,不管你在服务器里敲什么命令都没用:数据包早就把端口占满了,唯一能把它们丢弃的,是拥有上游那台路由器的人。如果接口很安静但网站还是打不开,那情况正好相反——你的主机商看不出任何问题,因为站在它的角度,确实什么问题都没有,而修复完全是你自己的事。

"含 DDoS 防护"这句话,到底买到了什么
网络层防护是真实存在的,也确实有价值,只是几乎总是被误读。当一家主机商宣称提供 L3/L4 过滤时,意思是它的网络会监测发往你地址的流量,一旦检测到洪水,流量就会被引导经过清洗设备,恶意部分被丢弃,看起来合法的部分则被继续转发。整个过程不需要你开工单,通常你也只会感觉到短暂的一下波动,甚至完全无感。
这一句话背后其实包含了很多内容,值得拆开来看看它到底包含什么、不包含什么:
- 它覆盖的是你独自一人扛不住的攻击。用一场 200 Gbps 的放大攻击,打一台只有 1 Gbps 端口的服务器,这不是配置问题,是纯粹的算术问题。上游清洗是唯一存在的答案。
- 它对你的应用是无状态的。这道过滤器不知道你哪个 URL 开销大、哪些访问者已经登录,也不知道一次对搜索接口的请求,开销是请求你 logo 图片的四百倍。
- 它响应的是阈值,不是你的痛苦。检测靠的是流量大小触发。一场从未越过阈值的攻击永远不会触发它,不管这场攻击把你的网站打垮到了什么程度。
- 在极端情况下,它可能会短暂把你的地址空路由掉。每一张网络都有上限。如果一场攻击威胁到共享基础设施,你的地址可能会被暂时断开一段时间——这是全行业的通行做法,值得在它发生之前就了解清楚,而不是等到发生时才第一次听说。
一句话版本:你的主机商保护的是它自己的网络,你也因此受益。它保护不了你的应用,而且根本没有办法保护。七层不是一项被故意藏起来不卖给你的增值服务——而是一层主机商如果不终止你的 TLS,就根本看不进去的层面,而对任何选择离岸托管的人来说,终止 TLS 是一笔代价不小的交易。
第一步:先判断这到底是不是一场攻击
相当一部分被怀疑是 DDoS 的事件,其实只是别的问题披了一件 DDoS 的外套,而对应的解法并不能互换。在对任何东西限速之前,先花两分钟排除这些冒牌货——这一步一旦误判,轻则搭进去一个小时,重则搭上你的真实用户。
- 你火了。一个大型聚合站给出的链接,产生的流量形状和七层洪水一模一样,唯一的区别是来源(referrer)是真实的,请求的也是真人会想看的页面。这是一个理由让人开心的容量问题;对它限速等于自残。
- 一个爬虫没了分寸。激进的抓取程序和 AI 训练机器人,轻轻松松就能把一台小型服务器跑垮。user-agent 通常会自己露馅,解法是
robots.txt加上一条针对性的限制,而不是一条一刀切的通用限制。 - 是你自己搞坏了什么。一次意外关掉了缓存的部署、一个失控的 cron 任务、一张丢了索引的数据表——这些都会表现成"负载突然飙升,原因不明"。如果时间点和你做过的某次改动对得上,那就相信那次改动。
- 洪水其实是你自己的监控系统。这种情况少见、说出来也挺丢人,但比大家愿意承认的要常见得多。一个没有退避机制、不断重试的健康检查循环,能刷出一个货真价实、令人印象深刻的请求速率。
判断的关键问题很简单:这些流量是不是真的想要什么东西?真实的负载——哪怕看起来来势汹汹——是有形状的。它访问的是真实存在的页面,会跟着链接走,会加载资源文件,而且来自一个说得通的、分散在多个网络上的分布。而攻击通常懒得做这些。
直接从服务器上读懂这场攻击
你不需要一个仪表盘才能判断发生了什么。依次运行四条命令,一分钟之内就能知道自己是在哪一层作战——而这个判断决定了你接下来做的每一件事。
管道满了吗?盯着接口计数器看。如果吞吐量被死死钉在端口上限附近,你面对的就是一场流量型攻击,你要做的是开工单,而不是改配置:
vnstat -tr 10——十秒钟的平均吞吐量,是判断是否被打满最快、最诚实的一种读数。- 间隔一秒执行两次
cat /proc/net/dev——得到每个接口的数据包和字节增量,不需要额外工具。
是 SYN 洪水吗?半开连接会堆积在 SYN-RECV 状态。少数几个是正常的;几千个就不正常了:
ss -s——摘要行,一眼看到按状态分类的连接数。ss -tn state syn-recv | wc -l——那个真正要紧的具体数字。
是七层吗?如果带宽并不起眼,但一切都在变慢,那就去访问日志里数一数按客户端计算的请求数。一个地址打出几万次请求,那是业余的;十万个地址、每个只打三次,那才是真家伙:
tail -n 100000 access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -30- 把
$1换成$7,改为按请求路径排序。如果某一个开销很大的接口一枝独秀,你就找到了目标,同时也找到了一半的解法。
真正耗尽的是什么?load average 单独看意义不大。要找的是你具体撞上的那道上限:PHP-FPM 的子进程全部忙碌、数据库连接数到顶、文件描述符耗尽,或者 worker 卡在等待磁盘的 D 状态。打垮网站的是那道上限,而不是流量本身,而提高这道上限,往往比过滤任何东西都更快见效。
排查的同时,记住这一点:如果你运营的是一项注重隐私的服务,攻击发生的当口,恰恰是你最容易忍不住把日志级别调高、然后就再也没调回去的时候。如果必须调高,那就调高,但事后一定要调回去,并且把日志文件积极地轮转清理掉。一次让整整一个月的完整访客日志留在磁盘上的事故,等于是拿一个问题换来了一个更糟、存续更久的问题。我们的服务器 OpSec 指南详细讲了这方面的纪律。
最初的十分钟
在压力之下,人们会本能地去拉那根看起来最大的杠杆,而那根最大的杠杆往往是错的。下面这个顺序能把损失控制到最小,大致按照又快又安全的程度排列:
- 先分类,再动手。接口被打满,说明是流量型;接口安静但 worker 都很忙,说明是七层。这里花三十秒,能省下一整个小时修错层面的功夫。
- 如果是流量型,立刻开工单,附上目标地址、开始时间和你的接口计数器数据。然后不要再对着服务器敲命令了——这不是在服务器内部能修好的事。
- 如果是七层,先上缓存。为匿名访问者开启激进的整页缓存,是把一次宕机变成一次无关紧要的小插曲的最快办法,而且它也是唯一一项对真实流量同样有帮助的措施。
- 然后再限速——先限被针对的那个接口,再限其他一切。一开始就保守一点。一条把自己用户也限没了的规则,等于是自己接着攻击者继续打自己。
- 只拦截毫无疑问的目标。十几个各自打出十万次请求的地址、一个明显是伪造的 user-agent、一个你压根没有用户的国家。压住那种想在交火当下就写一堆精巧规则的冲动;下个月你根本不会记得自己写过什么。
- 如果必须减负,那就有意识地去减。给未登录访问者返回一个静态的"维护中"页面,能让主机保持存活、让你的 API 继续可用,还能给你争取思考时间。自己决定牺牲什么,总好过被局势替你决定。
- 把你做过的事情记下来。每一条你临时加上的规则,都是埋给未来的你的一颗地雷。有用的规则就把它变成永久规则;其余的,明天就撤掉。
真正扛得住的限速,以及几乎所有人都会犯的错
限速是应对七层的主要手段,nginx 用两条指令就能把它做好,而这两条指令解决的其实是两个真正不同的问题。limit_req 限制的是请求速率——一个客户端多久能问一次。limit_conn 限制的是并发数——一个客户端能同时占用多少个连接。洪水攻击要靠前者来防;而靠几千个近乎空闲的连接来耗尽你 worker 池的 Slowloris 攻击,要靠后者来防。只部署其中一条,你就只挡住了这个问题的一半。
三个细节,决定了一条限速规则是真的有用,还是只是摆设:
- 要设置突发余量(burst),并且用上
nodelay。真实浏览器的请求本来就是一阵一阵的——加载一个页面就会几乎同时打出十几个资源请求。一条没有余量的限速规则,会把真实访问者卡住,而一个把节奏压在阈值以下的攻击者反倒能大摇大摆地通过。 - 对开销大的接口单独限速。你的搜索页面、登录表单、密码重置,以及任何会写入数据库的接口,理应比静态资源拿到严格得多的配额。攻击者不费吹灰之力就能找到这些接口,因为正是这些接口最伤。
- 返回 429,而不是 503。这个状态码是在向守规矩的客户端和搜索引擎发出信号:这是限流,不是故障——它能防止一个糟糕的下午,最后又演变成一个排名问题。
一个会悄无声息让上面这一切全部作废的错误:只要你的服务器前面挂着任何东西——CDN、负载均衡器,或者你自己的反向代理——那么每一个请求抵达时,用的都是它的地址,而不是访问者的地址。这时按客户端计算的限速,算的其实是把整个互联网当成了一个客户端,结果要么完全不起作用,要么把你的全部访客一次性封光。你必须先配置好真实 IP 来源(在 nginx 里,给代理的地址段配置 set_real_ip_from,再给它发送的头部字段配置 real_ip_header),这些限速规则才谈得上有意义。而且这份信任也必须只限于代理自己的地址段:如果信任一个来自公网、由客户端自行提供的头部字段,攻击者就能在每个请求里伪造一个新身份,直接穿过你设下的所有限制。
缓存是你能部署的最廉价的防护手段
限速拒绝的是工作。缓存则让工作根本不存在。对匿名访问者能看到的任何内容,整页缓存都会改变整场攻击的经济账:一个原本要花一次数据库往返、一次模板渲染和一个 PHP worker 才能完成的请求,变成了一次以微秒计的文件读取。同一台在每秒四百个动态请求下就已经瘫痪的服务器,能在毫无压力的情况下,应对每秒几万次的缓存命中。
真到要动真格开启缓存的时候,以下几点才是关键:
- 只为匿名访问者做缓存。遇到会话 cookie 就绕过缓存。把一个登录用户的页面发给另一个用户看,是比你正在修的这场宕机严重得多的事故。
- 有意地返回过期内容(stale)。nginx 的
proxy_cache_use_stale配合updating error timeout,意味着后端吃力的时候,访问者拿到的是一个稍微过期的页面,而不是一个错误页。在攻击期间,这就是一个看起来正常的网站,和一个看起来已经挂了的网站之间的区别。 - 合并重复的缓存未命中。
proxy_cache_lock能保证一千个同时请求同一个尚未缓存页面的请求,最终只产生一次到后端的请求,而不是一千次。少了它,一次绕过缓存的攻击会直接穿过缓存,全力砸在你的数据库上。 - 拆掉用于绕过缓存的查询字符串这一招。常见手法是在后面附上一个
?和一个随机值,让每个请求都成为独一无二的缓存键,永远命中不了缓存。把你的缓存键规范化,忽略掉那些应用实际上根本不会用到的查询参数。
这里有一种值得记在心里的令人愉快的不对称:你花在缓存上的每一个小时,同时也会让网站在风平浪静的日子里更快、运行成本更低、更扛得住突然爆红。几乎没有别的哪一道防线,能在什么坏事都没发生的时候也带来回报。
决定你会不会被打垮的那些上限
大多数服务器倒下,不是因为 CPU 用光了。它们倒下,是因为撞上了一道没有人刻意设置过的隐形上限——一个十年前定下的默认值,在早就没人再用的硬件上曾经合理过。真正在攻击中最先崩溃的,是下面这些:
- 应用 worker。PHP-FPM 的
pm.max_children、你的 Python worker 数量、你的 Node 集群规模——这才是你网站真正的并发上限。一旦全部忙碌,每多来一个访问者都要排队,而不管 CPU 看起来多闲,网站都已经等同于宕机。把这个数值提高到内存允许的极限为止——发生 swap 比排队还要糟糕。 - accept 队列。
net.core.somaxconn和你的监听队列(backlog)长度,决定了有多少个连接可以排队等待被接受。一个过小的 backlog,会把一次本来扛得住的突发流量,变成一堆被拒绝的连接。 - SYN cookies。
net.ipv4.tcp_syncookies能让内核应对 SYN 洪水时,不必为那些永远不会建立完成的连接分配状态。现代内核默认会启用它;去确认一下,而不是想当然地认为它已经开着——这项检查不花你一分钱,却能让你躲开最常见的一种洪水攻击。 - 文件描述符。每一个连接都是一个描述符。默认的
nofile限制,常常低于你实际要处理的连接数,而它的失败方式——accept 不断失败,但表面上一切正常——在凌晨三点真的会把人搞糊涂。 - 数据库连接。只提高 worker 数量而不提高连接池大小,只是把排队现象挪到了一个更难看见的地方。这两个数字必须一起调整。
把这些参数调好要趁风平浪静的日子,而不是等到事故发生时。了解它们的意义在于,网站一旦倒下,你能直接说出它撞上了哪一道上限,而不是靠猜——而一道能被说出名字的上限,就是一道能被修好的上限。
在源站前面放点什么
以上这些都发生在服务器本身上。下一步要决定的是,这台服务器究竟要不要直接承接流量。老老实实说,一共有三种选择,而正确答案取决于你托管的是什么,远胜于取决于你的预算。
| 方案 | 它给你带来什么 | 它让你付出什么 |
|---|---|---|
| 源站直接暴露,做好加固 | 简单,没有第三方,TLS 终止完全掌握在自己手里 | 你的地址是公开且永久的。七层完全靠你自己扛 |
| 商业 CDN 或清洗服务 | 巨大的吸纳能力,一键开启的验证页面,全球范围的缓存 | 一个对你的内容有自己看法的投诉窗口,以及一家能看到你流量的公司。对离岸项目或涉及 DMCA 敏感内容的项目来说,这可能是整套精心配置里最薄弱的一环 |
| 自己的前置节点——一台运行 nginx、代理流量到已用防火墙锁死的源站的小型 VPS | 完全的控制权,请求路径中没有第三方,一个可以随时烧掉重换的地址,以及一个始终藏在幕后的真实地址 | 它自己也有容量上限,还多了一台要维护的机器。在不同网络里部署两三个前置节点,能让攻击者明显更难把你打垮 |
自建前置节点这个方案,应得的关注通常比它实际得到的要多,对那些选择离岸托管的理由未必会得到大型 CDN 认同的人来说尤其如此。这套模式并不光鲜:前面是几个便宜的代理节点,源站用防火墙锁死、只接受这些节点发来的连接,DNS 指向这些前置节点。一旦某个前置节点被攻击,几分钟内就能换上一个新地址,源站完全不会察觉。这套架构的完整版本——包括源站地址依然会泄露的六种方式——是我们隐藏源站 IP这篇指南的主题。
隐藏源站,比任何过滤器都更有价值
这一点值得直白地说出来,因为它颠倒了通常的优先级:对你来说,成本最低的 DDoS 缓解手段,是一个攻击者根本拿不到的地址。过滤,是在这一点已经失守之后才要做的事。
这一点的分量比听起来要重得多,因为源站地址一直在悄悄泄露。你把代理接上之前留下的历史 DNS 记录,会比这次变更多存活好几年。应用直接发出的邮件,邮件头里就带着这个地址。为裸地址签发的 TLS 证书,会被永久发布在证书透明度日志里。一个错误页面、一次重定向,或者一个从未被代理过的冷门子域名,都会把它暴露出来。如果你是给一台曾经直接暴露过的服务器,事后才在前面接上 CDN,那就应该假定旧地址已经泄露,除非你已经把它换掉。
由此推出的一条防火墙规则,是这篇指南里单条价值最高的一行:一旦前面有东西在挡着,源站就应该在 80 和 443 端口上,拒绝除了前端地址之外的一切连接。没有这条规则,代理就只是一个建议——任何人只要得知真实地址,就能绕开代理直接攻击你,而你在前端配置的一切都成了摆设。
选好硬件和节点位置,让攻击变得无聊
这里面有一部分,是在攻击发生之前、在你选套餐的那一刻就已经决定了的。有三项属性,比配置表上看起来的更重要:
- 不限流量带宽。在按流量计费的套餐上,一场攻击带来的不只是宕机,还有一张账单。那些你从未招惹、也无法拒绝的流量,依然会计入你的流量配额。不限流量带宽能把一个财务风险,变成一个纯粹的技术问题,而技术问题是好应付得多的一类问题。
- 端口是不是完全属于你。在共享的虚拟化主机上,邻居遭到攻击也会拖累你,而且你自己能拿到的防护上限,也是和别人共享的。拥有独立端口的独立硬件,能同时消除这两种影响。对一个预计会招来恶意关注的项目来说,这是从 VPS 升级上去最充分的理由——比核心数或内存都更充分。
- 网络位于哪里。一张拥有真实中转容量、对等互联(peering)充分的欧洲网络,扛得住的洪水规模,是一张对等互联薄弱的网络望尘莫及的,而你出于法律原因选中的司法辖区,同样有自己的网络特性。在从可选的节点位置中挑选时,这两点都值得确认。
还有一个关于规模的论点,不动声色地站在简单这一边。一个放在普通服务器上、有缓存挡在前面的静态网站,极难被打垮;而同样的内容如果放在一套笨重的 CMS 上,搜索接口又没做缓存,一个有决心的人靠一个脚本就能把它打垮。减少动态的部分,本身就是一种防护手段,而且是免费的。如果你的项目确实长期处于高负载之下,我们关于高流量托管的说明,讲的正是同一个问题里选型这一面。
五件不该做的事
这里的失败模式很有规律,规律到值得列出来,而每一条都曾经让某个人搭进去一个周末:
- 不要自己把自己的地址空路由掉。把自己的地址拉进黑洞,是让攻击终止的最字面意义上的一种办法——没有人能连到你,包括你的用户在内。这是主机商才会用的最后手段,不是你该主动采取的行动。
- 不要把 fail2ban 当成 DDoS 防护。面对来自少数几个地址的暴力破解尝试,它是个称手的工具。但面对一场分布式洪水,它要花几分钟才能反应过来,而洪水几秒钟就到了,而且一条封禁了几千个地址的规则,在防火墙处理上消耗的资源,可能比这场攻击本身造成的损失还大。
- 不要付赎金。绝大多数扬言要发动毁灭性攻击的勒索邮件,来自那些根本没有能力动手、只是群发几千封一模一样邮件的人。而那一小撮真有能力兑现威胁的人,会再来一次,因为你已经证明了自己会付钱。
- 不要报复回去。这么做在几乎所有地方都是违法的,而且攻击流量的来源,本身就是被攻陷的第三方。你打的其实是受害者,而且是用一个明明白白属于你自己的地址去打。
- 不要在恐慌中迁移。在攻击进行中换主机,意味着新地址几分钟内就会公开,而且你手头没有任何调好的配置。先稳住局面,以后再从容地迁移——如果你确实要迁移,我们那篇关于不停机迁移的指南存在的意义,正是让这次迁移本身不至于变成第二场事故。
简版结论
把推理过程去掉,可执行的模型可以压缩成八行:
- 先分类。接口被打满,说明是流量型,归你的主机商管。接口安静但 worker 已经耗尽,说明是七层,归你自己管。
- 流量型就开工单,附上地址、时间戳和你的计数器数据——然后不要再碰服务器了。
- 为匿名访问者激进地开启缓存,承压时返回过期内容,并合并重复的缓存未命中。这是你能做的杠杆效应最大的一项改动。
- 同时按请求速率和并发数限速,对开销最大的接口收得最紧,并返回 429。
- 在做以上任何一件事之前,先修好你的真实 IP 配置,否则代理背后的每一条按客户端限速规则,要么形同虚设,要么后果灾难性。
- 了解你的各项上限——worker、backlog、描述符、数据库连接——并且趁风平浪静的日子,有意识地把它们提高。
- 让源站地址保密,并用防火墙锁死,只放行你的前置节点。这一条比所有过滤器加起来都更有价值。
- 购买不限流量带宽,这样你没招惹过的流量,就永远不会同时变成一张账单。
以上这些都不会让你彻底免疫,而任何声称能卖给你免疫力的人,卖的其实是别的东西。它们真正做到的,是把你从一个无聊的青少年就能轻易打下线的那群人里挪出来,挪进一个必须动用真正的资源、怀着真正的意图才能打扰到的那群人里——而对绝大多数项目来说,这已经和绝对安全没有区别了。剩下的,就是那些让一台服务器在其他方方面面同样出色的、并不光鲜的日常功夫:从第一天起就加固到位,在最糟的一天也能恢复如初,并且把它放在一个把你的流量当成你自己的事的地方运行。