为什么个人站也需要 CDN 限速
前面几批里写过 CDN 免费防火墙挡 CC 攻击,也写过 Nginx 的 limit_req / limit_conn 怎么配。但实际跑久了会发现一个问题:源站限速是最后一道闸,等到请求打到 Nginx 才被拦,带宽、连接数、甚至 TLS 握手都已经消耗掉了。个人站通常只有 1~2 核、几 MB 带宽,这层消耗足以让正常用户在高峰期打不开页面。
这篇文章讲的是把限速往前挪一层——放在 CDN 边缘。思路和 Nginx 的限速不完全一样:CDN 边缘是分布式的,同一个 IP 的请求可能落到不同边缘节点,所以「全局限速」在边缘往往不准,需要配合边缘的计数器或规则匹配。不同厂商的实现差异很大,我这里以通用可迁移的思路为主,具体字段名请对照自己 CDN 的文档。
先分清三种流量,再谈限速
不加区分地限速,最常见的后果是误伤。我的经验是先把流量分成三类,各自用不同手段:
- 正常用户:一个 IP 几十秒内几次到十几次请求,带完整 UA、带 Referer、带 Cookie(回访用户)。这类要放行,不能因为「同一个 IP 请求多」就拦。
- 搜索引擎与正经爬虫:Googlebot、Bingbot、百度蜘蛛。它们可能一个 IP 短时间内抓几十个页面,属于正常行为,误伤会直接影响收录。必须用反查验证(后文详述)。
- 扫描器与采集器:无 UA、UA 写死成 python-requests / curl、请求密集、专挑 /wp-login.php、/.env、/admin 之类的路径,或者整站按顺序爬。这才是限速的主要目标。
这三类的区分不能只看 QPS。我见过一个采集器故意把速率压到每秒 0.5 次请求,但连续跑 12 小时,总请求量比任何一个正常用户都大得多。所以除了「瞬时速率」,还要看「单位时间总量」和「路径分布」。
CDN 边缘限速能做的四件事
一、按 IP + 路径维度做令牌桶限速
边缘节点能拿到客户端的真实 IP(前提是回源时正确传递,见下),就能对「某个 IP 访问某个路径前缀」做令牌桶。配置上通常有三个参数:速率(每秒多少请求)、桶容量(允许多少突发)、以及触发后的动作。
关键在于桶容量不要设成 1。真实用户的页面加载会并发发起 HTML、CSS、JS、图片十几个请求,桶容量太小会把正常访问打成 429。我的经验值是速率 5~10 请求/秒、桶容量 20~30,触发阈值设在「明显高于正常用户、明显低于采集器」的区间。
二、给敏感路径单独加一条严格规则
整站统一限速太粗,针对性更强的做法是给少数路径单独配规则:
/wp-login.php、/xmlrpc.php、/admin之类:速率 1~2 请求/分钟,超了直接拒绝,因为这些路径正常用户一天也用不了几次。/search、/?s=:搜索页是最容易被爬的入口,每次请求都要查数据库全文匹配,成本高。这类路径限速要严,或者干脆在 robots.txt 里 Disallow(但注意 Disallow 只约束守规矩的爬虫,采集器不看)。- 带
.env、.git、.sql、.bak后缀的:直接拦,正常的个人站不会有用户访问这些。
三、按 UA 特征做规则匹配
这一层是「先手筛」:UA 为空、或者包含 python-requests、Go-http-client、curl/、Scrapy 这类库标识的请求,直接在边缘返回 403,连源站都不用到。规则写法通常是前缀/包含匹配,注意按大小写不敏感处理。
这一层有一个必须留的口子:你自己写的监控脚本。如果你有站外哨兵定时 curl 检查页面(这类脚本在前面批次里写过),要给它单独加一个白名单,或者换一个自定义 UA 并在规则里放行,否则监控会因为被自己拦掉而误报「站点宕机」。
四、用边缘缓存挡住重复请求
限速是「拦」,缓存是「挡」,后者更划算。对于不区分用户的页面(首页、分类页、文章页),在边缘缓存 TTL 内,同一个 URL 被请求一万次也只回源一次。这样即使有爬虫在刷,消耗的也是边缘带宽而不是源站 CPU。
要注意的是缓存键(Cache Key)。如果 CDN 把完整的 query string 都算进缓存键,采集器只要在 URL 后加一个随机参数就能绕过缓存,每次都回源。处理办法有两种:一是把无意义的追踪参数(utm_*、fbclid、gclid、以及纯随机的 _=时间戳)从缓存键里排除;二是对未知参数过多的 URL 直接返回 403 或重定向到干净的 URL。这一点在之前讲缓存键治理时提过,在防刷场景里同样适用。
回源保护:边缘的限速为什么会失效
配好了边缘规则,源站 CPU 还是被打满,最常见的原因是回源请求没带对信息。具体有三类:
真实 IP 丢失
如果 CDN 回源时用的是自己的节点 IP,源站 Nginx 的 $remote_addr 全是 CDN 的地址,源站的 limit_req 就变成了「对 CDN 节点限速」,一个节点代理成千上万个用户,一限就是误伤一大片。必须在 CDN 侧开启真实 IP 传递(一般是 X-Forwarded-For 或专门的头部),源站侧配 real_ip_header + set_real_ip_from 只信任 CDN 的网段,并用 real_ip_recursive on 取最右边可信的那一段。
这里有个经典配置错误:set_real_ip_from 写成 0.0.0.0/0。这样任何人都能伪造 X-Forwarded-For,直接绕过所有基于 IP 的限速和封禁。一定要只填 CDN 官方公布的网段,并且定期更新。
边缘缓存未命中率太高
边缘规则拦的是「请求」,但如果每次请求都是未命中、都要回源,那边缘限速的桶被消耗完了就放行回源,源站照样被打。检查方式是看 CDN 控制台的缓存命中率曲线:正常内容站应该 85% 以上,低于 60% 说明有大量 URL 不缓存(可能是 Set-Cookie 导致的,也可能是 TTL 设得太短,或者上面说的缓存键被参数切碎)。
另一个细节:Set-Cookie 会导致大部分 CDN 拒绝缓存该响应。如果源站在所有页面都下发 Cookie(比如 PHP session_start() 无条件调用),那整站就都是不可缓存的。修法是只在真正需要登录态的路径上开 session,或者在 CDN 侧配置忽略特定的 Cookie。
回源 QPS 没有独立配额
即使边缘限速配好了,突发流量(比如某个节点被打、或者规则漏了某个 UA)还是会把源站打满。兜底手段是给源站入口再加一层连接限制:limit_conn 限制单 IP 并发连接数,limit_req 限制单 IP 请求速率,并配合 limit_conn_status 429 返回明确的状态码。这层的作用不是「拦住攻击」,而是「保证源站不被拖死」,宁可 1% 的用户看到 429,也不要 100% 的用户看到 502。
429 之后怎么办:别让用户看到空白页
限速触发后返回什么,直接决定了体验和 SEO 影响。我的建议是三层设计:
- 返回 429 而不是 403 或 444。403 会被当成永久禁止,爬虫可能不再来;444 是 Nginx 特有的「直接断连」,适合对恶意 IP 用,但对可能是误伤的正常用户很不友好。429 语义明确,附带
Retry-After头,告诉客户端多久后再试。 - 429 页面要有人看的说明。自己写一个静态的
429.html,写清楚「访问过于频繁,请稍后重试」,给个返回首页的按钮。不要用默认的 Nginx 错误页,那会让人以为站点坏了。 - 429 页面必须加
noindex。error_page 429 /429.html如果不加internal标记,这个页面可能被搜索引擎单独抓取收录,形成一个看起来像「站点已限制访问」的索引条目。
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=connip:10m;
server {
location / {
limit_req zone=perip burst=30 nodelay;
limit_req_status 429;
limit_conn connip 20;
limit_conn_status 429;
}
error_page 429 /429.html;
location = /429.html {
internal;
add_header Retry-After 10 always;
add_header X-Robots-Tag "noindex" always;
}
}注意 limit_req_status 和 limit_conn_status 都要显式设置,默认是 503。503 会被搜索引擎当成服务不可用,持续的 503 可能导致排名下滑;429 则明确表示「限流」,对 SEO 影响可控。
怎么验证规则有没有误伤正常流量
限速上线后最怕的是「拦了很久才发现把 Googlebot 拦了」。建议上线后立刻做三件事:
一、看错误日志里的 429 分布
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
grep ' 429 ' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20第一个命令看总请求的 IP 分布,第二个看被限速的 IP 分布。如果被限速的 IP 里出现了某个访客(通过其他日志或统计工具能对上的),那就是误伤,需要放宽阈值或者给它的行为特征加白名单。
二、验证爬虫真伪
UA 里写 Googlebot 不等于真的是 Googlebot,反过来也不能因为 UA 不对就拦。标准做法是反查:把 IP 做反向 DNS,得到的域名再正向解析一次,确认解析回同一个 IP,并且域名在 googlebot.com 或 google.com 下。这一步用 Python 写十几行就够:
import socket, re, sys
def verify(ip, suffix):
try:
host = socket.gethostbyaddr(ip)[0]
except Exception:
return False, 'no-ptr'
if not host.endswith(suffix):
return False, 'ptr-not-in-' + suffix
try:
back = socket.gethostbyname(host)
except Exception:
return False, 'fwd-fail'
return (back == ip), host + ' -> ' + back
if __name__ == '__main__':
print(verify(sys.argv[1], sys.argv[2]))验证通过的 IP 可以提高限速阈值(甚至豁免),验证不通过的按普通流量限速。注意这个过程有开销,不要在 Nginx 请求路径里实时做,而是离线跑一遍、把结果写成 IP 白名单文件用 geo 模块加载,定期更新。
三、看核心网页指标有没有恶化
限速如果误伤了真实用户,最先变化的不是服务器负载,而是 Search Console 里的抓取统计和 Core Web Vitals 里的 LCP。上线后一周对比:抓取请求数有没有掉、首页和文章页的 LCP 有没有变长。这两项正常,说明限速没有伤到主线流量。
一个务实的配置分级方案
个人站的资源有限,不要一上来就追求完美的规则体系。我的建议是按下面的顺序分级,每级都验证过再加下一级:
- 第一级(必做,零风险):UA 黑名单(空 UA、常见爬虫库标识)+ 敏感路径直接拒绝 + 排除追踪参数后的边缘缓存。这三条几乎不会误伤。
- 第二级(低风险):按 IP 的令牌桶限速,速率放宽到 10 请求/秒、桶容量 30,只对明显的异常速率生效。上线后盯三天 429 日志。
- 第三级(需要观察):搜索页和登录页的严格限速,配合
Retry-After和自定义 429 页面。 - 第四级(兜底):源站
limit_conn+ 连接数上限 + 内核层面的somaxconn、tcp_max_syn_backlog检查。这层不是为了防住攻击,而是为了在攻击期间保住正常用户的可用性。
每一级都记录上线时间和当时的基线数据(QPS、平均响应时间、429 数量、缓存命中率),下次出问题才有对照。这套记录比任何规则都重要——限速规则本身不难写,难的是知道它有没有伤到不该伤的人。
最后一句实话
限速和防刷本质上是成本博弈。CDN 边缘的免费额度有限,超出之后计费按请求数走,被恶意刷量时账单可能比服务器还贵。所以除了技术上的限速,还要在 CDN 控制台设好「带宽/请求数上限告警」和「单 IP 请求数异常告警」,做到流量异常时第一时间知道。技术手段能把大部分噪音挡在外面,但要真正做到睡得着觉,还得靠告警 + 快速响应的组合。