Nginx map 指令实战:变量映射、多条件分流与维护页/A-B 灰度开关全流程
很多站长写 Nginx 配置时,习惯用一长串 if 来判断条件,结果掉进「if is evil」的坑里——在 location 里用 if 做非 rewrite 的事情,行为往往出人意料。Nginx 官方推荐的替代方案,就是 map 指令。它把「输入变量 → 输出变量」的映射关系抽出来,放在 http 块里集中定义,既避免了 if 的陷阱,又让配置可读、可维护、可复用。这篇文章用几个真实场景,把 map 讲透:多站点分流、基于 UA 的规则、维护页开关、A/B 灰度、防盗链、真实 IP 传递等。
一、map 的基本语法
map 只能放在 http 块中(部分版本支持在 server 里用 map 引用,但定义必须在外层)。语法如下:
http {
map $source_variable $target_variable {
default default_value;
"exact_value" result_value;
~regex_pattern result_value;
hostnames; # 允许使用通配符与正则混合时按主机名匹配
volidate; # 拼写错误:应为 volatile,跳过缓存
}
}要点:
- default 必须存在(除极少数情况),用于兜底。
- 值里有空格或特殊字符要用引号包起来。
- 以
~开头表示正则匹配(PCRE),~*表示忽略大小写。 $target_variable是「新造」的变量,可以在后续任何位置使用。hostnames用来把*.example.com这类通配当作主机名而非普通正则。
关键理解:map 是惰性求值的——只有当某个请求真正用到 $target_variable 时,Nginx 才会去查这张表。所以定义很多 map 不会拖慢没有用到它们的请求。
二、场景一:多域名/多站点分流
一个 IP 上挂多个站点,想让不同域名落到不同的上游或根目录,用 map 最干净:
map $host $site_root {
default /var/www/default;
"blog.example.com" /var/www/blog;
"shop.example.com" /var/www/shop;
"api.example.com" /var/www/api;
}
server {
listen 80 default_server;
server_name _;
root $site_root; # 一个 root 指令搞定所有站点
index index.html index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}比起为每个域名复制一份 server 块,这种写法让公共配置(gzip、日志、安全头)只写一次,改一处全站生效。
三、场景二:基于 User-Agent 的规则
用 map 把 UA 归一化成一个布尔标志,再拿它做判断,逻辑清晰:
map $http_user_agent $is_bad_bot {
default 0;
"~*(scrapy|python-requests|curl|wget)" 1;
"~*(AhrefsBot|SemrushBot|MJ12bot)" 1;
"" 1; # 空 UA 也拦
}
map $http_user_agent $is_mobile {
default 0;
"~*(Android|iPhone|iPad|Mobile)" 1;
}
server {
if ($is_bad_bot) {
return 403;
}
location / {
add_header X-Device $is_mobile;
}
}注意这里 if 用的是 return 403,属于 rewrite 模块允许的用法,安全。真正危险的是 if 里塞 proxy_pass、add_header 这类指令。
四、场景三:一键维护页开关(运维最实用)
值班时想把站点切到维护页,传统做法是改 root 或注释 proxy_pass 再 reload。用 map 可以做到「改一行、reload 即生效」,甚至可以配合一个环境变量文件做半自动开关:
# 从外部文件读取维护开关(改文件后 reload 生效)
map $http_x_maintenance $maintenance_mode {
default 0;
"1" 1;
}
# 或者从 map 里直接写死,注释/取消注释即切换
map $host $maintenance_mode {
default 0;
# "www.example.com" 1; # 打开本行即进入维护模式
}
server {
listen 80;
server_name example.com www.example.com;
# 维护模式下,除白名单 IP 外全部返回 503
if ($maintenance_mode = 1) {
set $maintenance_block 1;
}
location / {
if ($maintenance_block) {
return 503;
}
proxy_pass http://backend;
}
# 自定义维护页
error_page 503 /maintenance.html;
location = /maintenance.html {
root /var/www;
internal;
add_header Retry-After 3600;
}
}503 搭配 Retry-After 头,搜索引擎会知道这是临时状态,比直接 404 或不存在的 502 友好得多。若想做白名单,可以用 map 结合 $remote_addr:
map $remote_addr $is_admin_ip {
default 0;
203.0.113.10 1; # 你的办公 IP
198.51.100.7 1; # 备用 IP
}
server {
if ($maintenance_mode = 1) {
if ($is_admin_ip = 0) {
return 503;
}
}
}五、场景四:A/B 灰度分流
想按一定比例把流量导向新版后端,可以用 map 把来源信息映射成一个 0/1 标志。下面的做法利用 cookie 做「粘性分流」,同一个用户始终命中同一个版本:
map $cookie_ab_group $ab_backend {
"B" "backend_new";
"A" "backend_old";
default "backend_old";
}
upstream backend_old { server 127.0.0.1:8080; }
upstream backend_new { server 127.0.0.1:8081; }
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://$ab_backend;
# 没分配过分组的用户,按 IP 末位分桶并下发 cookie
if ($cookie_ab_group = "") {
add_header Set-Cookie "ab_group=$ab_bucket; Path=/; Max-Age=604800";
}
}
}
# 用 IP 哈希决定默认分桶
map $remote_addr $ab_bucket {
default "A";
~[02468]$ "B"; # IP 末位为偶数 = B 组,约一半流量
}注意:proxy_pass 里用动态变量时,Nginx 不会像静态 upstream 那样在启动时解析并缓存,而是在请求时解析——这通常没问题,但如果 upstream 指向域名,会触发运行期 DNS 解析。稳妥做法是 upstream 用固定 IP 或配合 resolver 指令。
六、场景五:防盗链与 Referer 白名单
map $http_referer $bad_referer {
default 0;
"" 0; # 直接访问(无 Referer)放行
"~*^https?://(www\.)?example\.com/" 0; # 本站放行
"~*^https?://(m\.)?example\.com/" 0;
"~*^https?://(t\.cn|url\.cn)/" 0; # 允许短链
"~." 1; # 有 Referer 但不是白名单 -> 拦
}
server {
location ~* \.(jpg|jpeg|png|gif|webp|mp4|zip)$ {
if ($bad_referer) {
return 403;
}
}
}白名单方式比黑名单安全,避免误伤搜索引擎、短链、无 Referer 的客户端。注意把空的 Referer 设为放行(0),否则用户直接从浏览器地址栏贴图片链接访问也会被拦。
七、场景六:日志、真实 IP 与 404 归一
# 把 UA 归一化成短标签写进日志,便于统计
map $http_user_agent $ua_class {
default "other";
"~*bot|spider|crawl" "bot";
"~*curl|wget" "cli";
"~*firefox" "firefox";
"~*chrome" "chrome";
"~*safari" "safari";
}
log_format custom '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent rt=$request_time '
'ua=$ua_class realip=$http_x_real_ip';
access_log /var/log/nginx/access.log custom;CDN 场景下,后端看到的 $remote_addr 是 CDN 节点 IP,真实用户 IP 在 X-Forwarded-For 里。可以用 map 把可信代理链归一,但更简单的做法是在 http 块里用 real_ip_header X-Forwarded-For 配合 set_real_ip_from,让 $remote_addr 自动变成真实 IP。
八、性能与常见坑
- map 在 http 块,别塞进 server/location:定义位置错了会报
directive is not allowed here。 - 正则不是越多越好:map 里的正则按顺序匹配,前面的命中就不再往后看。把高频精确匹配放前面,能省一点 CPU。
- 变量名不要和内置变量冲突:不要用
$host、$uri这类内置名做目标变量,会覆盖原生行为导致诡异 bug。 - map 结果可以被 rewrite 的 set 覆盖:如果同一变量先用 set 赋值,map 的值会被覆盖,注意执行顺序。
- volatile:默认 map 结果在当前请求内会被缓存,若源变量在请求处理中途变化,需要标
volatile才能每次重新计算。 - 正则里点号要转义:
example.com里的.会匹配任意字符,写成example\.com才精确。
九、调试技巧
map 配错时最难的是「不知道变量到底算成了什么」。用一个 add_header 或 return 把中间变量吐出来验证:
location /debug {
add_header X-Site-Root $site_root;
add_header X-Is-Bot $is_bad_bot;
add_header X-Maint $maintenance_mode;
add_header X-UA-Class $ua_class;
return 200 "ok
";
}
# 命令行查看
# curl -sI http://example.com/debug | grep -i x-确认无误后再把调试 location 去掉。改完 map 记得 nginx -t 自检、nginx -s reload 生效。
九、把 map 和 limit、geo、split_clients 组合起来
map 真正的威力在于它能和 Nginx 的其它模块串联。举几个常见的组合拳,都是靠一个中间变量把两个模块接起来。
第一个是按来源区域限流。先用 geo 模块把 IP 划成「国内」「海外」,再用 map 把区域翻成不同的令牌桶:海外访客给更严的限流,国内正常放行。这样单一 limit_req_zone 不够精细的问题就解决了,因为 limit_req 的 burst 和速率本身不支持按请求动态变化,但你可以在 location 里配多个 zone,用 map 结果选择命中哪一个。
第二个是用 split_clients 做百分比灰度。当你的分流只需要「大约 10% 的流量走新版本」而不管用户一致性时,split_clients "$remote_addr" $variant { 10% new; * old; } 比 map 更直观。split_clients 用一致性哈希,同一个 key 永远落到同一档,天然实现了「同用户粘性」。map 适合精确枚举,split_clients 适合按比例切分,两者互补。
第三个是把 map 结果交给 limit_conn 或 proxy 的 header。比如把识别出的客户端类型(爬虫/手机/桌面)写进 X-Client-Type 传给后端,让应用层根据它渲染不同模板;或者根据客户端类型选择不同的 upstream。这种「Nginx 分层决策、后端只管内容」的架构,能让后端代码大幅简化。
最后一个实用技巧是用 map 实现 IP 黑名单的热更新。把黑名单 IP 写进一个被 map 引用的外部文件(include 或 map ... { include /etc/nginx/blacklist.map; }),新增封禁只需追加一行再 reload,无需改动主配置、无需回滚版本,非常适合应对突发攻击。
十、工程化落地的几条经验
第一,把大配置拆成小文件。map 定义集中放一个 maps.conf,用 include 引入 http 块,各站点配置里只引用变量。这样改一处映射,全站生效,也不会出现同名变量在多个 server 块里重复定义、互相覆盖的混乱。
第二,给变量起有意义的名字并加前缀。比如统一用 $m_site_root、$m_is_bot 这样的命名,一眼就能区分「这是我用 map 造出来的变量」和「这是 Nginx 内置变量」,排查时省很多事。团队协作时尤其重要,避免有人误以为 $m_* 是系统变量去查文档。
第三,版本管理与灰度上线。Nginx 配置本身就是代码,应该纳入 Git。每次改动 map 规则都提一次 commit,写清楚改了哪个映射、为什么改、影响面是什么。上线前先在测试环境 nginx -t 通过、再用 curl -I 逐个验证响应头,确认无误后再 reload 生产。reload 是平滑的,老连接继续用旧配置直到请求结束,所以窗口期很短,但依然建议避开流量高峰。
第四,用 limit_req_zone 的 key 结合 map 变量时注意共享内存。limit_req_zone 定义在 http 块,limit_req 在 location 里生效,如果 key 用了一个 map 造出来的变量,要确保该 map 定义在 zone 之前、且源变量在请求早期就已就绪(如 $remote_addr、$http_user_agent),否则可能拿到空值导致限流失效或误伤。
第五,文档化你的映射表。map 的语义不总是自解释,尤其是正则版本。在每个关键 map 上方写一行注释,说明它解决什么问题、维护人是谁、什么情况下要改,能大幅降低日后的维护成本。配置即文档,注释比任何外部 wiki 都更贴近代码。
十一、小结
map 是 Nginx 里被低估但极其强大的指令。它把散落各处的条件判断收敛成一张中心化的映射表,让多站点分流、UA 规则、维护开关、A/B 灰度、防盗链这些需求都能用声明式的方式表达,彻底避开 if 的坑。记住三条原则:map 定义放 http 块、只做「变量→变量」的纯映射、把结果交给后续指令使用。掌握它之后,你的 Nginx 配置会从一堆散装 if 变成一张清晰的策略表。