
在 DDoS 攻击下让 VPS 保持在线
三种相当不同的攻击共用 DDoS 这一个名字,你能读到的几乎每一条建议都会失效,因为它们把三者当成同一件事处理:拿一条 nginx 指令去回应一条被打满的上行链路,或者拿一台更大的服务器去应付一次精心设计的七层洪水攻击。真正有用的问题从来不是“怎么挡住它”,而是“这东西究竟在哪一层才能被物理拦截,而那一层归谁管”。这个页面要回答的,就是一台租来的 Linux 主机该怎么办——你上方的网络在你看到之前吸收了什么,在机器内部真正管用的是什么,以及为什么最有效的单一举措,往往是干脆让自己变得不可寻址。
这张网络上的流量型防护始终处于开启状态,没有什么需要你去手动启用:10 Gbps 以下的洪水会被静默吸收,10 到 100 Gbps 会在转接运营商那里被清洗,超过这个数字则会被牵引到专用清洗路径——这些阈值写在文档里,而不是留作一个用来做营销的形容词。这就是一个主机商能给你的、诚实的上限。而对于真正打垮小型服务器的那部分流量来说,这也是问题里没那么有意思的那一半。
因为真正能稳定打死一台 5 美元 VPS 的,从来不是那些上新闻头条的 340 Gbps 级怪兽流量。而是每秒四万个小 SYN 包,悄悄填满一张你从没看过的状态表;又或者是每秒三百次请求,专挑你网站上那个会跑数据库查询的 URL——这些流量抵达的是一个完全健康的网卡,走的是一条远没有跑满的链路,任何上游清洗设备都没法把它们和你的真实用户区分开。这部分要靠你自己处理,而处理它们只需要大约二十行配置——前提是你先搞清楚,眼前这次到底是三者中的哪一种。
接下来的内容默认你用的是 Debian 13 或 Ubuntu 24.04、nftables 和 nginx,并且假定你已经做完了第一小时加固——一台还在不该监听的端口上应答的服务器,还没资格谈防御。
三种攻击,同一个名字
“DDoS”描述的是一种意图,而不是一种机制,而这些机制彼此之间几乎没有共同点。把它们正确区分开,不是在咬文嚼字:这几乎就是这项工作的全部,因为每一种攻击都只能在恰好一个层面上被拦截,在其余层面上则完全隐形。
流量型攻击瞄准的是你的带宽。UDP 反射放大——DNS、NTP、memcached,以及最近任何一种用小请求换来大响应的协议——能让攻击者把自己手里 1 Gbps 的产能,放大成打向你的 50 Gbps。目标是链路,不是服务器。你的 CPU 全程都会很闲。
协议与状态攻击瞄准的是你内核里一张大小有限的表。SYN 洪水攻击试图耗尽 accept 队列;通用的小包洪水则试图耗尽连接跟踪表。两者都以每秒包数衡量,而不是每秒比特数,而且两者都能在一条百分之九十七空闲的链路上打死一台机器。这是打垮小型服务器的那一类,也是大多数指南会跳过的那一类。
应用层洪水攻击瞄准的是你的 CPU 或数据库,用的请求和真实请求毫无区别——因为它们本来就是真实请求。每秒一百次对搜索接口的请求,对网络来说不算什么,对一个 PHP 应用却是致命的。没有任何上游清洗设备能替你过滤这种流量:从外面看,它和一次成功访问一模一样。
还有第四种情况,会把自己伪装成以上三种中的任意一种,实际上根本不是攻击:某处线路状况不佳、你自己某个客户端行为异常,或者一只没礼貌的爬虫。先排除这种可能性不花一分钱,而它偏偏经常就是正确答案,这一点相当尴尬。
你在机器内部解决不了的那部分
如果有 60 Gbps 的流量瞄准你的地址,而你的端口只是个 1 Gbps 端口,那么该怎么处理这些包,是在你上游的一台路由器里就已经决定好的,早在到达你能管理的任何设备之前好几跳就已经发生。你的防火墙永远看不到它们。它也不可能看到:这些包是为了保护一条不属于你的链路才被丢弃的。这是关于流量型攻击最重要的一条结构性事实,也是“把防火墙加固到能防 DDoS”这种说法大多数时候站不住脚的原因。
所以真正有意义的问题,只有你的主机商自动做了什么,以及它们把阈值定在哪里。我们的阈值是公开写明的,而不是一句口头承诺:10 Gbps 以下会被吸收、不留下任何可见影响,10 到 100 Gbps 会在转接运营商那里被清洗,你可能会感觉到一点延迟上升,超过 100 Gbps 时,该网段会被牵引到专用清洗路径——延迟上升会更明显,但服务仍然可以访问。只有在攻击持续且威胁到更大范围 PoP 的情况下,才会考虑对某个地址执行黑洞路由,而一旦发生,我们会在几分钟内告诉你。没有什么需要购买,也没有什么需要手动启用。
关于流量清洗,有两件事供应商很少主动告诉你,却值得了解。第一,保护你的不只是你自己的防御,还有和你共用同一片上游网段的其他人:你上游 /20 网段里的另一个客户遭到攻击时,流量会在到达任何人的网段之前就被吸收——一次约 340 Gbps、瞄准巴黎某个相邻网段的洪水攻击,对我们这边没有造成任何可测量的影响,而这正是没人会注意到的那种结果。第二,清洗设备靠的是启发式判断,而启发式判断有时候会出错:在同一张网络上,曾经有一台清洗设备花了九分钟时间对合法连接发送 TCP reset,而当时它正在过滤一次针对邻居的攻击。这两起事件都记录在公开事故日志里。如果你的会话中断的方式看起来像是遭到了主动重置,而不是在别人挨打时出现的超时,那就是一种真实存在的故障模式,值得上报,而不是自己在本地排查一个小时。
先测量九十秒,再动手
每一次错误的 DDoS 应对,起点都是在没搞清楚发生了什么之前,就先动手改了什么。先拿到四个数字。它们一屏就能放下,而且会替你把攻击定位到具体哪一层。
# 1. bits vs packets — which axis is saturated?
sar -n DEV 1 5 # or: ifstat -i eth0 1
# 2. socket states — SYN-RECV piling up means a SYN flood
ss -s
ss -tan state syn-recv | wc -l
# 3. kernel drop counters (these are the ones that matter)
nstat -az | grep -E 'ListenDrops|ListenOverflows|SyncookiesSent|TCPReqQFullDrop'
dmesg -T | tail -20 # look for: nf_conntrack: table full, dropping packet
# 4. connection tracking headroom
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max把它们放在一起看。比特数高、包数低、CPU 低,是流量型攻击,你要做的是确认它,然后停止敲命令。包数高、比特数低——一大堆很小的包——是状态攻击;马上去看 SyncookiesSent 和 conntrack 计数。两条轴上的流量都不大、CPU 却被打满,是七层攻击,答案在你的 Web 服务器和数据库里,不在防火墙里。
有一个反直觉的解读值得记在心里:在一次真正的流量型洪水攻击期间,你的接口看起来可能几乎风平浪静。你看到的是幸存者——是那些勉强挤过一条已经跑满的链路、或者被上游清洗设备放行的流量。一个 1 Gbps 端口上显示着 900 Mbps、而用户却全都反馈完全无法访问的接口,并不能证明这不是一次攻击。恰恰相反,这正是一次攻击该有的样子。
然后确认这些流量不是单纯的真实流量。用 tail -f 盯着你的访问日志看十秒:如果是同一个路径被成千上万个互不相同的地址反复请求,而且都没有 referrer,那是攻击;如果是各种正常路径、来自各种正常浏览器的流量分布,那只是普通访客,对它做限流,等于替攻击者把活干了。
在攻击发生前缩小目标,而不是发生时才缩小
每一个开放的端口,都是一个攻击者能填满的队列,而且哪怕你把包丢掉,虚拟机每看一眼的包,也照样要花掉你的 CPU 和一条 conntrack 记录。最便宜的包,是你的机器压根没收到的那个包,这也是为什么在虚拟机之外过滤,比在虚拟机内部过滤更值钱。
这里有两层,而且是两码事。边缘过滤是可选的,按服务器单独配置,有状态、默认拒绝,运行在虚拟化层——你在那里拒绝掉的流量,根本到不了你的虚拟网卡,因此不花你一点 CPU、内存,也不占一条状态表记录。虚拟机防火墙——也就是你的 nftables 规则集——完全是你自己的地盘,我们从不插手。经得住攻击考验的做法,是把粗粒度、稳定的四层事实放在边缘(“这台机器只从这些地址对外提供 80、443 和 SSH,其余一概不存在”),把细粒度、随应用变化的规则留给虚拟机这一层。两者都写在防火墙文档里。
然后收紧剩下的部分。把 SSH 限制到你真正会用来管理的那些地址,在这里不是什么锦上添花的加固细节——它能把一整类连接耗尽攻击彻底从你的机器上排除出去。一个绑定在 127.0.0.1 或 WireGuard 地址上的数据库,根本没法从公网被打洪水攻击。如果你对外提供的是一个纯内部的控制面,就把它放到一个 WireGuard 接口后面,而不是开一个带密码的公网端口。
SYN 洪水攻击与 accept 队列
SYN 洪水攻击利用的是握手过程:攻击者发送一连串连接请求,却从不完成它们,每一个请求都会占用内核 SYN 队列里的一个位置,直到超时为止。把队列填满,合法的握手就会被丢弃——你的服务照常运行、照常监听,却没法访问。
Linux 从九十年代起就有一个干净的答案,而且默认开启。SYN cookies 让内核彻底不再为半开连接分配内存:它把连接状态编码进返回的序列号里,等客户端完成握手时再把状态还原出来。不要假设它是开着的,去确认它,并且给队列留足够的余量来扛过突发流量:
# /etc/sysctl.d/99-flood.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_abort_on_overflow = 0
sysctl --systemtcp_synack_retries = 2 比看起来更重要:默认值 5 意味着一次伪造的握手会占用内核大约三分钟,改成 2 之后,这个时间会降到大约七秒。调高 somaxconn 只完成了一半的工作——队列深度取的是这个值和应用程序自己要求的值二者之中较小的那个,所以 nginx 还需要 listen 443 ssl backlog=8192; 并重新加载,内核这边的设置才真正有意义。这是 sysctl 看起来已经生效、实际上却毫无作用的经典案例。
如果你想在流量到达应用程序之前就拦住这次洪水攻击,nftables 可以代替内核完成握手,只把确认是真实连接的流量交出去:
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
tcp dport { 80, 443 } ct state new synproxy mss 1460 wscale 7 timestamp sack-perm
tcp dport { 80, 443 } accept
ip protocol icmp icmp type echo-request limit rate 5/second accept
}
}在依赖 synproxy 之前,先在一台你仍能通过控制台访问的机器上测试它;一旦配置有误,它是把自己防火墙在自己服务器之外的绝佳方式。对大多数人来说,上面那段 sysctl 已经足够,诚实的优先级排序是:syncookies 第一,backlog 第二,只有在你已经验证前两者不够用之后,才轮到 synproxy。
没人会看一眼、直到它被填满的那张表
这一关,连有经验的人都会栽进去。有状态防火墙必须记住它见过的每一条流,而这份记忆就是 nf_conntrack——一张大小在启动时就已经固定下来的哈希表。一旦它被填满,内核就会丢弃新连接——不分青红皂白,攻击者和你的客户一视同仁——并且只在 dmesg 里留下一行没人会去看的日志:
nf_conntrack: table full, dropping packet它之所以这么有效,纯粹是算术问题。一台小型 VPS 上默认的表,大小在几万条这个量级,而每一个来自新源地址的包都会新建一条——包括 UDP 包,包括你丢弃的包,也包括洪水攻击本身。每秒两万个来自伪造源地址的包,能在一条只有几兆比特流量的链路上,用不到两秒钟就把表填满。你的带宽图上则什么异常都不会显示。
# /etc/sysctl.d/99-conntrack.conf
net.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_buckets = 65536
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 20
net.netfilter.nf_conntrack_udp_timeout = 20比起表的大小,超时时间的设置带来的好处更大。一条已建立 TCP 流的默认存活时间是五天,也就是说,一台已经运行了一周的服务器,还在为周二就已经结束的连接保留着状态;一小时对任何非空闲 SSH 会话来说都绰绰有余,而 net.ipv4.tcp_keepalive_time = 600 能让那些真正要保活的连接被正确地保活,而不是靠误打误撞活下来。定容量时,按每条大约 300 字节内核内存来估算:262,144 条大约是 80 MB,在一台 4 GB 内存的机器上没问题;如果只因为论坛上有人说,就把它设成一千万,那就有问题了。
如果你跑的是那种真正无状态、又高流量的服务——一台权威 DNS 服务器、一个公开的游戏服务器、一个 Tor 中继——更好的答案是干脆别去跟踪它。对一个根本没有实质连接可言的服务做连接跟踪,纯粹是白花成本:
table ip raw {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
udp dport 51820 notrack
tcp dport 9001 notrack
}
}记住,这样的流量会绕过你的 ct state established 规则,所以需要在 filter 链里为它单独写明确的放行规则。这就是这笔交易的本质:你在那个端口上放弃了有状态特性,换来一张永远填不满的表。
七层攻击:看起来和你的客户一模一样的洪水
对小型服务器来说,最有效率的攻击根本算不上什么洪水攻击。它只是每秒几百个精心挑选的 HTTP 请求,每一个都完全合法,每一个都精准打在那个会跑一次未加索引查询、或渲染一个未缓存页面的接口上。这笔经济账算下来很残酷:一个请求,攻击者只花几百字节,你却要搭进去 200 毫秒 CPU 和一个数据库连接。不管你能负担多大的服务规模,这场竞赛你都会输。
本能反应是去封攻击者。面对一个分散在成千上万个家庭宽带地址上的僵尸网络,每个地址每秒只发两个请求,按 IP 封锁纯属做样子——任何一个不会误伤真实用户的按地址限流阈值,都永远不会被触发,而你会在网站持续瘫痪的同时,把整段故障时间都花在加规则上。真正管用的做法,是改变成本,而不是数数量。
先做缓存,而且要把没命中也缓存住。整页缓存能把一个昂贵的请求变成一次内存读取,而在一次攻击中最关键的那条指令,是能阻止一千个并发的缓存未命中变成一千个并发数据库查询的那一条:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app:64m max_size=2g inactive=60m;
server {
location / {
proxy_cache app;
proxy_cache_valid 200 301 302 10m;
proxy_cache_lock on; # one origin request per key, not a thousand
proxy_cache_lock_timeout 5s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # serve stale, refresh behind it
add_header X-Cache $upstream_cache_status always;
proxy_pass http://127.0.0.1:8080;
}
}proxy_cache_lock 和 use_stale 这两行,决定了一次流量高峰是否扛得住。少了它们,一个热键一旦过期,每一个正在处理中的请求都会各自变成一次源站请求——这种踩踏效应,会把一次本可控制的攻击变成一次真正的宕机。有了它们,你的后端每个键每个周期只处理一次请求,其余所有人拿到的是一个略微过期的页面,而在唯一的替代选项是完全没有页面的每一种情况下,这都是正确的取舍。
然后专门给那些开销大的路径限流,尺度放宽松一些,而且要以一种失败得清清楚楚的方式限:
limit_req_zone $binary_remote_addr zone=general:16m rate=20r/s;
limit_req_zone $binary_remote_addr zone=costly:16m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=conn:16m;
limit_req_status 429;
limit_conn_status 429;
server {
limit_req zone=general burst=40 nodelay;
limit_conn conn 24;
location /search { limit_req zone=costly burst=5 nodelay; proxy_pass http://127.0.0.1:8080; }
location /login { limit_req zone=costly burst=3 nodelay; proxy_pass http://127.0.0.1:8080; }
}burst 如果不配合 nodelay,会把超出的请求排队,而不是直接拒绝,在遭受攻击时,这意味着 nginx 会很客气地替你把成千上万个连接开着——你等于把一次请求洪水变成了一次连接洪水。用上 nodelay,立刻返回 429,剩下的交给客户端自己处理。另外,只要处在任何代理之后,就要记住:除非你正确配置了 set_real_ip_from 和 real_ip_header,否则 $binary_remote_addr 拿到的其实是代理自己的地址;一个以你自家前端地址为键的限流规则,一旦触发,会把所有用户同时锁在外面。
Slowloris 式攻击——大量连接,每个连接每次只挤出一个字节的请求——基本困扰不到 nginx 的事件模型,但把超时收紧一些仍然值得:client_header_timeout 8s; client_body_timeout 8s; send_timeout 10s; reset_timedout_connection on;。把它和 limit_conn 组合起来,防御就完整了。
为什么 fail2ban 挡不住 DDoS
fail2ban 确实有用,只是它瞄准的是另一个问题。它读日志文件,判断在 M 分钟内失败了 N 次,然后插入一条防火墙规则。这整套流程,形状上就和洪水攻击对不上。
它太慢:一次持续四十秒的突发流量,在封禁窗口关闭之前就已经结束了。它靠读日志工作,所以攻击早就已经让你为每一个请求都付出了全价——解析发生在损害已经造成之后。它一次封一个地址,所以面对一万个来源,它要么什么都做不了,要么插入一万条线性规则,到那时防火墙本身就成了瓶颈,等于替攻击者把这次攻击完成了。而且它靠你的日志驱动,所以一次大到能把磁盘塞满日志行的洪水攻击,完全可能通过一条你从没想到过的路径,把服务器拖垮。
如果你想要动态封锁,就应该在数据平面里做——那里的查找是哈希操作,过期是自动的。一个 nftables 的动态集合,不管大小如何都是 O(1),而且会自己遗忘:
table inet filter {
set flooders {
type ipv4_addr
flags dynamic, timeout
timeout 10m
size 65535
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
ip saddr @flooders drop
tcp dport { 80, 443 } ct state new add @flooders { ip saddr limit rate over 50/second burst 100 packets } drop
tcp dport { 80, 443 } accept
}
}把这条规则仔细读一遍,因为它的否定逻辑很容易让人踩坑:只有当速率超过限制时,地址才会被加入集合并被丢弃。把 fail2ban 留给它真正擅长的场景——针对 SSH 和应用登录的缓慢猜密码攻击,在这种场景下,以人类时间尺度对特定地址下达封禁,才是恰到好处的做法。
别做别人的武器:你正在托管的那份放大能力
每一次大规模流量型攻击背后,都有一批运营者根本不知道自己参与其中的服务器在提供动力。它的机制是一个 UDP 服务,用一个伪造的小请求换来一个大回复:一个开放 DNS 解析器返回的数据量是请求的 50 倍,配置不当的 NTP 和 memcached 更夸张,任何一个在验证清楚是谁在问之前就先应答的协议,都是候选对象。
后果会先落到你头上,才轮到受害者。你的上行链路会被自己发出去的回复塞满,你的主机商会看到持续不断的滥用流量从你的端口流出,接收这些流量的转接运营商则会开始对来源网段做黑洞路由——这也是为什么可接受使用政策会在发起攻击和放大攻击这两件事上划一条硬线,而在几乎其他任何事情上都保持宽松。在不知情的情况下沦为反射器,是失去一个原本和你想做的事毫无关系的地址的最快方式。
# bind a recursive resolver to localhost, never 0.0.0.0
# unbound: interface: 127.0.0.1 + access-control: 0.0.0.0/0 refuse
# what is actually listening on a public address?
ss -ulpn
ss -tlpn
# from another machine, confirm you do not answer strangers
dig @your.server.ip example.com +short # must time out
ntpq -c rv your.server.ip # must fail如果你是故意在对外提供一个公开的 UDP 服务——一个游戏服务器、一个权威域名服务器、一个 WireGuard 端点——规则和别处一样:只限制响应速率,永远别限制请求速率。bind 和 knot 都实现了响应速率限制;用上它,因为这就是服务好你的用户和替别人运送一次攻击之间的区别。
最有效的防御,是让人找不到你
以上这一切,都是在为一次已经找到你地址的攻击做损害控制。这些都值得做,但也只能算次优解。一个没人能叫得出名字的源站,在三层或四层根本没法被攻击,而把对外提供流量的地址,和外界所知道的地址区分开来,是这个页面上杠杆率最高的一件事。
源站地址几乎从来不是靠精妙的攻击手段泄露出去的。它们是通过历史记录和粗心大意泄露的,而且就那么几种,早已是公开的秘密。过时的 DNS 记录是最常见的元凶:被动 DNS 档案会永远记得你上前端之前用过的那条 A 记录。证书透明度日志是公开且永久的,所以一张为某个直接指向源站的主机名签发的证书,就是一份带签名、带时间戳的记录,记着你以前住在哪儿。外发邮件会把发件服务器的地址烙进每一封邮件的邮件头里。还有一个不声不响的:你的 Web 服务器在裸 IP 上直接应答,这会让任何一个为了找你网站的 HTML 而扫描整个地址空间的人,一个下午就靠内容把你找出来。
最后这一条,只要两行配置就能修好,却几乎没人去做。让默认 server 拒绝一切没有携带你所提供的主机名到来的请求:
server {
listen 80 default_server;
listen 443 ssl default_server;
ssl_reject_handshake on; # nginx 1.19.4+: no certificate, no fingerprint
return 444; # close without a response
}然后选一个前端。商业 CDN 是显而易见的答案,但它带着一项在这里确实很关键的真实代价:你等于引入了一家公司,它终结你的 TLS、看得到你的明文流量、知道你的源站在哪,而且可能在一个你并未选择的司法辖区被送达法律文件——这在很大程度上抵消了你当初特意用加密货币匿名付款的意义。如果你仍然决定这么做,要清楚源站必须从来没有公开过,而且一旦有人翻出一条旧记录,整套安排就会失败。
能保住你原本花钱买到的那份属性的替代方案,是自己当自己的前端。在第二个地区开一台 5 美元的实例,跑 nginx 做反向代理,让源站的边缘过滤只接受来自这一个地址的流量、不接受别的,这样你就得到了一个可以在五分钟内更换的弃子地址,以及一个只对你自己负责的控制面。架构完全一样,只是少了第三方。而在受众能够使用的地方,一个 Tor 洋葱服务能把 IP 彻底排除在整个等式之外——根本没有地址可以被打洪水攻击,尽管洋葱服务在介绍点上有自己的攻击面,这也是现代 Tor 会内置工作量证明防御、专门应对这一点的原因。
封锁国家、ASN,以及流量的形状
迟早会有人建议直接封锁一个国家。这个办法很粗暴,偶尔碰巧是对的,但大多数时候只是一种让自己显得在忙的方式。
当你的服务受众确实是有边界的——一个区域性游戏服务器、一个内部工具、一个管理面板——而且你也愿意承担误伤的后果,包括正在出差的自家用户,以及路由走得比较古怪的人,这么做是站得住脚的。对一个现代僵尸网络,它基本没用,因为僵尸网络分散在包括你自己国家在内的各国家庭宽带连接上;而对任何面向公众的服务,它则是实打实的有害:你会悄无声息地失去真实用户,而且永远看不到那些离开的人。
按 ASN 封锁更精准一些。发起于少数几家托管商——那种按小时出租的廉价 VPS 网段——的攻击流量,可以按前缀丢弃,误伤会比封国家少得多,因为普通用户不会从一个数据中心里上网。而且数据中心网段恰好也是爬虫和撞库流量的常见来源。
但这个思路真正经得住时间考验的版本,根本无关地理位置:它是按流量的形状过滤,而不是按来源。一次攻击通常在结构上有共同点——同一个 user agent、同一个缺失的请求头、同一个 URL 带着同一个查询参数、同一个 TLS 指纹,或者彻底缺失真实浏览器总会发出的第二个请求。找到这个共同特征,你就能写出一条几乎不花成本、而且在攻击者换地址之后依然管用的规则——他们换地址的速度,会比你列出这些地址的速度快得多。
# in an attack: what do these requests have in common?
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20 # addresses
awk -F'"' '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20 # user agents
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20 # paths如果单一一个 user agent 占了九成请求,你离修好这个问题只有九十秒。如果排名前二十的地址每个只占流量的百分之二,那就别再找地址了——这是一次分布式攻击,答案是缓存和限流。
当这个地址已经保不住了
有时候攻击是精准针对的、持续不断的,而且是冲着你个人来的,而不是随便找了个地址下手,这时候正确的应对方式,是不再守着这个地址,直接放弃它。这不是失败;对一个小型服务来说,这往往是代价最小的结局,而且只有在你没演练过的情况下,它才会显得痛苦。
能不能做得快,取决于你事先做了哪些准备。把 DNS TTL 长期保持在 300 秒——代价可以忽略不计,好处是你能在五分钟内搬家,而不是一整天。保持部署可复现,因为一台能重建的服务器,才是一台能搬走的服务器;如果重建意味着要回忆三月份你到底做了什么,那你手上的就不是一份方案,而是一份人质。在另一个地区保留一份可还原的备份,并且通过一次真实演练,弄清楚恢复到底要花多久。
到那时,真正要做的动作很简短:在另一个地区部署、还原、在改 DNS 之前先用主机名覆盖的方式在新地址上验证、切换记录,再让旧服务器多跑一段时间,等最后一批解析器缓存过期。迁移指南把这套顺序讲得很清楚,不管你是因为追求性能而搬家,还是因为有人冲你发火而搬家,用的都是同一套顺序。
计费模式让这种演练几乎不花钱:费用是按天从你的余额里按比例扣除的,所以一台你在一个下午之内部署、测试、再销毁的第二台机器,只要几美分,而且没有合同、没有信用卡,也没有需要取消的续费。你完全没有理由让自己的第一次改地址,发生在真正挨打的时候。
这一切买不到什么
最后老老实实说两点,因为这个页面从结构上就写得比较乐观。
第一,没有任何主机商能吸收一切,任何这么宣称的服务商都是在做营销。容量是有限的,清洗容量更是有限,而在足够大的规模下,对于一张要保护成千上万个客户的网络来说,经济上理性的做法就是停止广播某一个地址。我们把这当成对付持续威胁到更大范围 PoP 的攻击的最后手段,而且会主动告诉你,而不是让你自己去排查——但如果谁承诺这种事永远不会发生,那就是在撒谎,遇到这么承诺的主机商,你应该打个折扣去看待。
第二,报复不在选项之内。Booter、stresser 这类服务,本质上是把有偿攻击包装成压测工具对外出售,它们在大多数说了算的司法辖区都是违法的,被广泛监控,而且会在这里——一个几乎放行其他所有事情的政策之下——让你的服务器被终止。让 DDoS 对攻击者有吸引力的那种不对称性,恰恰让它对防御者毫无价值:你没法在洪水量上赢过一个没什么可失去的人。吸收、缓存、搬家,然后让这件事变得无聊——对任何要为自己发出的流量买单的人来说,它最终都会变得无聊。
- 九十秒内判断出是哪一层
在做任何配置改动之前,先拿到两条轴和两个计数器。比特数高、包数低,是流量型;包数高、比特数低,是状态攻击;两条轴都不大、CPU 却被打满,是七层攻击。
sar -n DEV 1 5 # bits/s and packets/s per interface ss -s # socket summary; watch synrecv nstat -az | grep -E 'ListenDrops|ListenOverflows|SyncookiesSent' cat /proc/sys/net/netfilter/nf_conntrack_count dmesg -T | grep -i conntrack | tail然后排除两种可能——流量其实是正常访问,或者问题出在自己身上:把访问日志扫十秒钟。如果这些路径看起来就是一个网站正在被正常使用,那你面对的是真实访客或者一个 bug,不是攻击者。
- 让内核能廉价地扛住攻击
两个文件,一次 reload。这些改动在任何通用服务器上都是安全的,而且能排除小型机器最常见的两种倒下方式。
cat > /etc/sysctl.d/99-flood.conf <<'EOF' net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 8192 net.core.somaxconn = 8192 net.ipv4.tcp_synack_retries = 2 net.ipv4.tcp_keepalive_time = 600 net.netfilter.nf_conntrack_max = 262144 net.netfilter.nf_conntrack_buckets = 65536 net.netfilter.nf_conntrack_tcp_timeout_established = 3600 net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 20 EOF sysctl --system sysctl net.ipv4.tcp_syncookies net.netfilter.nf_conntrack_max # verify, do not assume然后让应用程序跟上:nginx 需要
listen 443 ssl backlog=8192;,否则你刚刚调大的内核队列,还是会被进程自己要求的那个更小的数字卡住。 - 把不对外服务的端口,在虚拟机外层就全部关闭
列出机器上实际在监听的东西,判断哪些真的该出现在公网地址上,再把粗粒度的规则推到虚拟化层的边缘过滤上,让这些流量根本到不了你的虚拟网卡。
ss -tlpn; ss -ulpn # every public listener, with the process that owns it在Server → Network → Firewall里,把边缘过滤设成默认拒绝,只放行你实际提供的端口,并把 SSH 限制到你用来管理的那些地址。把虚拟机里的
nftables规则集保留为第二层——这是双重保险,不是二选一——数据库和管理界面则绑定到127.0.0.1或 WireGuard 地址,而不是靠防火墙去挡一个公网端口。 - 在昂贵路径前面加一层缓存
对任何会渲染页面的场景来说,这是单项价值最高的改动。加上
proxy_cache,配合proxy_cache_lock on和proxy_cache_use_stale,这样一千个并发的未命中,就会变成一次源站请求,其余所有人拿到的是一个稍微有点旧的页面。nginx -t && systemctl reload nginx curl -sI https://example.com/ | grep -i x-cache # MISS, then HIT先验证命中率,再相信它。一个因为每次响应都带
Set-Cookie而永远命中不了的缓存,是这整套操作里最常见的一种虚假安全感——用curl -sI连续请求两次,看一下X-Cache的值来检查。 - 限流限的是让你破费的路径,而不是访客本身
给一个宽松的全局限制,再给会碰数据库的接口一个严格的限制。始终带
nodelay,始终返回一个状态码,绝不做静默排队。limit_req_zone $binary_remote_addr zone=general:16m rate=20r/s; limit_req_zone $binary_remote_addr zone=costly:16m rate=2r/s; limit_req_status 429;如果 nginx 处在任何代理之后,先配置好
set_real_ip_from和real_ip_header。一个以你前端自己地址为键的限流规则,限制不到攻击者——它会在第一次触发时,把所有人同时限制住。 - 把源站地址从公众视野里拿掉
先让裸 IP 变得毫无用处,再决定用什么给它当前端。这个拒绝逻辑只要两行,而且能一劳永逸地堵上扫描整个互联网找你网站 HTML 这条路。
server { listen 80 default_server; listen 443 ssl default_server; ssl_reject_handshake on; return 444; }然后按通常的发生顺序,逐一排查泄露点:被动 DNS 档案里的历史 A 记录、证书透明度日志里曾经解析到源站的主机名,以及外发邮件的邮件头。只要其中任何一处还留着这个地址,前端就救不了你——先改地址,再上前端。
- 汇报给网络运营方时,带上具体数字
流量型防护是自动的,不需要开工单,但一份带证据的报告,能让人工核实自动化系统到底做了什么,并抓住它看不到的那些故障模式——比如清洗设备把合法会话给重置掉了。
把目的地址、UTC 起始时间、协议和目的端口、你测到的两条轴上的速率,以及你自己的计数器显示的是包正常抵达还是在上游消失,都发过去。五十行
tcpdump -ni eth0 -c 200的输出,比一整段文字描述更有价值。“网站有点慢”不具备可操作性;“185.x.x.x,14:02 UTC,UDP 打向 443,约 1.2 Mpps,接口显示到达 40 Kpps”才是。 - 趁一切正常时,先演练一次改地址
今天就把 DNS TTL 设成 300 秒,然后一直保持这样。接着完整跑一遍演练,从头到尾,在一台你一小时后就会销毁的机器上做。
# deploy a second instance in another region, restore, verify by hostname override curl --resolve example.com:443:<new-ip> https://example.com/ -sI把这次演练花掉的分钟数记下来。这个数字才是你真正的 DDoS 方案——比这篇指南里任何一条规则都更是——因为它说明了一次精准打击能让你下线多久。费用是按天从余额里按比例扣的,所以整场演练只要几美分。
每一种攻击,究竟能在哪里被拦下
| 防护层 | 能挡住什么 | 挡不住什么 | 成本 | 由谁掌控 |
|---|---|---|---|---|
| 上游流量清洗 | 流量型攻击——10 Gbps 以下静默吸收,10 到 100 Gbps 之间清洗,超过 100 Gbps 则 BGP 引流 | 任何淹没在正常流量体量之内的攻击:状态攻击、七层攻击 | 不花你一分钱。始终开启,无需工单,引流期间偶尔会有延迟 | 我们,自动完成 |
| 边缘过滤(虚拟化层) | 所有打向未开放端口的流量,在到达你的虚拟网卡之前就被拦下——不耗 CPU,不占状态表 | 针对你必须开放的端口发起的攻击 | 除了你自己写的规则之外不花成本;有状态,默认拒绝 | 你,按服务器逐台配置 |
| 虚拟机防火墙(nftables) | 用 synproxy 挡 SYN 洪水攻击、按来源限制包速率、拦截不需要的协议 | 已经把你上游链路堵满的流量——它压根到不了这里 | 每个包都要耗一份 CPU 和一条 conntrack 记录,哪怕是被丢弃的包也不例外 | 完全是你自己的事 |
| 内核调优(sysctl) | accept 队列和 conntrack 耗尽——小型服务器最经典的死法 | 任何一个合法、已经建立完成的连接 | conntrack 设到合理大小时,大约耗 80 MB 内存。两个配置文件 | 完全是你自己的事 |
| 应用层(缓存 + 限流) | 七层洪水攻击、请求踩踏、开销大的接口 | 包级别的攻击——它们根本到不了 Web 服务器这一层 | 页面会稍微过期,还会有一些被你误判的用户收到 429 | 完全是你自己的事 |
| 反向代理前端 | 对源站的直接攻击——因为根本没有公开地址可以瞄准 | 对前端本身的攻击,以及源站 IP 一旦泄露之后的任何攻击 | 每月 5 美元,外加多一台要持续打补丁的机器 | 你自己运营的话,就是你 |
| 商业 CDN | 大规模的三/四层与七层攻击,有规模优势,还有支持团队兜底 | 一个曾经公开过的源站,或者一个会在裸 IP 上直接应答的源站 | TLS 由一个了解你身份、还可能被送达法律文件的第三方来终结 | 他们 |
| Tor 洋葱服务 | 所有基于 IP 的攻击——因为协议里根本没有地址这回事 | 对介绍点的洪水攻击——这正是现代 Tor 会内置工作量证明防御的原因 | 延迟,以及愿意用 Tor 的那部分受众 | 你和整个 Tor 网络 |
常见问题解答
我需要在服务器上手动开启 DDoS 防护吗?
不需要。流量型防护部署在 PoP 上游,始终处于开启状态——没有产品需要购买,也没有开关需要拨动。10 Gbps 以下会被吸收、不留下任何可见影响;10 到 100 Gbps 会在转接运营商那里被清洗,你可能会看到短暂的延迟上升;超过 100 Gbps,该网段会被牵引到专用清洗路径,延迟上升会更明显,但服务仍然可以访问。这些阈值公布在文档里。不自动的,是四层以上的一切:状态表耗尽和应用层洪水攻击,从上游看起来就是普通流量,必须在你自己的机器上处理。
如果我遭到攻击,你们会把我的 IP 拉黑洞路由吗?
只会作为最后手段,而且只针对那种持续威胁到更大范围 PoP、而不只是你一台服务器的攻击——一旦发生,我们会在几分钟内告诉你,而不是让你自己去发现。任何承诺这种事绝不会发生的主机商,说的是营销话术,而不是一张真实的网络:在足够大的规模下,保护成千上万个客户,就意味着终究要放弃某一个地址。现实中真正的保护,是让自己变成一个没有吸引力的目标——不公开源站地址、准备好一份演练过的改地址方案,并且清楚自己的恢复用时。
我的网站打不开,但带宽图看起来很正常,这是怎么回事?
几乎可以肯定是状态耗尽——最经典的情形就是 nf_conntrack 表被填满。每秒两万个来自伪造源地址的小包,只用几兆比特的流量,就能在几秒钟内填满一张默认大小的表,所以图上什么都看不出来,而每一个新连接都会被丢弃。用 dmesg -T | grep conntrack 查一下有没有“table full, dropping packet”,再对比 nf_conntrack_count 和 nf_conntrack_max。另一种可能是完全相反的解读:你上方的链路已经满了,你的接口显示给你看的是幸存者,而不是攻击本身。
换一个更大的套餐,能扛住小套餐扛不住的攻击吗?
有时候可以,但原因和大多数人想的不一样。更多的 vCPU 和内存,对七层洪水攻击和状态耗尽确实有帮助,因为这两类攻击消耗的正是 CPU、内存和表空间。面对流量型攻击,套餐本身几乎无关紧要——不管你端口后面接的是什么,包都会在到达端口之前就被上游丢弃,一个 2.5 Gbps 的端口,在 40 Gbps 面前根本救不了你。升级之前,先把缓存和 conntrack 的大小设置好;这样做更便宜,而且到头来通常会发现,那才是真正的问题所在。
可以在免 KYC 的 VPS 前面接 Cloudflare 或其他 CDN 吗?
技术上可以,而且效果不错。但在这么做之前,要弄清楚这笔交易的代价:这家 CDN 会终结你的 TLS、看得到你的明文流量、知道你的源站地址,还持有一个用邮箱、往往还有支付方式标识你身份的账户,并且可能在一个你没有选择的司法辖区被送达法律文件。对一台不挂名买来的服务器来说,这恰恰重新引入了当初这套安排想要避开的那一方。如果你的威胁模型是停机,而不是身份暴露,这是一个合理的选择——但前提是源站必须从来没有公开过,否则一条旧的 DNS 记录就能让整套安排失效。如果你的威胁模型还包括谁知道你在哪儿,那就改用自己在第二台实例上跑的反向代理,让源站只接受来自这台实例的流量。
fail2ban 能挡住 DDoS 吗?
不能,而且这不是一个偏弱的工具,是一个用错了的工具。它在解析完日志之后,才以人类时间尺度做出反应,所以这些请求早就已经让你付出了它们能造成的全部代价;它一次只封一个地址,面对一万个来源等于什么都没做,而如果放任它去尝试,还会把你的防火墙变成一次线性扫描;而且它依赖的日志,本身就可能被攻击者用洪水淹没。把它用在它真正擅长的地方——针对 SSH 和登录表单的缓慢猜密码攻击——至于任何以洪水速度发生的事,改用一个带超时的 nftables 动态集合。
要不要直接封锁整个国家或 ASN?
只有在你的受众确实有明确边界、并且你愿意接受误伤出差用户和路由古怪的用户时,封锁国家才站得住脚。对一个现代僵尸网络来说,这么做基本没用,因为它分散在包括你自己国家在内的世界各地家庭宽带连接上。封锁数据中心 ASN 更精准一些——普通人不会从一个托管商网段里上网——还能顺带拦下爬虫和撞库流量。更好的直觉,是按流量的形状过滤:一个共同的 user agent、一个缺失的请求头、一个反复出现的路径。这条规则在攻击者换地址之后依然管用,而他们换地址的速度,用不了几分钟。
运行 Tor 洋葱服务,能让我彻底免疫吗?
对基于 IP 的攻击,确实免疫——协议里根本没有地址可以让洪水瞄准,这是一种真正意义上不同的安全姿态,而不只是渐进式的改善。但它不是全方位免疫:洋葱服务可以在介绍点上被攻击,这也是现代 Tor 会内置工作量证明防御、让客户端发起洪水攻击变得昂贵的原因。它同时也要付出延迟的代价,并把你限制在愿意用 Tor 的那部分受众里。很多人两者都跑——用明网前端保证触达范围,再留一个洋葱地址,在明网那边挨打时依然能用。