ARTICLE · 1026163
同一域名,手机和电脑该看哪个站?Nginx/Ingress 设备分发跳转完整配置 + 14 条验证清单
同一域名,手机和电脑该看哪个站?Nginx/Ingress 设备分发跳转完整配置 + 14 条验证清单
版本基线:nginx 1.30.5(stable,2026-09-15 时点;mainline 为 1.31.6)· ingress-nginx controller-v1.15.1(Helm chart 4.15.1,2026-03-19 发布)。文中所有配置项均已用 crawl4ai 抓取 nginx.org 官方模块文档 与 kubernetes.github.io/ingress-nginx 官方注解文档 逐条比对,关键处标注出处。
——◆——
一、一个被「想当然」坑到的需求
需求很常见:同一个业务,PC 站和移动站各一套,希望手机访问 PC 域名自动跳移动站、桌面访问移动站自动跳回 PC 站,爬虫永远不跳,用户能手动切换并记住选择。
需求文档里通常已经写好了方案:「一台 Nginx 上配两个 server 块,用 if 判断 $http_user_agent,return 302 到对面域名」。
但这份方案有个前提没验证:PC 站到底经不经过 Nginx?
实际去核对拓扑才发现,两端的承载形态完全不同:
• 移动站是静态资源,前面挂了一台 Nginx —— 可以按原方案写 map + if;
• PC 站是 Node/PM2 起的 SSR 服务(Nuxt 之类),前面根本没有 Nginx,流量从 LB 直接进 ingress-nginx 再到 Service。
Vault 里记的实测结论是这样的:
Client ─► ingress-nginx ─► pc-service(80→3000) ─► pc-deployment [PM2 cluster + Nuxt SSR]Client ─► nginx(静态) ─► m-site
结论:需求文档里的「两个 Nginx server 块」只对了一半。 PC 侧的跳转逻辑要么写进应用中间件,要么用 ingress-nginx 的 server-snippet 注解。
💡 这是本文最想先讲的一条:先实测「请求到底经不经过这个网关」,再决定跳转逻辑放哪。 DNS 指向、LB VIP、Service 链路都要确认,否则配置写了也不生效——而它「看起来是对的」。
——◆——
二、三条红线(先立规矩,再写配置)
不管落在 Nginx 还是 Ingress,有三条不能破:
红线 1:一律 302,禁止 301
301 是「永久重定向」,会被浏览器和 CDN 永久缓存。一旦某个 PC 用户被错跳一次,后续他自己清缓存都未必能修好,服务端改配置也救不回来;CDN 缓存了 301 更麻烦——所有访问者被批量错跳。
回看官方示例,ingress-nginx 文档里给的 server-snippet 例子恰恰写的是 return 301:
if ( $agentflag = 1 ) { return 301 https://m.example.com;}
照抄它的 301 会踩坑。 设备分发是「按请求特征分流」,本质上是可变的(UA 会变、用户会换设备、策略会调整),必须是 302。
红线 2:爬虫判定必须放在最前面
移动跳转最容易出的 SEO 事故,是把搜索引擎爬虫也跳走。
这里有个隐蔽的坑:百度移动爬虫的 UA 里含有 iPhone 字样。如果先判断「像手机就跳移动站」,百度移动爬虫就会被判成手机、被跳走,进而影响收录。
所以判定顺序必须固定为:爬虫 → 用户 Cookie 偏好 → 设备 UA。
红线 3:保留完整路径与参数
用 $request_uri(官方定义:full original request URI (with arguments)),不要用 rewrite ^(.*) ... $1 这类改写。
$request_uri 天然带着查询参数和原始路径,跳转后用户落在移动站的同一个页面,而不是被丢回首页。
return 302 https://m.example.com$request_uri; # ✅# return 302 https://m.example.com/; # ❌ 路径全丢
三条红线与完整判定链,一张图看懂:

设备分发跳转:判定链与三条红线
——◆——
三、先实测,再选载体:四种场景对号入座
跳转逻辑放哪,取决于后端承载形态,不是取决于「你喜欢哪种写法」。
map + server{} 判定 | 方案 A | |
ingress-nginxserver-snippet,或应用中间件 | 方案 B / C | |
host 判断:经 Nginx 的走 Nginx,其余走 Ingress | ||
m. 子域) vs 同域不同路径(/m/) |
载体选型决策图 —— 先实测,再选落点:

