按 $request_uri 版本前缀配置 Nginx add_header 实现灰度流量标记

灰度发布时,后端服务往往需要知道「这个请求是不是灰度流量」,才能决定走新逻辑还是老逻辑。最省事的入口标记点就在 Nginx:在反向代理层解析 $request_uri,按版本前缀(例如 /v2/、/beta/)打上响应头,让下游网关、应用、甚至前端调试面板都能看到流量归属。本文解决三个具体问题:怎么用 map 稳定地从前缀推导标记、add_header 在哪

灰度发布时,后端服务往往需要知道「这个请求是不是灰度流量」,才能决定走新逻辑还是老逻辑。最省事的入口标记点就在 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 片段避免重复与覆盖。把这三步固定进配置模板,灰度发布时的排查成本会显著下降。