CDN边缘限速与回源保护:把防刷前移一层

为什么个人站也需要 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 影响。我的建议是三层设计:

  1. 返回 429 而不是 403 或 444。403 会被当成永久禁止,爬虫可能不再来;444 是 Nginx 特有的「直接断连」,适合对恶意 IP 用,但对可能是误伤的正常用户很不友好。429 语义明确,附带 Retry-After 头,告诉客户端多久后再试。
  2. 429 页面要有人看的说明。自己写一个静态的 429.html,写清楚「访问过于频繁,请稍后重试」,给个返回首页的按钮。不要用默认的 Nginx 错误页,那会让人以为站点坏了。
  3. 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 有没有变长。这两项正常,说明限速没有伤到主线流量。

一个务实的配置分级方案

个人站的资源有限,不要一上来就追求完美的规则体系。我的建议是按下面的顺序分级,每级都验证过再加下一级:

  1. 第一级(必做,零风险):UA 黑名单(空 UA、常见爬虫库标识)+ 敏感路径直接拒绝 + 排除追踪参数后的边缘缓存。这三条几乎不会误伤。
  2. 第二级(低风险):按 IP 的令牌桶限速,速率放宽到 10 请求/秒、桶容量 30,只对明显的异常速率生效。上线后盯三天 429 日志。
  3. 第三级(需要观察):搜索页和登录页的严格限速,配合 Retry-After 和自定义 429 页面。
  4. 第四级(兜底):源站 limit_conn + 连接数上限 + 内核层面的 somaxconn、tcp_max_syn_backlog 检查。这层不是为了防住攻击,而是为了在攻击期间保住正常用户的可用性。

每一级都记录上线时间和当时的基线数据(QPS、平均响应时间、429 数量、缓存命中率),下次出问题才有对照。这套记录比任何规则都重要——限速规则本身不难写,难的是知道它有没有伤到不该伤的人。

最后一句实话

限速和防刷本质上是成本博弈。CDN 边缘的免费额度有限,超出之后计费按请求数走,被恶意刷量时账单可能比服务器还贵。所以除了技术上的限速,还要在 CDN 控制台设好「带宽/请求数上限告警」和「单 IP 请求数异常告警」,做到流量异常时第一时间知道。技术手段能把大部分噪音挡在外面,但要真正做到睡得着觉,还得靠告警 + 快速响应的组合。

Last modification:October 21st, 2026 at 12:30 pm

Leave a Comment