某天运维群里有人贴出一段Nginx访问日志:同一个URL在几秒内被请求了几百次,来源IP全是自家CDN节点,Nginx负载瞬间飙红,最终触发502雪崩。排查一圈才发现,源站的回源配置指向了另一层CDN,而那层CDN的回源地址又绕了回来,请求就在两层CDN之间来回打转。这类问题的根源在于:CDN节点无法知道一个请求是否已经经过了"自己"。为了解决它,RFC 8586定义了一个专门的HTTP请求头——CDN-Loop,本文结合Nginx日志与实际配置,讲清楚它的原理和落地方法。

一、CDN-Loop到底是什么:从一个请求死循环说起
先看问题是怎么发生的。假设你有一层CDN记为A,源站前面又挂了一层代理记为B。A的回源地址配置成了B的域名,而B的上游又指向A(可能是配置错误,也可能是DNS解析变更导致)。于是用户请求进入A之后,A去回源找B,B又把请求转回A,A发现缓存未命中继续回源B……循环就这样形成,每一次转发都会多消耗一次带宽和计算资源,日志里表现为同一请求的无限重复。
在CDN-Loop出现之前,业界常用的办法是借助Via头来判断,但Via头承载的是代理信息,语义宽泛,且很多代理软件会改写或省略它,可靠性不足。RFC 8586在2019年正式标准化了CDN-Loop头,专门用于CDN回源循环检测。它的语法很简单,结构上是"发起回源的那个CDN的身份标识列表",例如:
GET /index.html HTTP/1.1 Host: www.example-cdn-origin.com CDN-Loop: cdn-provider-a
当CDN A向源站或下一层发起回源请求时,它会在请求头中追加自己的标识。如果CDN A收到的请求中,CDN-Loop头部已经包含自己的标识,说明这个请求之前已经经过自己一次,再转发必然形成循环,此时CDN应当拒绝转发并返回错误,而不是继续往下游送。这个机制和IPv6里的跳数限制、SIP协议里的Max-Forwards思路一致:与其事后发现风暴,不如在协议层就让循环走不下去。
二、在Nginx中记录与传递CDN-Loop头部
标准Nginx本身不会自动处理CDN-Loop(OpenResty或商业版Nginx Plus可以做更灵活的逻辑判断),但我们可以先做两件基础的事:一是把收到的CDN-Loop记录到访问日志里,用于事后排查;二是通过proxy_set_header在转发时附加自己的标识。日志格式可以这样定义:
log_format cdn_trace '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"cdn_loop=$http_cdn_loop" '
'"via=$http_via" '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/cdn_trace.log cdn_trace;
这样每条访问日志都会带上请求进入Nginx时的CDN-Loop值。排查时用一条命令就能看出循环:
# 统计同一个CDN-Loop值出现的次数,重复出现且持续增长说明存在循环
awk -F'"cdn_loop=' '{split($2,a,"\""); print a[1]}' /var/log/nginx/cdn_trace.log | sort | uniq -c | sort -rn | head
如果Nginx作为中间层需要继续向下游转发,应当把自己的标识追加进去。追加而不是覆盖,是关键细节:
location / {
proxy_set_header CDN-Loop "cdn-provider-b, $http_cdn_loop";
proxy_pass http://upstream_origin;
}
注意这里有一个容易踩的坑:proxy_set_header后面的值在配置解析时不展开变量,但$http_cdn_loop是运行时变量,写在这里是生效的。另一个坑是逗号分隔:RFC允许一个CDN-Loop头里携带多个标识,也可以重复出现多个CDN-Loop头,处理逻辑要两种情况都兼容,简单拼接时注意不要产生"cdn-a, , cdn-b"这样的空段。
三、主动阻断循环:判断自己是否已在头部列表中
只记录还不够,真正有价值的做法是检测到循环时直接拒绝请求。原生Nginx没有if正则匹配头部的优雅能力,但可以用map配合$http_cdn_loop实现。假设我们这层的标识是cdn-provider-b,当收到的CDN-Loop里包含这个字符串时,说明请求已经转过一圈了:
map $http_cdn_loop $cdn_loop_hit {
default 0;
"~*cdn-provider-b" 1;
}
server {
listen 80;
server_name origin.example.net;
if ($cdn_loop_hit) {
return 421; # 421 Misdirected Request,语义上贴合"请求被错误导向"
}
location / {
proxy_set_header CDN-Loop "cdn-provider-b, $http_cdn_loop";
proxy_pass http://real_backend;
}
}
返回状态码的选择可以自行权衡。用421(Misdirected Request)语义比较贴切,也有团队选择502或自定义错误页并打点告警。比状态码更重要的是监控:一旦这个if分支被命中,说明线上确实存在循环转发,应当立即触发告警而不是默默吞掉。可以在返回前配合access_log单独记录到一块专用日志,接入告警系统。
还有一点必须强调:map做的是子串匹配,如果标识命名不当会误判。比如标识是cdn-a,而头部里出现了另一个CDN的标识cdn-a-backup,子串匹配会误命中。建议标识全局唯一且包含明确的分隔边界,或者用更精确的正则,例如~*(^|,\s*)cdn-a(\s*,|$)来匹配完整段落。
四、多层CDN架构下的防御要点与常见故障复盘
结合实际故障,多层CDN串联场景下有几个高频踩坑点。第一,DNS层面的回环:CDN A的回源域名解析到CDN B,B又解析回A,双方配置看起来都没错,循环却真实存在。定期用dig核对各层回源地址的实际解析结果,比只看配置文件可靠。第二,缓存失效风暴放大循环:循环本身只是重复请求,一旦叠加缓存刷新或大文件回源,单次循环的代价会被放大几十倍,所以循环检测必须前置到请求入口而不是等后端扛不住。
第三,来自客户端的伪造。任何人都可以手动构造带CDN-Loop头的请求打到你的节点。如果直接信任这个头做拒绝,攻击者反而可以伪造一个包含你标识的请求,让正常缓存节点拒绝服务。因此对来自不可信链路的请求,应先剥离客户端传入的CDN-Loop头(proxy_set_header CDN-Loop "";),只信任自家边缘节点注入的值,或者在剥离前用IP白名单校验上一跳是否真是自己的CDN节点。
最后给一套实用的排查清单:线上同时开启CDN-Loop、Via、X-Forwarded-For三个头部的日志记录;为每一层代理分配唯一且不变更的标识;入口层剥离客户端伪造值;中间层追加标识并在发现自己已存在时立即拒绝;对拒绝事件接入告警。这套组合拳下来,回源循环问题基本可以在几秒内被发现并在协议层被掐断,而不是等日志膨胀、带宽打满之后才事后补救。