Nginx 限流、IP 白名单与 CORS 跨域配置
文中 IP 与 example.com 等域名均为示例,实际使用时按自己网络环境替换。
Nginx 作为反向代理入口时,有两类配置经常要一起处理:一类是用限流挡住突发流量;另一类是前后端分离后的跨域响应头。下面按这两块记录配置方式和需要注意的取舍。
两个限流模块
Nginx 自带的限流从两个维度起作用:
limit_conn_zone/limit_conn:限制同一个 key(通常是 IP)的并发连接数。适合下载大文件、长连接、防多线程并发下载打满后端。limit_req_zone/limit_req:限制单位时间请求数,比如rate=10r/s表示每秒 10 个请求。适合防接口刷票、防 CC、收敛接口调用频率。
两个 zone 可以叠加使用,分别卡住“连接数”和“请求速率”。
IP 白名单 + 限流
办公网、内网服务器等内部来源通常需要放行,只对公网来源做限流。这个需求用 geo + map 两级处理比较干净:
geo根据来源 IP 命中网段,给变量赋值,白名单设为0,非白名单设为1;map把这个值转成真正给限流模块用的 key:0转成空字符串,1转成$binary_remote_addr。
限流 zone 的 key 用 $limit_key。limit 模块遇到空字符串 key 时不计数,相当于直接放行;非白名单 IP 则按客户端地址计数限流。把“是否白名单”和“用什么做 key”拆成两层后,后续加白名单只动 geo 块,不需要碰限流配置。
http {
# geo:判断来源 IP 是否白名单
geo $limit_whitelist {
default 1; # 默认全部限流
127.0.0.1 0; # 本机回环
192.168.0.0/16 0; # 内网地址段
10.0.0.0/8 0; # 内网地址段
# x.x.x.x 0; # 办公网或专线出口,按需追加
}
# map:白名单标记转成限流 key
map $limit_whitelist $limit_key {
0 "";
1 $binary_remote_addr;
}
# 白名单请求 $limit_key 为空字符串,不计数
limit_conn_zone $limit_key zone=conn_zone:10m;
limit_req_zone $limit_key zone=req_zone:10m rate=10r/s;
server {
# 单 IP 请求频率:基础 10r/s,突发 30,不延迟
limit_req zone=req_zone burst=30 nodelay;
# 单 IP 并发连接数上限
limit_conn conn_zone 15;
limit_req_status 429;
error_page 429 /429.html;
location = /429.html {
default_type text/html;
return 200 "请求频率过高,请稍后再试~
";
}
}
}
几个点值得单独说一下。
rate=10r/s + burst=30 表示速率本身是每秒 10 个,超过速率的请求先进 burst 额度。nodelay 去掉的是排队延迟,也就是 burst 额度内的请求到了就处理,不等待。去掉 nodelay 后,这些请求会排队并按 rate 的节奏放给后端,代价是请求被拉长。是否使用 nodelay,取决于业务更怕延迟还是更怕瞬时打满后端。
limit_conn conn_zone 15 限制的是并发连接数。如果后端有下载类长耗时请求,同一出口 IP 很容易堆连接,这个值需要按实际业务调,不适合原样照抄。
error_page 里的 return 200 比较关键。限流触发时状态码是 429,但进入该 location 后会被 return 200 改写,最终客户端看到的是 200 + 一段 HTML。给浏览器展示“请求频率过高”的友好页面没问题;如果调用方是 API,需要按 429 做重试或降级,就不应该在这里 return 200,应保留 429 状态或直接返回带 429 的响应体。
这里还有一层边界:Nginx 取的是与它直连的客户端地址。如果 Nginx 前面还有 CDN、SLB 等代理,需要在代理层解决真实 IP 透传,否则 geo 白名单和限流 key 都会不准。
CORS 跨域响应头
前后端分域时,浏览器会拦截跨域响应。Nginx 要做的并不是简单加一个固定的 Access-Control-Allow-Origin,而是根据请求里的 Origin 头判断来源是否允许。
这里用 map 动态回显允许的来源:
http {
map $http_origin $cors_origin {
default https://mydomain.example.com;
"~*(^https?://([a-z0-9-]+\.)?example\.com$)" $http_origin;
}
server {
location / {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# 后端如果自带 CORS 头,这里先隐藏,避免响应头重复
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Expose-Headers;
}
}
}
匹配逻辑:https://www.example.com、https://api.example.com 这类请求会把原始 Origin 回显到 Access-Control-Allow-Origin;来自 https://other.com 的请求命中 default,返回主域名。这个主域名与请求来源不一致,浏览器仍然会拦截,并不是说 default 会放行所有来源。
default 不能配成空字符串。空值等于不给客户端输出这个头,跨域请求会直接失败;给一个主域名兜底后,至少不匹配来源拿到的响应头是明确的。
由于这里启用了 Access-Control-Allow-Credentials: true,Access-Control-Allow-Origin 不能用 *,必须返回具体来源。上面的 map 回显方式也基于这个原因。
去掉 always 时,Nginx 只在部分响应码下输出 add_header 指定的头,4xx/5xx 错误响应可能不带 CORS 头。后端一旦报错,浏览器会先把错误响应拦截掉,前端只看到 CORS error,问题很难定位。always 保证所有响应都带上这些头。
proxy_hide_header 解决的是重复头问题。后端应用或网关如果自己返回过 CORS 头,Nginx 转发时再加上一条同名头,客户端收到的就是两个不同值的 Access-Control-Allow-Origin。需要先隐藏后端同名头,再由 Nginx 统一输出。
注意上面的配置只处理了 CORS 响应头,没有覆盖浏览器预检请求(OPTIONS)。如果接口存在自定义 Header、非简单方法等会触发 preflight 的情况,还要单独处理 OPTIONS 请求,并确认后端对预检请求不会执行真正的业务逻辑。
需要复核的配置点
geo和map定义在http块;limit_req、limit_conn在server或location里引用。- 白名单值统一为
0,非白名单为1;map里0对应空字符串才会放行。 limit_req_zone的rate是限流速率,burst和nodelay决定突发处理方式。- 429 错误页若用
return 200改写,最终响应码不是 429,API 场景慎用。 - CORS 的
default不能为空,add_header记得加always。 - 后端已返回 CORS 头时,先
proxy_hide_header再add_header,避免重复。
配置改完先执行 nginx -t,通过后 nginx -s reload 平滑加载。上面写的 10r/s、burst=30、并发 15 这些参数是按示例场景给出的,真实业务需要按流量曲线调整。