Nginx map 指令实战:按 User-Agent 分流移动端并回写上游请求头

很多站点做移动端适配时,第一反应是改后端代码:在 Controller 里读 User-Agent,判断是不是手机,再决定跳 m. 域名还是返回不同模板。这种写法能用,但把「流量分发」这件本该由接入层处理的事塞进了业务代码,结果是每个语言、每个服务都要重复实现一遍判断逻辑,正则写得还不一致;更麻烦的是灰度阶段想临时改规则,得重新发版。 Nginx 的 map

很多站点做移动端适配时,第一反应是改后端代码:在 Controller 里读 User-Agent,判断是不是手机,再决定跳 m. 域名还是返回不同模板。这种写法能用,但把「流量分发」这件本该由接入层处理的事塞进了业务代码,结果是每个语言、每个服务都要重复实现一遍判断逻辑,正则写得还不一致;更麻烦的是灰度阶段想临时改规则,得重新发版。

Nginx 的 map 指令正好解决这个问题。它在 http 上下文里建立一张「变量 → 变量」的映射表,请求进来时一次性求值,后续 server、location、if、proxy_set_header 里都能直接引用。本文给出一套可直接落地的配置:根据 $http_user_agent 判断终端类型,把结果写入自定义变量,再通过 proxy_set_header 回写给上游,同时覆盖 PC 站与移动站分流、缓存 key 隔离、上游读取的完整链路,并列出实跑中会遇到的报错与排查方法。

为什么用 map 而不是 if

先说清楚选型理由,否则后面每个细节都容易被质疑。

  • if 在 location 里属于「rewrite 阶段」指令,官方文档明确写了 if 在 location 上下文中使用有诸多限制,比如 if 内不能再嵌套 if,某些指令在 if 块里行为异常,容易踩坑。
  • map 是编译期构建的哈希表,请求到达时按需惰性求值(只有被引用时才计算),正则匹配走的是预编译的 PCRE,比在每个 if 里现写正则更稳。
  • map 的结果是变量,可以跨 server、location 复用,还能参与 proxy_cache_key、log_format,这是 if 做不到的。

一个常被忽略的点:map 只能定义在 http 上下文,不能写在 server 或 location 里。写错了 Nginx 启动会直接报:

nginx: [emerg] "map" directive is not allowed here in /etc/nginx/conf.d/mobile.conf:12

解决办法就是把 map 块挪到 http { } 内部,通常放在 conf.d/ 下一个单独文件里,include 进去即可。

基础配置:从 User-Agent 到自定义变量

先写核心映射。要点有三个:变量名不能和内置变量冲突、默认值必须给、正则大小写不敏感要显式声明。

# /etc/nginx/conf.d/mobile-map.conf
# 必须在 http 上下文,通过 nginx.conf 的 include conf.d/*.conf 引入

map $http_user_agent $is_mobile {
    default                 0;
    ""                      0;   # 空 UA,按 PC 处理

    # 移动端系统与浏览器关键字
    ~*iphone                 1;
    ~*ipod                   1;
    ~*ipad                   0;   # iPad 默认走 PC 版,按需改 1
    ~*android                1;
    ~*windows\s+phone        1;
    ~*blackberry             1;
    ~*bb10                   1;
    ~*opera\s+mini           1;
    ~*opera\s+mobi           1;
    ~*iemobile               1;
    ~*mobile                 1;
    ~*webos                  1;
    ~*kindle                 1;
    ~*silk                   1;
    ~*playbook               1;

    # 爬虫单独标记,避免被当作手机分流
    ~*(bot|spider|crawler)   2;
}

几个容易写错的细节:

  • ~* 是大小写不敏感的正则匹配,~ 是敏感匹配,~* 后面紧跟模式,中间不能有空格之外的修饰。写成 ~* iphone(多一个空格)虽然多数版本能容忍,但规范写法是不加空格的。
  • 顺序即优先级:map 按书写顺序匹配,命中第一条即返回。所以爬虫规则如果放在 ~*android 后面,一个 Android 上的爬虫(UA 里既有 android 又有 bot)会先被 ~*android 命中。要么把爬虫规则提前,要么像上面这样单独给一个值 2,让业务侧自行决定。
  • default 必须存在,否则未命中时变量值为空字符串,而 proxy_set_header 传空值会导致上游拿不到该头。
  • 变量名 $is_mobile 必须是自定义的。若写成 $mobile 之类,注意别和已有模块的变量撞名。用 nginx -V 看不到变量列表,但启动时报 variables hash 相关错误基本就是撞名。

回写上游请求头:proxy_set_header 的正确用法

变量有了,接下来把它塞进发往上游的请求里。这里最常见的误解是以为设了变量上游就能拿到——不会,必须显式 proxy_set_header。