跳转逻辑放哪:先实测再选载体
3.1 场景 A:静态站 + 自建 Nginx
最直接。map 定义变量(必须写在 http 上下文,见下方校验说明),server{} 里做判定。
3.2 场景 B:Node/PM2/Nuxt 无 Nginx
流量经 ingress-nginx,用 server-snippet 注解注入 server{} 级配置。注意两个开关必须同时打开,否则注解被静默拒绝(§5.2 详解)。
3.3 场景 C:应用层中间件
如果团队更愿意把逻辑收在代码里(例如 Nuxt middleware / Express 中间件 / Tomcat Filter),好处是跟业务发布走同一个流程、可单测;代价是每个后端都要实现一遍,且要自己处理 CDN 缓存头。
选型建议:能放网关就别放应用。网关层一处配置、全站生效,且改配置不需要重新发包;应用层只适合「网关确实够不着」的场景。
3.4 场景 D:独立域名 vs 同域路径
• 独立域名(www.example.com ↔ m.example.com):整域 302 最简单,也是本文主线。
• 同域不同路径(/ ↔ /m/):需要按路径前缀分流,且要小心别把 /m/ 自己又跳回去,形成重定向死循环(浏览器会报 ERR_TOO_MANY_REDIRECTS)。此时应把 /m/ 加入排除名单。
——◆——
四、方案 A:Nginx 原生完整配置
4.1 http {} 上下文:先定义所有变量
先说一条官方硬约束:map 指令的 Context 只有 http(nginx.org 官方文档明确标注)。写在 server{} 里会直接报:
nginx: [emerg] "map" directive is not allowed here同理,自定义变量如果只在 server{} 里 if 引用、却没在 http{} 里 map 声明,启动时会报:
nginx: [emerg] unknown "no_redirect" variable完整变量定义(可直接复制):
# ================= http {} 上下文 =================# 1) 设备判定map $http_user_agent $is_mobile { default 0; "~(iphone|ipod)" 1; "~android.mobile" 1; # 注意:有 Android 无 Mobile = 平板 "~(windows phone|iemobile|blackberry)" 1; "~(opera m(ob|in)i|webos)" 1;}# 2) 爬虫判定(必须最先判)map $http_user_agent $is_bot { default 0; "~(bot|spider|crawler|slurp|baiduspider|googlebot|bingbot|360spider|sogou|bytespider|yandex|duckduckbot|facebookexternalhit)" 1;}# 3) 用户手动偏好(Cookie)map $http_cookie $force_ver { default ""; "~site_version=mobile" "m"; "~site_version=desktop" "d";}# 4) 用户已明确选择「留在 PC」map $http_cookie $pc_stay { default 0; "~pc_stay=1" 1;}# 5) 永不跳转的路径白名单(robots / 校验文件 / 静态资源)map $request_uri $no_redirect { default 0; "~^/(robots\.txt|favicon\.ico|sitemap[^/]\.xml)$" 1; "~^/(Baidu_verify_|google|bing|yandex)[a-z0-9_]\.html$" 1; "~\.(js|css|png|jpe?g|gif|svg|ico|woff2?|ttf|map|json|txt)$" 1;}# 6) PC 专用页(手机访问也留在 PC)map $request_uri $pc_only { default 0; "~^/course/detail($|\?)" 1; # 换成你自己的 PC 专用页路径}# 7) 切换端点自身不参与跳转map $request_uri $skip_endpoints { default 0; "~^/(switch|stay-pc)($|\?)" 1;}# 8) 全站回滚开关(平时 1,故障时改 0 即全站回 PC)map $http_user_agent $switch_on { default 1; }
⚠️ 注意$no_redirect里的sitemap[^/]\.xml——用[^/]而不是.*,避免正则跨越路径分隔符造成误匹配。
4.2 移动站 server{}:桌面来了就回 PC
# ================= 移动站 m.example.com =================server { listen 443 ssl; server_name m.example.com; set $go_pc 0; if ($no_redirect = 0) { set $go_pc 1; } # 默认倾向:非白名单一律考虑回 PC if ($is_bot) { set $go_pc 0; } # 爬虫:留 if ($is_mobile) { set $go_pc 0; } # 手机:留 if ($force_ver = m) { set $go_pc 0; } # 用户选过手机版:留 if ($force_ver = d) { set $go_pc 1; } # 用户选过电脑版:回 if ($skip_endpoints) { set $go_pc 0; } # 切换端点:留 if ($switch_on = 0) { set $go_pc 1; } # 总开关关闭:全部回 PC if ($go_pc = 1) { return 302 https://www.example.com$request_uri; } location / { root /var/www/m-site; index index.html; }}
4.3 PC 站 server{}:手机来了就去移动站
# ================= PC 站 www.example.com =================server { listen 443 ssl; server_name www.example.com example.com; set $go_mobile 0; if ($no_redirect = 0) { set $go_mobile $is_mobile; } # 默认倾向:只有手机才去移动站 if ($is_bot) { set $go_mobile 0; } if ($pc_stay) { set $go_mobile 0; } # 用户点过「留在电脑版」 if ($arg_stay = 1) { set $go_mobile 0; } # ?stay=1 一次性留 PC if ($force_ver = m) { set $go_mobile 1; } if ($force_ver = d) { set $go_mobile 0; } if ($pc_only) { set $go_mobile 0; } if ($skip_endpoints) { set $go_mobile 0; } if ($switch_on = 0) { set $go_mobile 0; } if ($go_mobile = 1) { return 302 https://m.example.com$request_uri; } # --- 手动切换端点(回 PC 用)--- location = /switch { if ($arg_v = mobile) { add_header Set-Cookie "site_version=mobile; Domain=.example.com; Path=/; Max-Age=2592000; SameSite=Lax" always; return 302 https://m.example.com$safe_path; } add_header Set-Cookie "site_version=desktop; Domain=.example.com; Path=/; Max-Age=2592000; SameSite=Lax" always; return 302 https://www.example.com$safe_path; } location / { proxy_pass http://127.0.0.1:3000; }}
关于 /switch 端点,有两个细节值得说:
① add_header ... always 不能省。 nginx 官方 add_header 文档写明:默认只在响应码为 200、201、204、206、301、302、303、304、307、308 时才加头。而我们这里正是 return 302——理论上默认也能加。但 always 参数能让它在任何响应码下都生效,避免未来逻辑改动(比如改成 404 分支)导致 Cookie 静默丢失。显式写上 always,是给自己留的安全边际。
② $safe_path 防开放重定向。 如果直接把用户传来的 $arg_p 拼进 Location,攻击者可以构造:
/stay-pc?p=https://evil.com/x.html把用户跳到外部站点。用 map 白名单收敛(只放行以 / 开头的站内相对路径):
map $arg_p $safe_path { default ""; "~^(/[^/].*)$" $1; # 只接受 /xxx,拒绝 //evil.com、https://evil.com}
不匹配时落到 default "",即回本站首页。
4.4 Cookie 共享
跨子域共享 Cookie,Domain 必须写前置点:
add_header Set-Cookie "site_version=desktop; Domain=.example.com; Path=/; Max-Age=2592000; SameSite=Lax" always; • Domain=.example.com —— 前置点让 www. 与 m. 共享;
• Max-Age=2592000 —— 30 天记忆;
• SameSite=Lax —— 兼容 HTTPS 跳转链路的默认安全档位。
——◆——
五、方案 B:ingress-nginx server-snippet 完整配置
5.1 能力边界(先看清哪些注解做不到)
permanent-redirecttemporal-redirect | ||
server-snippet | ||
http-snippet | map 定义 | |
server-snippet |
5.2 两个开关缺一不生效(本节最容易踩)
ingress-nginx 默认禁止 *-snippet 注解。 官方 ConfigMap 文档给出的默认值是:
allow-snippet-annotations | false | Snippet directives are disabled by the Ingress administrator |
annotations-risk-level | High | annotation ... is too risky for environment |
官方对 annotations-risk-level 的定义是「Represents the risk accepted on an annotation」,接受值为 Critical / High / Medium / Low;取值 Medium 时,风险为 High、Critical 的注解都不会被接受。
而 server-snippet 被 ingress-nginx 源码标记为 Critical 风险,因此必须把 annotations-risk-level 提到 Critical:
Low(0) < Medium(1) < High(2) < Critical(3)默认 maxrisk = High(2),而 server-snippet 的 risk = Critical(3)→ 3 > 2,被拒绝
别把 annotations-risk-level 理解成「风险高低」,它是「我能接受多高风险的注解」的上限阈值。
5.3 values.yaml(Helm 部署)
# values.yaml —— ingress-nginx Helm chart 4.15.1 / controller 1.15.1controller:allowSnippetAnnotations: true # ① 允许 snippet 注解config:annotations-risk-level: "Critical" # ② 放行 Critical 风险注解 # ③ 配套:词黑名单,收紧可注入内容(官方建议值)annotation-value-word-blocklist: "load_module,lua_package,_by_lua,location,root,proxy_pass,serviceaccount,{,},',\"" # ④ map 必须在 http 上下文 —— 用 http-snippet 注入http-snippet: | map $http_user_agent $is_mobile { default 0; "~(iphone|ipod)" 1; "~android.mobile" 1; "~(windows phone|iemobile|blackberry)" 1; "~(opera m(ob|in)i|webos)" 1; } map $http_user_agent $is_bot { default 0; "~(bot|spider|crawler|slurp|baiduspider|googlebot|bingbot|360spider|sogou|bytespider|yandex|duckduckbot)" 1; } map $http_cookie $force_ver { default ""; "~site_version=mobile" "m"; "~site_version=desktop" "d"; } map $http_cookie $pc_stay { default 0; "~pc_stay=1" 1; } map $request_uri $no_redirect { default 0; "~^/(robots\.txt|favicon\.ico|sitemap[^/]\.xml)$" 1; "~^/(Baidu_verify_|google|bing|yandex)[a-z0-9_]\.html$" 1; "~\.(js|css|png|jpe?g|gif|svg|ico|woff2?|ttf|map|json|txt)$" 1; } map $request_uri $pc_only { default 0; "~^/course/detail($|\?)" 1; } map $request_uri $skip_endpoints { default 0; "~^/(switch|stay-pc)($|\?)" 1; } map $http_user_agent $switch_on { default 1; }
5.4 Ingress 注解
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: web-pcnamespace: defaultannotations: nginx.ingress.kubernetes.io/server-snippet: | set $go_mobile 0; if ($no_redirect = 0) { set $go_mobile $is_mobile; } if ($is_bot) { set $go_mobile 0; } if ($pc_stay) { set $go_mobile 0; } if ($arg_stay = 1) { set $go_mobile 0; } if ($force_ver = m) { set $go_mobile 1; } if ($force_ver = d) { set $go_mobile 0; } if ($pc_only) { set $go_mobile 0; } if ($skip_endpoints) { set $go_mobile 0; } if ($switch_on = 0) { set $go_mobile 0; } if ($go_mobile = 1) { return 302 https://m.example.com$request_uri; }spec:ingressClassName: nginxrules: - host: www.example.comhttp:paths: - path: /pathType: Prefixbackend:service:name: web-pcport:number: 80
⚠️ 官方文档明确:server-snippet 注解每个 host 只能用一次,且对该 Ingress 的全部 host 生效。多 host 场景请拆成多个 Ingress。5.5 Helm 改配置的正确姿势
如果是 Helm 管理的控制器,不要用 kubectl edit cm:改动会在下次 helm upgrade 被还原;更糟的是,若 values.yaml 与现网已有漂移,升级会把现网覆盖回旧值。
规范流程:改 values.yaml → 渲染比对 → helm upgrade。
# 渲染出 ConfigMap 片段helm template <rel> <chart> -n <ns> -f values.yaml 2>&1 \ | sed-n "/kind: ConfigMap/,/^---/p" > /tmp/rendered-cm.yaml# 拉现网 ConfigMapkubectl-n <ns> get cm <name> -o yaml > /tmp/live-cm.yaml# 只比顶层键,确认「只多出预期的新键」diff <(grep-E "^ [a-z].:" /tmp/live-cm.yaml | sort) \ <(grep-E "^ [a-z].:" /tmp/rendered-cm.yaml | sort)
——◆——
六、五个真实踩过的坑
坑 1:unknown "xxx" variable
自定义变量只写了 server{} 里的 if 引用,忘了在 http{} 里 map 声明。map 只能在 http 上下文(官方 Context 明确)。
自检一行命令:
nginx -T | grep-c '^map ' # 数一数 map 到底被加载了几个
坑 2:/switch 切换端点被 server 级跳转抢走
nginx rewrite 模块的官方执行顺序是:先执行 server 级的 rewrite 指令,再去找 location。
所以 server{} 里那句 if (... ) { return 302 ...; } 会先于location = /switch 命中,切换链路直接失效。
解法:用 $skip_endpoints 把 /switch、/stay-pc 排除在跳转判定之外。
坑 3:CDN 缓存了 302
比浏览器缓存更难查——你本地怎么测都对,线上就是有人被错跳。必须要求 CDN 遵循源站、不缓存 302。
坑 4:CDN 剥离了 Set-Cookie
版本记忆直接失效,用户每次访问都被跳一遍。CDN 侧必须放行 Set-Cookie。
坑 5:爬虫被跳走,收录掉了
判定顺序写成了「先设备、后爬虫」。记住红线 2:爬虫必须最先判。
——◆——
七、回滚开关
兜底原则一句话:宁可全站 PC,不可全站乱跳。
map $http_user_agent $switch_on { default 1; }判定链最后判断它;故障时把 1 改成 0 并 reload/upgrade,一秒钟全站回 PC,不需要回滚任何代码或镜像。
在 Ingress 方案里,它对应改 http-snippet 里的那一行 + helm upgrade。
——◆——
八、14 条验证清单(可照做)
上线前请在本地/测试环境用真实 nginx 跑一遍。用 curl 逐条打(-A 指定 UA):
# 手机 UAcurl-sI-A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" \ https://www.example.com/ | grep-iE "^HTTP|^location"# 百度 PC 爬虫(注意验证不被跳)curl-sI-A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" \ https://www.example.com/ | grep-iE "^HTTP|^location"
iPhone) | ||
Location 保留完整路径参数 | ||
robots.txt / 静态资源 | ||
/switch?v=mobile | Set-Cookie | |
/stay-pc?p=/xxx.html | pc_stay Cookie | |
/stay-pc?p=https://evil.com/x.html | ||
/course/detail,手机访问) | ||
switch_on=0 |
本次落地前,PC 侧 + 移动侧配置在 nginx 1.24.0 上完整跑通,30 个子用例全部 PASS。目的就是避免上机后反复试错——首次上机最容易踩的正是坑 1 的 unknown "no_redirect" variable。
8.1 本文配置的复现验证(nginx 1.30.5)
写这篇文章时,我把正文 §4 的配置原样搬进 nginx.conf,用 Docker 起真实 nginx 1.30.5 复现了一遍:
# 语法校验docker run --rm-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" \ nginx:1.30.5 sh -c 'mkdir -p /tmp/nginx-test && nginx -t'# → syntax is ok / test is successful# 起服务(PC=8082,移动站=8081)docker run -d--name ngx-test-p 18081:8081 -p 18082:8082 \-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.30.5 \ sh -c 'mkdir -p /tmp/nginx-test && nginx -g "daemon off;"'# 跑行为矩阵PC_PORT=18082 M_PORT=18081 bash run_tests.sh
实测结果:
TOTAL=18 PASS=18 FAIL=0✅ 全部通过
覆盖:手机/桌面/平板 UA、百度 PC 爬虫、百度移动爬虫(UA 含 iPhone)、Googlebot Smartphone、路径参数保留、robots.txt 与静态资源白名单、Cookie 偏好(电脑版/手机版)、/switch 双分支、/stay-pc 路径保留、开放重定向拦截。
两点提示:1. 用 Docker 跑时,nginx 的 pid / 日志 / temp 目录必须显式指定到可写路径(如/tmp/nginx-test/)并mkdir -p,否则会报open() "/tmp/nginx-test/nginx.pid" failed——这是环境问题,不是配置问题。2. 主机端口别选 8081:本机常被其他服务(如llama-server)占用,会静默把请求打到别的服务上,测试结果全错。用lsof -nP -iTCP:8081 -sTCP:LISTEN先确认,或干脆映射到 18081/18082。
——◆——
九、版本与信源
| 1.30.5 | CHANGES(2026-09-15) | |
| controller-v1.15.1 | ||
$is_mobilemap 语义 | http;default/hostnames/volatile | ngx_http_map_module |
ifreturn 语义与执行顺序 | server/location/if;server 级先于 location | ngx_http_rewrite_module |
$request_uri | ngx_http_core_module | |
add_header ... always | ngx_http_headers_module | |
allow-snippet-annotations | false | |
annotations-risk-level | High,接受 Critical/High/Medium/Low | |
annotation-value-word-blocklist | "",官方给出建议值 | |
server-snippet | ||
| CVE-2021-25742 | v1.0.0 与 <= v0.49.0;>= v0.49.1 / >= v1.0.1 可缓解 |
安全提醒(CVE-2021-25742)
放开 allow-snippet-annotations 后,任何能创建/更新 Ingress 对象的用户,都可能借 snippet 注入 Nginx 配置,进而读取集群内 Secret。官方评级 High,明确写着:
Multitenant environments where non-admin users have permissions to create Ingress objects are most affected by this issue.
落地前务必评估 RBAC:多租户集群里,不要让普通用户有创建 Ingress 的权限;单租户/受信集群再开。配套把 annotation-value-word-blocklist 按官方建议值收紧。
——◆——
十、小结
1. 先实测再选载体——「一台 Nginx 两个 server 块」是假设,不是事实。
2. 302 不是 301——包括别照抄官方示例里的 return 301。
3. 爬虫判定最先——百度移动爬虫 UA 含 iPhone,顺序错了就掉收录。
4. map 只能在 http;ingress 侧用 http-snippet 放 map,server-snippet 放判定。
5. snippet 两个开关缺一不可:allow-snippet-annotations: true + annotations-risk-level: Critical。
6. 留回滚开关——宁可全站 PC,不可全站乱跳。
7. 多租户慎开 snippet(CVE-2021-25742 + RBAC)。
——◆——
延伸阅读:
K8s 证书翻车合集:apiserver/kubelet/etcd 三处证书各自过期,症状报错 + 5 分钟定位法
K8s 1.37 把 kube-proxy IPVS 判了死刑:还在用 ipvs 的集群,这份迁移 + 避坑清单趁早看
内网穿透怎么选?Tailscale、ngrok、frp、ZeroTier 六方案对比,附选型决策图
——◆——
你遇到过「配置看起来全对、就是有人被错跳」的情况吗?留言说说你的排查经历,一起交流 👇
——◆——
🤔 互动话题
关于同一域名,手机和电脑该看哪个站?Nginx/Ingress 设备分发跳转完整配置 + 14 条验证清单,你有什么踩坑经历或心得?评论区聊聊~
📖 阅读原文 👈 底部查看
👍 点赞 + 在看 + 转发 是对我最大的支持!
本文首发于「不怕慢」