Nginx map 指令实战:变量映射、多条件分流与维护页/A-B 灰度开关全流程

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 变成一张清晰的策略表。

Last modification:October 10th, 2026 at 10:25 pm

Leave a Comment