server {
    listen 80;
    server_name www.example.com;

    location / {
        proxy_pass http://backend_pool;

        # 回写自定义头,值就是 map 的结果
        proxy_set_header X-Device-Type    $is_mobile;
        proxy_set_header X-Original-UA    $http_user_agent;

        # 关键:Host 与真实 IP 的透传(否则上游日志全是 127.0.0.1)
        proxy_set_header Host             $host;
        proxy_set_header X-Real-IP        $remote_addr;
        proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

注意 X-Device-Type 的值是 0 / 1 / 2,如果上游希望拿到语义化的字符串,可以在 map 里直接映射成字符串,也可以再套一层 map 做「值到值」的翻译:

map $is_mobile $device_label {
    default  desktop;
    0        desktop;
    1        mobile;
    2        crawler;
}

# 在 location 中
proxy_set_header X-Device-Type $device_label;

map 的源变量可以是任意变量,包括另一个 map 的结果,这种链式写法在需要保持内部数值语义、又想让上游拿到可读值时很好用。

顺带处理:给上游的 Accept 头与缓存隔离

有些老后端会根据 Accept 头判断要不要返回 WAP 页面,如果你希望统一由 Nginx 决定,可以覆盖掉客户端的 Accept:

map $is_mobile $forward_accept {
    default  "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
    1        "text/vnd.wap.wml,text/html;q=0.9,*/*;q=0.8";
}

proxy_set_header Accept $forward_accept;

原文写的是 default "text/html,...",1 "text/vnd.wap.wml,...",两个分支都要用引号包住,因为值里有逗号和分号,不加引号 Nginx 会把分号当指令结束符,报 nginx: [emerg] unexpected ";"。

更实用的是缓存隔离。如果前面挂了 proxy_cache,PC 和移动端共用同一个 key 会互相污染:

proxy_cache_key "$scheme://$host$request_uri|$device_label";

这样同一个 URL 会生成两份缓存,各自命中。代价是缓存体积翻倍,可接受。

移动站跳转:301 还是内部转发

分流有两种出口,配置完全不同,别混用。

方案 A:302 跳转到 m 域名

server {
    listen 80;
    server_name www.example.com;

    # 只在移动端且不在 m 站时跳转,避免循环
    if ($is_mobile = 1) {
        return 302 https://m.example.com$request_uri;
    }

    location / {
        proxy_pass http://backend_pool;
    }
}

用 302 而不是 301:301 会被浏览器永久缓存,一旦判断规则调整(比如某机型不再算移动端),用户端无法刷新。用 302 保留调整空间。$request_uri 带原始 query string,不要用 $uri,否则参数全丢。

循环跳转是这套方案的头号事故:m 站如果也走同一套 Nginx 配置,UA 判断为移动端后会继续跳自己。规避方式是把判断条件补上 $host:

if ($is_mobile = 1) {
    return 302 https://m.example.com$request_uri;
}

放在 server_name www.example.com 的 server 块里就不会触发 m 站,因为 m 站是另一个 server 块。

方案 B:内部转发到不同的上游

upstream pc_backend {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}
upstream m_backend {
    server 10.0.2.11:8080;
}

map $is_mobile $backend_name {
    default  pc_backend;
    0        pc_backend;
    1        m_backend;
}

server {
    listen 80;
    server_name www.example.com;

    location / {
        proxy_pass http://$backend_name;
        proxy_set_header X-Device-Type $device_label;
    }
}

用变量做 proxy_pass 的目标时,不再支持 upstream 的负载均衡与健康检查(Nginx 无法在运行时解析变量为 upstream 组,除非用 resolver + 变量形式指向域名)。所以上面这种写法实际要求 $backend_name 的值能直接作为地址,或者你在 map 里映射到 127.0.0.1:8081 这样的字面量。若必须保留 upstream 能力,用 PC / m 两个 location 加 if 转发更稳,或者干脆走方案 A。

验证与调试

配置改完先别 reload,用 -t 检查语法:

nginx -t
# 期望输出
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

验证变量取值最直接的办法是加一个调试 location,把变量回显出来(生产环境务必加 IP 白名单或删掉):

location = /debug-ua {
    allow 10.0.0.0/8;
    deny  all;
    add_header Content-Type text/plain;
    return 200 "ua=$http_user_agent\nis_mobile=$is_mobile\ndevice=$device_label\n";
}

用不同 UA 请求:

# 模拟 iPhone
curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15' \
     http://127.0.0.1/debug-ua
# 输出 is_mobile=1 device=mobile

# 模拟桌面 Chrome
curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0' \
     http://127.0.0.1/debug-ua
# 输出 is_mobile=0 device=desktop

# 模拟 Googlebot
curl -s -H 'User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
     http://127.0.0.1/debug-ua
# 输出 is_mobile=2 device=crawler

要看上游到底收到了什么,在上游服务里打印请求头即可。以 Python 为例:

from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = (
            f"X-Device-Type={self.headers.get('X-Device-Type')}\n"
            f"X-Original-UA={self.headers.get('X-Original-UA')}\n"
        ).encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 9000), H).serve_forever()

