灰度发布时,后端服务往往需要知道「这个请求是不是灰度流量」,才能决定走新逻辑还是老逻辑。最省事的入口标记点就在 Nginx:在反向代理层解析 $request_uri,按版本前缀(例如 /v2/、/beta/)打上响应头,让下游网关、应用、甚至前端调试面板都能看到流量归属。本文解决三个具体问题:怎么用 map 稳定地从前缀推导标记、add_header 在哪些情况下会「不生效」、以及上线后如何验证并排查 duplicate header "X-Gray" 之类的高频报错。
一、为什么用 $request_uri 而不是 $uri 做前缀匹配
Nginx 中与请求路径相关的变量有两组,语义差异会直接影响匹配结果:
| 变量 | 含义 | 是否含 query string | 是否解码 |
|---|---|---|---|
$request_uri |
客户端原始请求行里的 URI,未经规范化 | 是 | 否,保留 %2F 等编码 |
$uri |
经过 rewrite 与 internal redirect 处理后的规范化路径 |
否 | 是,已解码 |
$document_uri |
与 $uri 基本等价,历史遗留变量 |
否 | 是 |
做灰度标记时通常希望「客户端请求了什么前缀,就标什么」,所以用 $request_uri 更贴近原始意图。但要注意它带 query string,直接做 map 精确匹配会失败,必须用正则或 ~ 前缀匹配。
如果后端存在
rewrite ... last;且灰度判断希望基于重写后的路径,应改用$uri。两者混用是灰度标错的最常见原因。
1.1 map 的匹配顺序要点
map 中字符串匹配优先于正则匹配;正则按书写顺序,先匹配者生效;default 必须存在,否则未命中时变量值为空字符串(不会报错,但会让 add_header 输出空值头,容易误以为配置没生效)。
二、核心配置:map + add_header 完整示例
下面是一份可直接放入 http 块(map 与 log_format)与 server 块(location)的可用配置,场景是:/v2/ 为灰度、/v1/ 为稳定、其余为 default,同时把标记写入 access log 便于统计。
http {
# 1) 用正则从 $request_uri 提取版本前缀,得到灰度通道名
map $request_uri $gray_channel {
default "stable";
"~^/v2/" "gray";
"~^/beta/" "beta";
"~^/v1/" "stable";
}
# 2) 需要对外暴露的标记头,供后端/网关读取
map $gray_channel $gray_header_value {
default "chan=stable";
"gray" "chan=gray;ratio=10";
"beta" "chan=beta";
}
log_format gray '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'chan=$gray_channel uri=$request_uri';
access_log /var/log/nginx/access.log gray;
upstream app_backend {
server 10.0.0.21:8080 max_fails=3 fail_timeout=10s;
server 10.0.0.22:8080 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://app_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-Gray-Channel $gray_channel;
add_header X-Gray-Channel $gray_header_value always;
add_header X-Gray-From "nginx" always;
# 灰度流量在日志里单独落一份,便于比对
access_log /var/log/nginx/gray_access.log gray;
}
}
}
几个必须解释清楚的点:
add_header的always参数(Nginx 1.7.5+)让响应头在 4xx/5xx 状态下也输出。不加always时,add_header只在 200/201/204/206/301/302/303/304/307/308 这些状态码下生效——这是「灰度头时有时无」的头号原因。proxy_set_header只影响发给上游的请求头,客户端看不到;add_header只影响回给客户端的响应头,后端看不到。二者都要写,语义才完整。access_log写在location里会覆盖http级的access_log,不是追加。如果只想保留一份日志,把log_format换成含$gray_channel的自定义格式即可。
2.1 更严格的匹配:只在「版本号 + 斜杠」处命中
上面的 ~^/v2/ 会误伤 /v2/ 之外形如 /v20/ 的路径吗?不会,因为正则里带了 /。但会误伤 /v2/internal-admin/。如果不希望管理路径被标成灰度,可以在 map 中用带否定预查的正则(PCRE 支持):
map $request_uri $gray_channel {
default "stable";
"~^/v2/(?!internal-admin/).*" "gray";
"~^/beta/" "beta";
}
三、add_header 的继承陷阱与典型报错
3.1 子级 add_header 会整体覆盖父级
add_header 的继承规则与其他指令不同:只要当前层级出现了任意一条 add_header,父级的全部 add_header 都不再生效,不会做合并。常见现象是「在 server 块配了 CORS 头,在某个 location 里加了灰度头,结果 CORS 头没了」。验证命令:
curl -sI -H 'Host: api.example.com' 'http://127.0.0.1/v2/user/1' | sed -n '1,20p'
预期输出中应同时包含:
HTTP/1.1 200 OK
Server: nginx/1.24.0
Date: Tue, 11 Jun 2024 07:31:02 GMT
Content-Type: application/json
X-Gray-Channel: chan=gray;ratio=10
X-Gray-From: nginx
解决方式只有两种:把灰度头和原有头一起写到同一层级;或用 include 把公共头收敛到一个片段文件,各层级统一 include。
# /etc/nginx/snippets/gray-headers.conf
add_header X-Gray-Channel $gray_header_value always;
add_header X-Gray-From "nginx" always;
add_header Access-Control-Allow-Origin "*" always;
location / {
include snippets/gray-headers.conf;
proxy_pass http://app_backend;
}
3.2 重复头导致 502:duplicate header “X-Gray-Channel”
如果上游 Java/Go 服务自己也写了 X-Gray-Channel,就会出现两个同名响应头,浏览器和部分 HTTP 客户端(尤其 requests 的 headers['X-Gray-Channel'])行为不一致。若上游使用的是某些强制校验的框架,可能直接在 Nginx 侧看到:
2024/06/11 15:22:41 [error] 12345#12345: *9021 upstream sent duplicate header line: "X-Gray-Channel: chan=gray", previous value: "X-Gray-Channel: chan=stable" while reading response header from upstream, client: 10.1.2.3, server: api.example.com, request: "GET /v2/user/1 HTTP/1.1", upstream: "http://10.0.0.21:8080/v2/user/1"
处理方案:Nginx 永远用 proxy_hide_header 先把上游同名头去掉,再自己输出,保证单一来源。
location / {
proxy_pass http://app_backend;
proxy_hide_header X-Gray-Channel;
proxy_set_header X-Gray-Channel $gray_channel;
add_header X-Gray-Channel $gray_header_value always;
}
3.3 变量为空导致头被丢弃
当 map 未命中且缺少 default,$gray_header_value 为空字符串时,Nginx 会直接不输出该响应头(Nginx 1.7.5 之后的行为),表面看就是 add_header 失效。排查时先确认变量值:
curl -s -o /dev/null -D - 'http://127.0.0.1/v9/anything' -H 'Host: api.example.com'
# 若无 X-Gray-Channel 输出,检查 map 是否写了 default
四、验证与压测:确认标记与路由一致
4.1 配置语法与重载
nginx -t -c /etc/nginx/nginx.conf
# 输出:nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
nginx -s reload
4.2 批量验证各前缀的标记结果
for p in /v1/order/1 /v2/order/1 /beta/order/1 /other/order/1; do
printf '%-22s -> ' "$p"
curl -s -o /dev/null -D - "http://127.0.0.1$p" -H 'Host: api.example.com' \
| awk 'BEGIN{IGNORECASE=1} /^X-Gray-Channel:/{gsub(/\r/,"");print $0}'
done
期望输出:
/v1/order/1 -> X-Gray-Channel: chan=stable
/v2/order/1 -> X-Gray-Channel: chan=gray;ratio=10
/beta/order/1 -> X-Gray-Channel: chan=beta
/other/order/1 -> X-Gray-Channel: chan=stable
4.3 带 query string 的边界用例
$request_uri 含 query string,验证 ?from=app 时前缀是否仍命中的最直接方式:
curl -s -o /dev/null -D - 'http://127.0.0.1/v2/order/1?from=app&trace=abc' -H 'Host: api.example.com' \
| grep -i '^X-Gray-Channel'
# X-Gray-Channel: chan=gray;ratio=10
另一个容易踩的坑是编码后的路径。GET /%76%32/order/1 在 $request_uri 中仍是 /%76%32/order/1,正则 ~^/v2/ 不会命中;而 $uri 已解码为 /v2/order/1,会命中。若客户端可能出现编码路径,应在 map 两个变量都考虑,或统一用 $uri。
4.4 用 ab 快速看状态码分布
ab -n 2000 -c 50 -H 'Host: api.example.com' 'http://127.0.0.1/v2/order/1' | tail -n 20
关注 Failed requests 与 Non-2xx responses 是否为 0;灰度标记本身不会引发 5xx,出现异常应优先看上游健康检查和 upstream 超时日志。
五、把标记用于日志统计与灰度判定
仅写响应头还不够——灰度发布需要「量」。在 log_format 里带上 $gray_channel 后,可直接统计灰度占比:
# 按通道统计请求量
awk '{for(i=1;i<=NF;i++) if($i ~ /^chan=/){c[$i]++}} END{for(k in c) print k, c[k]}' \
/var/log/nginx/access.log | sort
# 只看灰度通道的 5xx
grep 'chan=gray' /var/log/nginx/access.log | awk '$9 ~ /^5/ {print $7}' | sort | uniq -c | sort -rn | head
如果需要按比例而非按前缀灰度,可以配合 split_clients 做用户维度分流,再与本文的前缀标记叠加使用:
split_clients "${remote_addr}${http_user_agent}" $bucket {
10% "gray";
* "stable";
}
map "$gray_channel:$bucket" $final_channel {
default "stable";
"~^gray:" "gray";
"~:gray$" "gray";
}
这样前缀灰度与百分比灰度取并集,$final_channel 再写入头和日志即可。注意 split_clients 基于哈希,同一用户在同一 UA 下结果稳定,适合做会话一致性要求不高的灰度。
六、常见问题(FAQ)
Q1:配置了 add_header,curl -I 看不到响应头?
三种可能:一是请求返回 4xx/5xx 且没加 always;二是 map 命中空值导致头被丢弃;三是用了 curl -I(HEAD)而 location 内部有 if 或 return 提前结束处理。建议用 curl -s -o /dev/null -D - 发 GET 验证。
Q2:为什么后端收到的 X-Gray-Channel 和客户端看到的不一样?
因为 proxy_set_header 和 add_header 是两个方向。若希望二者完全一致,让它们在同一个 map 变量上取值,如本文的 $gray_channel 与 $gray_header_value,不要各自写死字符串。
Q3:location 里加了灰度头,server 级的 CORS 头消失了?
add_header 不做继承合并,子级一出现就覆盖父级全部。把公共头抽到 snippets 里各层级 include,或者全部写在 server 级、避免在 location 重复声明。
Q4:报错 "duplicate header line" 怎么处理?
上游也输出了同名头。用 proxy_hide_header X-Gray-Channel; 屏蔽上游的,再由 Nginx add_header 统一输出。注意 proxy_hide_header 对 Set-Cookie、X-Accel-* 之外的普通头都有效。
Q5:前缀带大小写,比如 /V2/ 没被识别?
map 中的字符串匹配区分大小写,正则可加 (?i) 或在 map 块首行写 hostnames;(仅对 host 匹配有效)。路径匹配推荐 "~*^/v2/" 忽略大小写。
Q6:能只对灰度通道加缓存控制头吗?
可以,用 map 派生一个值再交给 add_header:
map $gray_channel $gray_cache_control {
default "no-store";
"stable" "public, max-age=60";
}
add_header Cache-Control $gray_cache_control always;
Q7:Nginx 版本有硬性要求吗?
add_header ... always 需要 1.7.5+;proxy_hide_header 自 0.7.x 起就有;map 中的正则 PCRE 预查需要编译时带 PCRE 支持(默认开启)。用 nginx -V 确认版本与编译参数。
小结:灰度标记的关键在于三件事——用 map 从 $request_uri(或 $uri)稳定推导通道值并写 default;用 add_header ... always 保证异常状态也有头;用 proxy_hide_header + 统一 include 片段避免重复与覆盖。把这三步固定进配置模板,灰度发布时的排查成本会显著下降。