先确认这确实是一次攻击
故障处理中最昂贵的错误,就是诊断错了对象。"网站变慢了"这个症状,可能来自一次真正的洪流攻击,也可能来自一次上线了未加索引查询的部署、一个每小时同一分钟就开始转储数据库的 cron 任务、一个发现了你分面搜索的爬虫、一条冲上了某个大站首页的链接,或者一块被写满的磁盘。这些情况在浏览器里看起来一模一样,而对应的处理方式却互相排斥:你不会希望仅仅因为一次迁移漏加了索引,就去对真实客户限速。
在一分钟之内,三个问题就能把它们区分开。流量真的异常,还是流量正常而服务器变慢了?洪流攻击表现为数据包或每秒请求数的阶跃式变化;糟糕的部署则表现为请求量正常而响应时间崩掉。过去一小时里,你这边有没有改动过什么?先查部署日志,再查防火墙——在小规模基础设施上,自己捅的篓子远比遭到攻击更常见。负载是分散在整个网站上,还是集中在某一条路径上?真正的洪流通常是不分青红皂白的,或者直接冲着首页去;一个昂贵的接口被上百个客户端反复敲打,更接近滥用而不是 DDoS,而且修复成本低得多。
回答完这些问题,再往下走。本指南接下来的部分,都假定答案是:流量确实异常,你这边什么都没改,而这台机器正在被淹没。
SP·02五分钟分诊
只有两种故障模式真正要紧,而它们需要完全相反的应对方式。要么管道满了——数据包涌入的速度超过了你的上行带宽或内核的处理能力,流量在到达你的任何软件之前就已经丢失;要么管道没问题,是应用被拖垮了,因为格式良好的请求到达的速度超过了它能应答的速度。把两者搞混会白白浪费这一个小时:面对被打满的链路,调 nginx 毫无作用;面对请求洪流,多买带宽也毫无作用。
用两个数字的对比来区分它们。同时查看网卡计数器和 CPU 的时间分布。如果 rx 字节数被死死钉在接近端口速率的位置,如果 dropped 或 overrun 计数器在持续攀升,并且耗时主要花在软中断而不是你的应用上,那么这就是发生在三层或四层的洪流,是一个容量问题。如果带宽看起来平平无奇,但工作进程池已经饱和、连接在排队,且访问日志里满是看起来合理的请求,那就是七层问题,是一个过滤问题。
接下来,把三层/四层这一类情况再细分一步,因为几种子类型的表现并不相同。SYN 洪流表现为数以万计处于 SYN-RECV 状态的半开连接;一旦开启了 syncookies,内核能很好地应付这种情况。UDP 洪流或反射放大攻击表现为大量流量涌向你根本没有监听的端口——DNS、NTP、memcached、CLDAP 反射——而且你在服务器上做什么都没用,因为损害在数据包到达网卡的那一刻就已经造成了。分片洪流或原始数据包洪流表现为每秒数据包数很高而带宽却不起眼,这会耗尽 CPU 而不是链路。在动配置文件之前,先记下你遇到的是哪一种。
机器内部解决不了的问题
这是大多数文章都会跳过的部分,却也是决定你这一个小时是否有效的部分。在目标机器上加一条防火墙规则,救不了被打满的上行带宽。你那条 iptables 的 DROP 规则,运行在管道末端的这台机器上——数据包早已穿过了中转链路,早已消耗掉你花钱买的带宽,也早已把一个真实用户的数据包挤了出去。在本地丢弃它,能保护你的应用不去浪费处理周期,这一点是有价值的,但对你的带宽而言,什么保护作用都没有。
单台服务器诚实的上限,大致是两个数字里较小的那一个:它所连接端口的速率,以及它的 CPU 能分类处理的每秒数据包数。一个 1 Gbps 的端口,无论你的规则集写得多精巧,到了 1 Gbps 照样是满的;而一场规模不大的小包洪流,足以在带宽数字看起来还不吓人的时候,就先把几个核心的中断处理能力耗尽。过了这个点,唯一管用的办法,是在更上游的地方,有一台容量比攻击流量还大的设备,在流量抵达你的链路之前就把它丢弃。这就是清洗的本质,也是为什么我们机群里的每一台机器,前面都挂着 1.5 Tbps 的全天候清洗,而不是一个更大的防火墙。
推论同样重要:如果你没有上游防护,服务商面对一次大规模流量攻击的应对方式就是把你的地址空路由掉,因为另一个选择是让同一条链路上的所有其他客户都跟着降速。这不是恶意,是算术问题——它意味着攻击者靠的是把你变得昂贵来获胜,而不是靠打垮什么东西。提前弄清楚清洗防护是你套餐自带的,还是一项你从未开通过的付费增值服务,是一项五分钟就能做完的检查,值得今天就做,而不是等到事故发生时才做。我们家是每个套餐都自带,这也是凌晨三点唯一真正管用的安排。
SP·04L7:看起来像正常流量的洪流
应用层洪流更难对付,因为每一个单独的请求都是合法的。一次构造精良的 HTTP 洪流会完成 TCP 握手、协商好 TLS、发出一个带着可信 User-Agent 的合法 GET /,然后读取响应。里面没有一个数据包是畸形的。真正要命的是算术问题:攻击者发一个请求的成本几乎为零,而你要为此付出一次数据库查询、一次模板渲染,以及一个工作进程被占用的上百毫秒——这段时间它没法再服务任何别人。
破绽就在你自己的访问日志里,而且只要你是带着目的去找,而不是漫无目的地看,通常都很明显。用来击穿缓存的查询字符串——成千上万个指向 /?1234567 的请求,每一个都是能绕开你所有缓存的独一无二的 URL——是最常见的单一特征。没有长尾的 User-Agent 分布:真实流量是数百种浏览器版本混杂在一起的一团乱麻,而洪流往往只是三四个字符串被重复了上百万次,或者是一个根本没有真人在用的字符串。Referrer 字段在所有请求里完全一样。请求彻底跳过了你的静态资源——真实浏览器会在拿到 HTML 之后接着请求 CSS、字体和图片;而洪流客户端只要 HTML,拿到就走。以及来源分布过于扁平:一个僵尸网络分散在一万个住宅 IP 地址上,每个地址每秒发两个请求,乍看像是流量火爆,直到你注意到每个地址的速率均匀得可疑。
还有一种变体,几乎不需要任何流量:慢速攻击。几百个连接建立起来,每二十秒发一个请求头,永远不会发完,这样就能占满你所有的工作进程,而你的带宽曲线却依然平坦。解决办法不是限速——请求速率本来就小得可怜——而是激进的请求头和请求体超时设置,这也是为什么它们会出现在下面第一个配置代码块里,而不是作为事后补充。
SP·05四种手段,按每分钟收效排序
压力之下,先做杠杆效应最大的事。下面的顺序不是随便排的,大致是按照你每投入一分钟注意力能卸掉多少负载,从高到低排列的。
- 提供廉价的响应。在应用前面加一个三十秒的微缓存,能把每秒一千个完全相同的请求,变成一次真正打到源站的请求加 999 次内存读取。这是应对几乎所有 HTTP 洪流最大的一根杠杆,只需要一个指令块的成本,而且对匿名流量来说几乎总是安全的。加上
proxy_cache_lock,这样缓存未命中时就不会把一大群请求同时甩给后端。 - 限制并发数,缩短超时时间。按地址设置的
limit_conn,再加上严格的请求头、请求体和 keepalive 超时,能直接打死慢速攻击,也能防止任何单个客户端长期占用你的工作进程池。在所有手段里,这是对真实用户代价最小的一种。 - 限速,并搭配突发余量。配合合理突发值的
limit_req精准,但调起来更费时间,而且如果你是在恐慌之中随手设的,而不是根据自己的基线来设的,它就是最容易产生误判的那一种。你得先弄清楚每个客户端正常情况下每秒请求数是多少,才能选出一个合理的数字——这也是为什么本指南结尾的复盘部分,比听起来更重要。 - 封锁,要窄,要不情不愿。丢弃特定网段,在来源集中时有效,在来源不集中时毫无作用。它还会随着时间变质:你在一次事故中加的每一条封锁规则,三个月后都可能变成一个被你悄悄拒之门外的客户。用带超时的 set,让规则到期自动失效。
注意,清单上没有这些做法:手动逐个封禁 IP 地址、反复重启 Web 服务器,以及关掉防火墙"看看有没有用"。第一种面对分布式来源慢得没有意义,第二种会丢掉你所有的热连接,第三种则是一场事故演变成一次入侵的经典方式。
SP·06别在装着你数据的那台机器上正面硬扛
上面提到的每一种手段,只要运行在存放数据库的那台机器之外,价值都会更大。如果你的边缘节点是独立的一台机器,洪流就会终结在一台专门负责终结洪流的机器上:它做缓存、做限速、做丢弃,手里还握着真实的客户端地址,而源站只会通过一条私有隧道,看到经过过滤后剩下的那一小部分流量。边缘节点倒下时,你能在 15 min 内把它换掉,什么都不会丢,因为它上面本来就什么都没存。源站倒下时,你面对的就是一次停机加一次恢复。
这种分离还能堵上另一个漏洞——正是这个漏洞让大多数防护形同虚设。如果源站仍然留有一个公网监听端口,攻击者只要通过被动 DNS、一条证书透明度记录、一条 MX 记录或一次链接预览找到这个地址,就能绕开你配置的所有防护,直接打到应用上。这种情况足够常见,以至于在证明并非如此之前,都应该把它当作任何一个"受保护"网站的默认状态来看待。搭建一套真正扛得住的版本,是另一篇指南的内容:一套源站在公网完全没有监听端口的离岸反向代理方案。
关于事故进行中的一点提醒:这是架构问题,不是急救措施。在遭受攻击的同时搭边缘节点、改 DNS、重建隧道,是一件需要两个小时、而且在压力之下大概率会做得很差的工作,而且光是 DNS 变更生效,就得等上你 TTL 设定的那么长时间。如果你已经有了,那就用它。如果没有,就先用手头现有的手段扛过这一个小时,等风平浪静的那一周再把它搭起来——而这恰恰是几乎没有人会做的事。
SP·07你是反射器而非目标的那一天
这场事故还有第二种版本:你的服务器根本不是受害者,而且没人会主动告诉你这件事。一个开放的解析器、一个暴露在外的 NTP 守护进程、一个跑在公网接口上未经身份验证的 memcached、一个容器里的 SSDP 或 CLDAP 响应器——它们每一个,都会用一个大得多的响应,去回应一个指向别人的、伪造来源的小请求。从你这一端看,症状是反过来的:出站带宽很高,入站带宽平平无奇,你的应用一切正常,而第一个真正的信号,往往是一封滥用投诉,或者一个被停用的端口。
这项检查只要一分钟,而且应该写进同一份运维手册,因为用的就是你在分诊阶段已经跑过的那条命令。ss -tulpn 列出的结果里,不应该有任何一个是绑定在公网地址上、却不是你故意放上去的;UDP 服务尤其值得怀疑,因为放大攻击靠的正是它们。递归解析器必须绑定在 localhost 或隧道地址上;memcached 和 Redis 绝不能从公网访问到;任何用 -p 0.0.0.0: 发布端口的容器,都刚刚在你配置好的防火墙上凿了一个洞,因为 Docker 会把自己的规则写在你的规则前面。最后这一点,每次都会让人大吃一惊。
同样的情况,也涵盖了一台已经被入侵的机器发出的出站洪流,这是服务商突然把一个地址空路由掉的另一个原因。如果你的出站流量曲线很高,而应用却很空闲,别再盯着配置看了,去查进程——那是入侵,不是容量问题,正确的应对是从一份已知良好的备份重建这台机器,而不是去过滤它。
SP·08停止之后:复盘与常备工具包
攻击总会停止。通常是攻击者玩腻了,有时候是清洗防护让攻击变得毫无意义,偶尔则是因为对方买的是一份固定时长的攻击服务,订阅到期了。这时候最大的诱惑,是把一切都原样放着然后去睡觉——而这正是一条临时限速规则,九个月后变成一个被遗忘的、悄无声息地把整个国家都挡在 429 之外的永久规则的方式。趁记忆还新鲜,花二十分钟把这件事收个尾。
值得产出三样东西。一份基线:你平时每秒请求数是多少,平时每个客户端的速率是多少,平时高峰带宽是多少。没有这些数字,你在下一次事故中设的每一个限制都是猜的,而其中一半会猜错方向,伤到自己的客户。一份回滚清单:记录你改动过的一切,附上日期和原因,这样紧急配置就不会悄悄变成永久配置。一份运维手册:只有四条命令那么长,存放在网站挂掉时你依然能找到的地方——不是放在服务器上,也不能只放在你自己脑子里。
然后再补上两个结构性缺口,因为这决定了这一层在整个体系里所处的位置。清洗吸收的是流量规模,边缘节点挡住的是洪流对你数据的威胁,而它们身后的那台机器,仍然需要被妥善加固,并拥有可恢复的异地备份,因为下一次事故未必还是一场洪流攻击。一台扛住了 DDoS、却在一个月后丢了硬盘的服务器,从来都算不上有韧性——它只是走了两次运。
SP·09分步操作
-
01
先确认,再改动任何东西
在动配置文件之前,先如实看清这台机器现在的状态。你要找的是数据包或请求数上的阶跃式变化,以及耗时究竟花在哪里——一个被软中断抢走 CPU 的应用,和一个在等数据库的应用,是两种完全不同的事故。
# is the box alive, and where is the time going? uptime # load average against your core count vmstat 1 5 # 'in' and 'cs' high, 'id' near zero = packet work mpstat -P ALL 1 3 # %soft pinned on one core = interrupt saturation # is the pipe full, or just busy? ip -s link show eth0 # rx bytes, and the errors/dropped counters ethtool eth0 | grep -i speed # requests per second from your own log, minute by minute tail -n 20000 /var/log/nginx/access.log \ | awk -F'[][]' '{print $2}' | cut -d: -f2-4 | uniq -c | tail -5在断定这是一次攻击之前,先查查你自己的部署日志和 cron 任务表。在单台 VPS 上,自己捅出来的故障远比洪流攻击常见,而且从外部看起来一模一样。
-
02
六十秒内测出流量的形态
现在给它分类。三个问题:来源地址有多少个不同的、协议和状态是什么、如果是 HTTP,又是哪些路径和哪些 User-Agent。答案决定你接下来该用哪种手段,而收集这些答案大约只需要一分钟。
# top source addresses on the wire right now timeout 20 tcpdump -nn -i eth0 -c 20000 2>/dev/null \ | awk '{print $3}' | rev | cut -d. -f2- | rev \ | sort | uniq -c | sort -rn | head -20 # TCP state census: a wall of SYN-RECV is a SYN flood ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn # layer 7: talkers, paths, agents over the last 50k requests L=/var/log/nginx/access.log tail -n 50000 $L | awk '{print $1}' | sort | uniq -c | sort -rn | head -20 tail -n 50000 $L | awk '{print $7}' | sort | uniq -c | sort -rn | head -20 tail -n 50000 $L | cut -d'"' -f6 | sort | uniq -c | sort -rn | head -10对照这些破绽来解读结果:某一条路径上全是独一无二的查询字符串、User-Agent 列表没有长尾、完全没有对静态资源的请求,或者每个地址的速率均匀得反常。如果带宽在你根本没监听的端口上很高,那就不用往下看了——这是一次流量型洪流,只有第六步才管用。
-
03
提供廉价响应,并限制连接数
杠杆最大的先来。一个三十秒的微缓存,能把一大堆完全相同的匿名请求压缩成一次源站命中;严格的超时设置,能打死速率限制根本看不见的慢速攻击。把这两者都放进
http块里,然后用 reload 而不是 restart,这样才能保住你的热连接。# /etc/nginx/nginx.conf — http block limit_conn_zone $binary_remote_addr zone=perip:10m; limit_req_zone $binary_remote_addr zone=flood:20m rate=10r/s; client_header_timeout 10s; client_body_timeout 10s; send_timeout 10s; keepalive_timeout 20s; reset_timedout_connection on; proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=hot:64m max_size=2g inactive=10m use_temp_path=off;# the server block — cap concurrency, serve the cached copy limit_conn perip 20; location / { proxy_cache hot; proxy_cache_valid 200 301 302 30s; proxy_cache_lock on; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; add_header X-Cache $upstream_cache_status; proxy_pass http://127.0.0.1:8080; }nginx -t && systemctl reload nginx curl -sI https://example.com/ | grep -i x-cache # want: HIT on the second call
-
04
限速要限真实客户端,而不是你自己的代理
如果 nginx 前面还有别的东西,那么每个请求看起来都来自同一个地址,按地址限速限制的就会是代理本身而不是攻击者——甚至可能直接把代理封掉,在你还在防守的时候就把网站搞下线。只信任来自代理地址的转发头,绝不信任来自公网的,然后再应用限速。
# /etc/nginx/conf.d/realip.conf — the tunnel or edge address only set_real_ip_from 10.66.0.1; real_ip_header X-Forwarded-For; real_ip_recursive off;
# burst absorbs bursty humans; nodelay keeps the page fast for them location / { limit_req zone=flood burst=20 nodelay; limit_req_status 429; } # the expensive paths get a much tighter bucket of their own location ~ ^/(search|login|register|api/) { limit_req zone=flood burst=5; limit_req_status 429; }然后看看你刚刚做了什么:
tail -f /var/log/nginx/error.log | grep limiting。如果被限速的地址看起来像是你的客户,说明速率设得太低了——调高它。一个把真实用户挡在外面的限速规则,是你自己造成的一次停机。 -
05
封锁要窄,且每条都要设过期时间
只有在第二步显示来源确实集中时,才值得这么做。用一个 set,而不是一千条规则——
ipset查询是常数时间,而一条很长的iptables链,每个数据包都要从头走一遍,最后会变成它自己的一场拒绝服务。给每一条记录都设上超时,这样今天的应急措施才不会变成明年那份悄无声息的封锁名单。ipset create flood hash:net timeout 3600 -exist iptables -I INPUT -m set --match-set flood src -j DROP # feed it from the census: /24s you actually verified, not guesses for n in 203.0.113.0/24 198.51.100.0/24; do ipset add flood $n -exist; done # SYN flood: let the kernel do the part it is good at sysctl -w net.ipv4.tcp_syncookies=1 sysctl -w net.ipv4.tcp_max_syn_backlog=8192 sysctl -w net.core.somaxconn=8192 ipset list flood | head -20 # keep a copy of this for the post-mortem
除非你能说出被你切断的到底是哪些客户,否则不要整个国家一起地理封锁。也绝不要为了验证一个猜测就去
ufw disable:一台在遭受攻击的同时还失去防火墙保护的机器,正是一次带宽事故演变成一次入侵的方式。 -
06
升级到真正能扛住它的那一层
如果网卡计数器显示链路已经打满,或者丢包数在攀升而你的 CPU 却很空闲,你就已经到达了服务器上能做的一切的上限。先确认这个判断,然后往上报,而不是继续在这台机器上调参数。
# drops in the stack itself — second column is 'dropped' awk '{print strtonum("0x" $2)}' /proc/net/softnet_stat | paste -sd+ | bc # interface-level loss against link speed ip -s link show eth0 | sed -n '3,6p'有了上游清洗,通常就没什么可做的了:检测全天候在线,洪流在到达你的端口之前,就已经在网络里被丢弃——在我们的机群上,那是 1.5 Tbps 的容量,挂在每个套餐前面,所以这类事故事后往往只在一张图表上看得出来。如果攻击是一次构造精良的 HTTP 洪流,而不是流量型攻击,那就该用上 L7 防护了,因为在数据包层面,请求洪流和真实用户没有区别,只能在更上层去判断。如果你完全没有清洗防护,现实的选择就是搬到有清洗防护的边缘节点后面,或者等——而且要在下一次攻击到来之前,先把第一种选择安排好。
-
07
闭环收尾:验证、回滚、写下来
从这台机器外部去验证,而不是在它自己的 shell 里验证。然后有意识地撤销那些应急措施,保留那些本来就值得长期保留的部分,并把数字记录下来,好让下一次事故从一条基线出发,而不是从一次猜测出发。
# from somewhere else entirely: is the site healthy for a normal user? curl -s -o /dev/null -w 'code=%{http_code} ttfb=%{time_starttransfer}s\n' \ https://example.com/ # did you leave a limit that is biting real people? grep -c 'limiting requests' /var/log/nginx/error.log # what is still blocked, and when does it expire? ipset list flood | head -30保留缓存、超时设置和 syncookies——这些是永久性的改进。把激进的限速规则回滚到你实测出的基线再加一点余量,让 ipset 里的记录自然过期。然后把第一步和第二步里用到的那四条命令写进一份运维手册,存放在这台服务器之外的地方,旁边附上你平时的每秒请求数和平时的高峰带宽。仅仅因为这两个数字已经存在,下一次攻击就会是一场更短的事故。


