图片防盗链与图片分流:Referer 规则、签名 URL 与回源带宽控制

图片被盗链是中小站点最常见、也最难第一时间发现的成本黑洞。很多站长是账单突然翻倍时才回头查日志,发现主站每天几万次图片请求里有相当一部分来自别人的页面。这篇指南讲清三件事:如何用 Referer 规则拦住站外引用,如何用签名 URL 限制单张图片的有效期限,以及如何通过独立图片域名把图片流量从主站分流出去,让回源带宽(单位时间内可传输的数据量)不再被无谓地打满。

下面按”先拦、再限、后分流”展开,每一步都给出可直接套用的 Nginx 配置和验证命令。

图片防盗链与图片分流的整体关系:站外引用被拦截,站内请求经图片域名分发到源站

图片防盗链真正要解决的是三笔账

图片防盗链的核心不是”禁止别人看我图”,而是让图片请求只服务真实访客。判断一个站点是否被大规模盗链,看三个指标就够了:图片出向流量占月度总额的比例、图片目录的并发连接数,以及源站出口带宽的峰值曲线。

举个具体的量级。一张 200 KB 的商品图被五个同行动态引用,每个站点每天展示两万次,就是一百万次额外的图片请求,折算约 190 GB 月度出向流量。静态图片由 Web 服务器直接读盘返回,CPU 压力其实不大,但磁盘 IOPS、并发连接和出口带宽会被同时占走;一旦出口跑满,正常访客的首屏就会从 1 秒变成 4 秒以上,这个损失比带宽费用更难挽回。

所以处理顺序应该是:先把明确的站外引用拦掉(成本最低、见效最快),再给需要授权的图片加上时效签名,最后才谈域名分流和限速。反过来的顺序会让配置越叠越复杂,还容易误伤真实用户。

Referer 规则:先定白名单,再决定拦还是放

Referer 是浏览器请求图片时附带的来源页地址,成本最低。它的问题也很明显:头部可以被客户端改写,脚本请求可以完全不带。所以它只能挡住 90% 顺手引用的人,不算安全边界,真要防批量抓取还得靠签名 URL。

配置上建议用 map 把判断逻辑从 location 里拆出来,比堆多层 if 更好维护,也更少踩 Nginx 的 if 嵌套坑:

map $http_referer $hotlink_block {
    default                              1;
    ""                                   0;
    "~^https?://(www\.)?example\.com/"  0;
    "~^https?://img\.example\.com/"     0;
}

server {
    listen 443 ssl;
    server_name example.com;

    location ~* \.(jpe?g|png|webp|gif|svg)$ {
        if ($hotlink_block) { return 403; }
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
    }
}

正则必须用 ^ 锚定。写成 ~example.com 时,evil-example.com、example.com.attacker.net 都会命中白名单,这是排障里最常见的漏配。空 Referer 是否放行要按业务定:直接放行会被脚本绕过,全部拦截又会让部分微信或 App 内嵌浏览器的首屏图片裂图,折中做法是放行空 Referer,让签名 URL 承担防抓取职责。

改完立刻用两条命令验证两个方向,不要只看浏览器:

curl -s -o /dev/null -w "%{http_code}\n" -e "https://other-site.com/" https://example.com/uploads/demo.webp
curl -s -o /dev/null -w "%{http_code}\n" -e "https://example.com/"   https://example.com/uploads/demo.webp

第一条期望 403,第二条期望 200;若两条都返回 200,通常是规则写在了错误的 location 里。

还有一个容易被忽略的点:只配源站是没用的。请求如果命中 CDN 缓存,根本不会到源站,防盗链规则等于不存在。CDN(内容分发网络)一侧要同步配置回源 Referer 校验或边缘鉴权,具体做法可以参考站内这篇CDN 回源策略与缓存规则设计的思路,把边缘判断和源站判断当成两层防线来设计。

同一张图片在站内页面正常显示,在站外页面被拒绝返回的对比

签名 URL:把”能访问”变成”限时可访问”

当图片本身带有商业价值——会员素材、付费图纸、订单附件、限时活动图——Referer 就不够了,你需要让每个 URL 自带有效期和签名。思路是:服务端用密钥对”过期时间 + 路径”做 HMAC 摘要,客户端拿到的链接只能在设定时间内使用,过期后返回 410。

生成端(Python)可以这样写,注意签名原文的顺序要和 Nginx 侧完全一致:

import hmac, hashlib, time

def sign(path: str, secret: str, ttl: int = 600) -> str:
    exp = int(time.time()) + ttl
    msg = f"{exp}{path}".encode()
    sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
    return f"{path}?exp={exp}&sig={sig}"

校验端用 Nginx 自带的 secure_link 模块,不需要额外跑一个鉴权服务:

location /private-images/ {
    secure_link $arg_sig,$arg_exp;
    secure_link_md5 "$secure_link_expires$uri my-secret";
    if ($secure_link = "")  { return 403; }
    if ($secure_link = "0") { return 410; }
}

secure_link_md5 的原文固定是”过期时间 + URI + 密钥”,密钥写在配置里,签名算法必须与生成端逐字节对齐。上线前先拿一张测试图跑通 200、403、410 三种返回,再批量切换,否则一次拼接错误会让整站私有图片集体 403。