把 proxy_pass 指向 http://127.0.0.1:9000,curl 一下就能看到 Nginx 实际写出的头。

踩坑清单:真实报错与处理

现象 / 报错 原因 解决
"map" directive is not allowed here 把 map 写进了 server / location 移到 http { } 内,通常独立 conf 文件 include
unknown "is_mobile" variable map 文件没被 include,或变量名拼错 确认 nginx.conf 有 include /etc/nginx/conf.d/*.conf;,且文件顺序在引用它的 server 之前
上游收到的头为空 map 未命中且没有 default,或 proxy_set_header 没写 补 default;确认 header 写在正确的 location 内,父级 location 的 header 会被子 location 覆盖
nginx: [emerg] unexpected ";" map 的值含分号/逗号未加引号 值用双引号包裹
302 跳转无限循环 m 站与 PC 站共用同一 server 块或同一判断 按 server_name 拆开,只在 PC 站做跳转
iPad 被识别为移动端 UA 里含 iPad,被 ~*mobile 或泛规则命中 显式加 ~*ipad 0; 且放在泛匹配之前
改了配置只 reload 未生效 reload 失败时旧配置继续服务 先 nginx -t,再 nginx -s reload,并 tail error.log 确认没有 worker 启动失败

一个完整的可用片段

把上面的内容拼成一份可直接放进 conf.d/ 的配置,PC 站跳 m 站、回写两个头、缓存隔离都包含:

# /etc/nginx/conf.d/mobile.conf

map $http_user_agent $is_mobile {
    default              0;
    ""                   0;
    ~*(bot|spider)       2;
    ~*ipad               0;
    ~*iphone             1;
    ~*ipod               1;
    ~*android            1;
    ~*windows\s+phone    1;
    ~*blackberry         1;
    ~*iemobile           1;
    ~*mobile             1;
}

map $is_mobile $device_label {
    default  desktop;
    0        desktop;
    1        mobile;
    2        crawler;
}

upstream pc_backend {
    server 10.0.1.11:8080;
    keepalive 32;
}

server {
    listen 80;
    server_name www.example.com;

    if ($is_mobile = 1) {
        return 302 https://m.example.com$request_uri;
    }

    location / {
        proxy_pass http://pc_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection        "";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Device-Type     $device_label;
        proxy_set_header X-Original-UA     $http_user_agent;

        proxy_cache my_cache;
        proxy_cache_key "$scheme://$host$request_uri|$device_label";
        proxy_cache_valid 200 10m;
        add_header X-Cache-Device $device_label always;
    }
}

注意 add_header 加了 always 参数,否则 4xx/5xx 响应不会带上这个头,排查时容易误以为配置没生效。

小结

map + proxy_set_header 这套组合的价值在于:把终端识别收敛到一处配置,业务代码只消费 X-Device-Type 这个稳定的契约头。规则调整只改 Nginx 并 reload,不涉及发版;同一个变量还能复用到缓存 key、日志格式、跳转逻辑里,避免了「五个地方各写一套 UA 判断」的维护灾难。落地时守住三条:map 必须在 http 上下文、default 必须给、正则顺序按从精确到宽泛排。

FAQ

map 里的正则能写多个条件(AND)吗?

不能。单个 map 条目只做一次匹配,没有逻辑与。需要组合判断(比如「Android 且不是微信」)时,用两层 map 串联:第一层算出 $is_mobile,第二层以 $is_mobile 和另一个变量为源再映射。Nginx 的 map 每条规则只接受一个源变量,链式 map 是官方支持的常见做法。

为什么上游读不到 X-Device-Type,但本地 curl 能看到?

先确认 proxy_set_header 写在最终命中的那个 location 里。Nginx 的 header 继承规则是:子 location 一旦自己写了任意一条 proxy_set_header,就完全不再继承父级的同类指令。很多「在 server 级写了一次,location 里又写了一次 Host」的场景会导致前者整组丢失。

UA 为空或伪造怎么办?

空 UA 已在 map 里归为 PC,不会误判为移动端。伪造 UA 无法从接入层彻底防住,这属于反爬范畴,需要配合 IP 频率限制(limit_req)和行为校验,不要指望 UA 判断承担安全职责,它只做分流。

map 会不会影响性能?

影响很小。map 建立的是哈希表 + 正则列表,且是惰性求值:只有当某个请求的上下文真正引用了该变量时才计算。一个请求里如果同时引用了 $is_mobile 和 $device_label,只计算一次 $is_mobile,$device_label 复用结果。真正有成本的是正则条数过多,几十条以内无需担心。

能否用 map 匹配请求头以外的条件,比如 Cookie?

可以。map 的源变量不限于 $http_user_agent,$cookie_xxx、$arg_xxx(query string 参数)、$http_xxx(任意请求头)都能用。强制跳移动站的场景里,常见做法是允许用户用 ?force_pc=1 覆盖自动判断,此时再叠一个 map 把 $arg_force_pc 映射为覆盖标志,最后用两层结果决定最终值即可。