这里有一个必须提前想清楚的权衡:签名 URL 与 CDN 缓存天然冲突。每个访客拿到的 sig 不同,如果把整个 query string 计入缓存 key,同一张图会被缓存成上千个副本,命中率掉到个位数,回源量反而暴涨。解决办法是让 CDN 的缓存 key 忽略签名参数,只按路径缓存;具体到静态资源上云的分工,可以参考对象存储加 CDN 加速实战里的路径与缓存键设计。

签名有效期按场景分级即可:订单附件 10 分钟足够,会员素材 1 到 2 小时,公开商品图不需要签名——把签名用在公开图上只会拖低命中率。

图片分流域名:把图片流量从主站剥出去

拦截和签名都在减少”不该来的请求”,分流做的是另一件事:让”该来的请求”走一条更便宜的路径。做法是给图片单独一个域名,例如 img.example.com,让它指向 CDN 或独立的静态资源站点,主站只管 HTML 和接口。

这样做有三个实打实的好处:图片请求与页面请求的连接数分开,主站的 keepalive 连接池不会被大图占满;静态域名可以设成不携带 Cookie,一个首页三十张图能省掉接近 18 KB 的请求头上行;图片域名能单独设缓存时长和限速策略,不必跟 HTML 的短缓存妥协。

代价是每个新域名多一次 DNS(域名解析系统)解析和 TLS 握手,缓解方式是在页面头部加预连接提示,把握手提前到解析 HTML 阶段:

<link rel="preconnect" href="https://img.example.com" crossorigin>
<link rel="dns-prefetch" href="https://img.example.com">

图片分流的四层结构:主站、图片域名、边缘节点与源站之间的请求路径

分流之后,媒体资源在源站的职责会发生变化:源站从”每次都要返回图片”变成”只在缓存未命中时回源”,容量规划的重点也随之从出口带宽转向缓存命中率和回源并发。图片和视频变重后 CDN 与源站怎么重新分工,这篇分析讲得更完整。

回源带宽控制:三个限流位置

回源带宽是可以算出来的,不是靠感觉调的。回源量 ≈ 图片总请求数 ×(1 – 缓存命中率)× 平均图片体积。一百万次日均图片请求、平均 180 KB、命中率 85%,回源流量约 27 GB/天;命中率只要掉到 70%,同一天的回源量就变成 54 GB,翻倍。所以控制回源的第一手段永远是命中率,而不是限速。

命中率之后才是限流,建议按三个位置分层设置,从外到内收紧:

  • 边缘节点做回源并发上限与合并回源,避免缓存过期瞬间几百个请求同时穿透到源站
  • 源站用 proxy_cache 做二级缓存,命中时直接返回,不打到后端
  • 出口用 limit_conn 和 limit_rate 兜底,防止单个爬虫或大图把出口占满

源站二级缓存的配置大致如下,inactive=7d 表示七天未被访问的副本自动清理,避免磁盘被长期占满:

proxy_cache_path /var/cache/nginx/img levels=1:2 keys_zone=img_cache:100m
                 max_size=20g inactive=7d use_temp_path=off;

location /uploads/ {
    proxy_cache img_cache;
    proxy_cache_valid 200 7d;
    proxy_cache_lock on;
    limit_conn addr 8;
    limit_rate_after 512k;
    limit_rate 1m;
}

limit_rate 一定是兜底而非常规优化。单个连接限到 1 MB/s 对正常访客毫无影响,但能把异常抓取的效率压到原来的几十分之一。上线前后用压测确认承载边界,具体方法见服务器带宽压测:iperf3 与 wrk 评估网站承载量。

五个最常见的误配与验证方法

配置写完不等于生效,下面这几条是排障里反复出现的坑,上线前逐条对一遍:

  • 只配源站不配 CDN:缓存命中时请求不回源,防盗链形同虚设,必须在边缘同步判断
  • 白名单正则未锚定:~example.com 会命中 evil-example.com,改用 ~^https?://(www\.)?example\.com/
  • 签名参数计入缓存 key:命中率掉到个位数,回源量翻几倍,需在 CDN 侧忽略 query
  • 空 Referer 全拦:部分 App 内嵌浏览器首屏图片裂图,改为放行空 Referer 并由签名兜底

验证不要只看浏览器里”图能显示”:用 curl -e 模拟站内和站外来源检查状态码,再看 CDN 回源日志里图片路径的请求量是否下降,最后对比图片目录的日均出向流量,三天趋势比单日数字更能说明问题。

总结:先止血,再优化,最后用数据验收

图片防盗链和图片分流不是两个功能,而是一条处理链:Referer 白名单挡住顺手引用,签名 URL 限制授权访问,独立图片域名把流量引导到正确的层,回源限速兜住异常峰值。任何一环单独上,都只能解决三分之一的问题。

落地节奏建议按风险从低到高排:第一周先开 Referer 白名单并放行空 Referer,几乎不会误伤真实访客,能立刻压掉大部分站外引用;第二到第四周再做签名 URL 与图片分流域名,同步调整 CDN 缓存 key;最后用命中率曲线和回源带宽曲线验收,把图片出向流量下降 30% 以上、CDN 命中率不低于 85%、主站首屏不退化作为通过标准。

如果你的站点跑在 Hostease 的共享主机上,面板里已经提供图片防盗链开关,可以直接打开;如果用的是独立服务器或 VPS(虚拟专用服务器)环境,上面的 Nginx 配置可以直接写进站点配置。推荐每次只改一层、观察一天再进入下一层,这样出问题时能立刻定位到是哪一步引入的。

发表评论